diff --git a/.github/actions/install-signpath-module/action.yml b/.github/actions/install-signpath-module/action.yml new file mode 100644 index 00000000000..3f41daca3ab --- /dev/null +++ b/.github/actions/install-signpath-module/action.yml @@ -0,0 +1,197 @@ +name: Install SignPath PowerShell module +description: >- + Installs the SignPath PowerShell module (Get-SignedArtifact) from PSGallery, + falling back to a pinned, hash-verified nupkg from the gallery CDN when the + gallery's package API is unavailable. + +inputs: + fallback-version: + description: Module version fetched directly from the CDN when the gallery API is unreachable. + required: false + default: 4.4.6 + fallback-sha256: + description: >- + SHA-256 of the pinned fallback nupkg. The CDN path bypasses the gallery's own + package validation, so this hash is the only integrity check on that route. + required: false + default: 2487357a9a02c7d985baaf9ebd9158b4ce877316a2d9de3a6e9af1b263c0a32d + +runs: + using: composite + steps: + - name: Install SignPath PowerShell module + shell: pwsh + env: + SIGNPATH_FALLBACK_VERSION: ${{ inputs.fallback-version }} + SIGNPATH_FALLBACK_SHA256: ${{ inputs.fallback-sha256 }} + run: | + $ErrorActionPreference = 'Stop' + # Why: force TLS 1.2 so gallery downloads work on older hosted images. + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + # Why: on some hosted Windows images `Register-PSRepository -Default` + # fails inside the legacy nuget.exe provider with "Missing option value + # for: '-source'", so PSGallery is never registered and the install + # below dies with "No repository with the name 'PSGallery'". PSResourceGet + # (bundled with PowerShell 7.4+) has PSGallery registered by default and + # avoids that code path, so prefer it and fall back to PowerShellGet only + # when it is absent. + $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) + + try { + if ($useResourceGet) { + if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSResourceRepository -PSGallery -Trusted + } else { + Set-PSResourceRepository -Name PSGallery -Trusted + } + } else { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null + if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSRepository -Default -InstallationPolicy Trusted + } + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + } + } catch { + # Why: repository registration also talks to the gallery, so a gallery + # outage can fail here before a single install is attempted. The CDN + # fallback below does not need a registered repository, so keep going. + Write-Warning "PSGallery repository registration failed: $_" + } + + $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars) + $currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator | + Where-Object { + if ([string]::IsNullOrWhiteSpace($_)) { + $false + } else { + $candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars) + $candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase) + } + } | + Select-Object -First 1 + + if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) { + throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.' + } + + $signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath' + + function Test-SignPathModule { + Import-Module SignPath -ErrorAction Stop + Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop + } + + function Remove-SignPathModuleDirectory { + if (Test-Path -LiteralPath $signPathModulePath) { + Write-Warning "Removing current-user SignPath module directory: $signPathModulePath" + Remove-Item -LiteralPath $signPathModulePath -Recurse -Force + } + } + + $installed = $false + + for ($attempt = 1; $attempt -le 3; $attempt++) { + if ($attempt -eq 2) { + Start-Sleep -Seconds 15 + } elseif ($attempt -eq 3) { + Start-Sleep -Seconds 30 + } + + try { + if ($useResourceGet) { + Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop + } else { + Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + } + Test-SignPathModule + $installed = $true + break + } catch { + Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_" + Remove-SignPathModuleDirectory + } + } + + # Why: the gallery's package API (OData search + repository metadata) sits + # behind Azure Front Door and has returned 403/502/504 for every install + # attempt during gallery incidents, which hard-failed the whole Windows + # release job. The CDN that serves the nupkg itself is a separate origin + # and stays up through those incidents, so fall back to a pinned version + # fetched straight from it. The hash pin is mandatory: this route skips the + # gallery's package validation, so an unexpected payload must fail loudly. + if (-not $installed) { + $version = $env:SIGNPATH_FALLBACK_VERSION + $expectedHash = $env:SIGNPATH_FALLBACK_SHA256 + Write-Warning "PSGallery install failed; falling back to pinned SignPath $version from the gallery CDN." + + $nupkg = Join-Path -Path $env:RUNNER_TEMP -ChildPath "signpath-$version.zip" + if (Test-Path -LiteralPath $nupkg) { + Remove-Item -LiteralPath $nupkg -Force + } + + # Why two URLs: the /api/v2/package route 302s to the CDN and can serve + # while the OData search endpoint is failing; the CDN URL is the same + # redirect target reached directly when the api host is down entirely. + $sources = @( + "https://www.powershellgallery.com/api/v2/package/SignPath/$version", + "https://cdn.powershellgallery.com/packages/signpath.$version.nupkg" + ) + + $downloaded = $false + foreach ($source in $sources) { + for ($attempt = 1; $attempt -le 3; $attempt++) { + if ($attempt -gt 1) { + Start-Sleep -Seconds (10 * $attempt) + } + + try { + Invoke-WebRequest -Uri $source -OutFile $nupkg -MaximumRedirection 5 -UseBasicParsing -ErrorAction Stop + $actualHash = (Get-FileHash -LiteralPath $nupkg -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash.ToUpperInvariant()) { + throw "SHA-256 mismatch for $source (expected $expectedHash, got $actualHash)." + } + $downloaded = $true + Write-Host "Downloaded and verified SignPath $version from $source" + break + } catch { + Write-Warning "SignPath CDN download attempt $attempt from $source failed: $_" + if (Test-Path -LiteralPath $nupkg) { + Remove-Item -LiteralPath $nupkg -Force + } + } + } + + if ($downloaded) { + break + } + } + + if (-not $downloaded) { + throw "Unable to install the SignPath PowerShell module: PSGallery installs failed and the pinned $version nupkg could not be downloaded from any source." + } + + Remove-SignPathModuleDirectory + # Why a version-named subdirectory: PowerShell only treats a nested folder + # as a side-by-side module version when the name matches the manifest's + # ModuleVersion, which is what makes `Import-Module SignPath` resolve it. + $versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version + New-Item -ItemType Directory -Path $versionRoot -Force | Out-Null + Expand-Archive -LiteralPath $nupkg -DestinationPath $versionRoot -Force + + # Why: strip nupkg packaging entries so only the module files remain. + foreach ($entry in @('_rels', 'package', '[Content_Types].xml', 'SignPath.nuspec')) { + $path = Join-Path -Path $versionRoot -ChildPath $entry + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } + } + + $manifest = Join-Path -Path $versionRoot -ChildPath 'SignPath.psd1' + if (-not (Test-Path -LiteralPath $manifest)) { + throw "Pinned SignPath nupkg did not contain SignPath.psd1 at $versionRoot." + } + + Test-SignPathModule + } diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 29996c70361..e6b16fab7a7 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -9,6 +9,12 @@ on: - 'config/scripts/build-windows-cli-launcher.mjs' - 'config/scripts/build-windows-cli-launcher.test.mjs' - 'config/scripts/computer-e2e-workflow.test.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-processes.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs' - 'config/scripts/computer-use-modifier-safety.test.mjs' - 'config/scripts/computer-use-skill-guidance.test.mjs' - 'config/scripts/computer-use-smoke.mjs' @@ -59,6 +65,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v6 with: node-version-file: package.json @@ -85,6 +93,8 @@ jobs: config/scripts/build-windows-cli-launcher.test.mjs src/main/ssh/ssh-remote-cli-launcher.test.ts config/scripts/computer-e2e-workflow.test.mjs + config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs + config/scripts/macos-computer-helper-owner-loss-processes.test.mjs config/scripts/computer-use-modifier-safety.test.mjs config/scripts/computer-use-skill-guidance.test.mjs config/scripts/computer-use-smoke.test.mjs @@ -140,6 +150,32 @@ jobs: ORCA_COMPUTER_E2E: '1' run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts + mac-native-owner-smoke: + if: github.event_name == 'pull_request' + runs-on: macos-15 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - uses: pnpm/action-setup@v6 + with: + run_install: false + - run: pnpm install --frozen-lockfile + - name: Owner-loss benchmark process cleanup + run: >- + pnpm vitest run + config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs + config/scripts/macos-computer-helper-owner-loss-processes.test.mjs + - name: Authenticated helper owner-loss smoke + run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1 + - name: Swift tests and signed universal helper verification + run: pnpm verify:computer-native + mac: # macOS Accessibility and Screen Recording require user-granted TCC entries. # Keep this on manual/scheduled permission-bearing runners instead of PR CI. diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml new file mode 100644 index 00000000000..d256faecb88 --- /dev/null +++ b/.github/workflows/hourly-mac-build.yml @@ -0,0 +1,330 @@ +name: Hourly macOS Dev Build + +# Why: gives developers a signed macOS build of main every hour that the in-app +# updater can install directly, without waiting for an RC cut. +# +# Deliberately narrow scope: +# - macOS only. Other platforms keep using RC/stable. +# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI +# and release-cut remain the gates that matter. +# - Signed but NOT notarized. Squirrel.Mac validates the replacement bundle's +# signature, not its notarization, so in-place updates still work while the +# ~10min notary round trip is skipped 24x a day. +# +# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main +# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly +# tags a day would evict every stable/RC entry and break updates for real users. +# +# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use a +# GitHub App installed on orca-hourly with Contents: Read and write. Its private +# key does not expire, unlike a PAT — nothing here needs yearly rotation, and the +# credential belongs to the org rather than to whoever created it. +# +# Provision the two secrets with `bash config/scripts/setup-hourly-release-token.sh`: +# HOURLY_RELEASE_APP_ID the App's numeric id +# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key +# +# Installation tokens live one hour, which is ample here: this job skips tests, +# notarization, and Windows signing entirely, so a run is pack + upload and lands +# well inside that. If one ever did overrun, the release is still an unpublished +# draft at that point, so nothing user-visible breaks. + +on: + schedule: + # Top of every hour. Skipped automatically when main has not moved. + - cron: '0 * * * *' + workflow_dispatch: + inputs: + force: + description: Build even if main has not moved since the last hourly + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: hourly-mac-build + cancel-in-progress: false + +env: + HOURLY_REPO: stablyai/orca-hourly + # Keep ~3 days of history so a regression can be bisected across a weekend. + HOURLY_RETAIN_COUNT: 72 + +jobs: + build-hourly-mac: + if: github.repository == 'stablyai/orca' + runs-on: blacksmith-6vcpu-macos-15 + # Why 120: it must exceed the worst case the retry budgets below can produce + # (install 3x10 + publish 2x25 = 80, plus ~30 for checkout/build/verify), or + # the job is killed mid-retry and no cleanup step runs at all. A typical run + # is far shorter — there is no notarization, Windows signing, or test phase. + timeout-minutes: 120 + env: + NODE_OPTIONS: --max-old-space-size=4096 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + # Why: this job only reads stablyai/orca and never pushes; every write + # goes to the hourly repo through a minted App token passed by env. + # Not persisting the checkout credential shrinks the blast radius if a + # build step is compromised (zizmor: artipacked). + persist-credentials: false + + - name: Mint hourly repo token + id: app_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-hourly + + # Why: main is often idle overnight. Rebuilding an unchanged commit burns a + # runner hour and adds a redundant tag to the retention window. + - name: Check whether main moved since the last hourly + id: freshness + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} + run: | + set -euo pipefail + head_sha="$(git rev-parse HEAD)" + echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT" + if [[ "$FORCED" == "true" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "Forced dispatch; building $head_sha." + exit 0 + fi + # The previous hourly records its source commit in the release body. + # Drafts are excluded: an unpublished leftover never shipped, so treating + # it as "the last build" would skip a build that never actually happened. + last_body="$(gh release list --repo "$HOURLY_REPO" --limit 20 --json tagName,isDraft \ + --jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)" + if [[ -z "$last_body" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "No prior hourly release found; building $head_sha." + exit 0 + fi + last_sha="$(gh release view "$last_body" --repo "$HOURLY_REPO" --json body \ + --jq '.body | capture("commit `(?[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)" + if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then + echo "should_build=false" >>"$GITHUB_OUTPUT" + echo "main is unchanged since $last_body ($last_sha); skipping." + else + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "main moved to $head_sha (last hourly built $last_sha); building." + fi + + - name: Setup pnpm + if: steps.freshness.outputs.should_build == 'true' + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + if: steps.freshness.outputs.should_build == 'true' + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Cache electron-builder downloads + if: steps.freshness.outputs.should_build == 'true' + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-mac- + + - name: Install dependencies + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # Why: signing is what makes an hourly installable over an existing Orca, so + # a missing cert must fail here rather than after a 20-minute build. + - name: Verify macOS signing environment + if: steps.freshness.outputs.should_build == 'true' + run: node config/scripts/verify-macos-release-env.mjs + env: + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Compute hourly version + id: hourly + if: steps.freshness.outputs.should_build == 'true' + run: node config/scripts/hourly-build-version.mjs >>"$GITHUB_OUTPUT" + + - name: Build app + if: steps.freshness.outputs.should_build == 'true' + run: pnpm build:release + env: + NODE_OPTIONS: --max-old-space-size=4096 + # Why: hourly builds are not an official channel — telemetry's transport + # gate accepts only 'stable' or 'rc', so leaving this unset keeps them + # silent, which is correct for unvetted dev artifacts. + ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + + - name: Create hourly release + id: release + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: v${{ steps.hourly.outputs.version }} + SHA: ${{ steps.freshness.outputs.head_sha }} + run: | + set -euo pipefail + short_sha="${SHA:0:12}" + # Why create it up front: electron-builder then uploads into a known tag + # rather than inferring one from package.json. + # + # Why --draft: everything between here and the manifest check is a window + # where the release exists but has no installable assets. A draft is + # absent from the releases list and from listReleaseBuilds, so a job that + # dies in that window — including a hard kill by the job timeout, which + # runs no cleanup step at all — leaves something invisible rather than a + # tag the picker offers and the download 404s on. It is flipped live only + # after the manifest is verified. + gh release create "$TAG" \ + --repo "$HOURLY_REPO" \ + --title "$TAG" \ + --draft \ + --notes "Automated hourly macOS dev build from commit \`$short_sha\`. + + Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA). + + **Unvetted.** No tests ran. Signed but not notarized — installable + through Orca's in-app updater, but a manual download will be + Gatekeeper-quarantined." + echo "tag=$TAG" >>"$GITHUB_OUTPUT" + + - name: Publish hourly macOS artifacts + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + # Why lower than the release pipeline's 3x45: that budget is sized for + # notarization and Windows signing, neither of which runs here. An attempt + # is pack + upload only. + timeout_minutes: 25 + max_attempts: 2 + retry_wait_seconds: 30 + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + env: + # Why: electron-builder's github publisher targets the repo named in the + # config; the token must therefore carry write access to orca-hourly. + GH_TOKEN: ${{ steps.app_token.outputs.token }} + ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }} + ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }} + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Why: the updater resolves a tag, then fetches latest-mac.yml from it. A + # release missing that manifest is a tag the picker offers and the download + # 404s on, so fail loudly instead of leaving a broken entry. + - name: Verify update manifest published + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + assets="$(gh release view "$TAG" --repo "$HOURLY_REPO" --json assets --jq '.assets[].name')" + echo "Published assets:" + echo "$assets" + # Why exit 1 without deleting here: the release is still a draft, so it is + # already invisible to users, and the failure handler below owns cleanup. + # Deleting inline under `set -e` would also let the delete's exit code + # preempt this explicit failure. + for required in latest-mac.yml; do + if ! grep -qx "$required" <<<"$assets"; then + echo "::error::Hourly draft $TAG is missing $required; the updater could not install it." + exit 1 + fi + done + if ! grep -q '\.zip$' <<<"$assets"; then + echo "::error::Hourly draft $TAG has no ZIP artifact for the updater to download." + exit 1 + fi + + # Why this is the last mutating step: publishing the draft is what makes the + # build visible to listReleaseBuilds. Doing it only after the manifest check + # means the picker can never offer a release whose assets are incomplete. + - name: Publish the verified release + id: publish_live + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease + echo "Published $TAG" + + # Why: a draft left behind by a failed publish is invisible to users but still + # holds its tag name, so the next run for the same minute would collide. + # + # Why it is gated on publish_live not having succeeded: a later failure (the + # prune step) must not delete a release that already went live and that users + # may already be installing. A job killed by the outer timeout runs no steps + # at all — which is exactly why the release stays a draft until verified. + # Why cancelled() too: a run stopped from the Actions UI is not a failure(), + # so without it a manual cancel mid-publish would strand the draft. + - name: Discard the draft release on failure + if: >- + (failure() || cancelled()) && steps.release.outputs.tag != '' && + steps.publish_live.outcome != 'success' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -uo pipefail + # No --cleanup-tag: an unpublished draft never created a git tag. + echo "Run failed before publish; discarding draft $TAG" + gh release delete "$TAG" --repo "$HOURLY_REPO" --yes || + echo "::warning::Could not discard draft $TAG; remove it manually." + + - name: Prune old hourly releases + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + set -euo pipefail + # Why: --cleanup-tag so pruning does not leave orphan tags behind that + # keep showing up in tag lists with no release or assets attached. + # Drafts are excluded so retention counts shipped builds only; a stale + # draft is handled by the failure path, not by the retention window. + stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,createdAt,isDraft \ + --jq "map(select(.isDraft | not)) | sort_by(.createdAt) | reverse | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName")" + if [[ -z "$stale" ]]; then + echo "Nothing to prune; at or under $HOURLY_RETAIN_COUNT retained builds." + exit 0 + fi + while read -r tag; do + [[ -n "$tag" ]] || continue + echo "Pruning $tag" + gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \ + echo "::warning::Could not prune $tag" + done <<<"$stale" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4888dd6f3b3..261c8d18adb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -273,6 +273,58 @@ jobs: - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked + - name: Smoke packaged hang watchdog worker + run: xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked + + package_windows: + name: package (windows) + runs-on: windows-2022 + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Cache electron-builder downloads + uses: actions/cache@v5 + with: + path: | + ~\AppData\Local\electron\Cache + ~\AppData\Local\electron-builder\Cache + key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-windows- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package inputs + run: pnpm run build:release + + - name: Prepare Electron native runtime + run: node config/scripts/ensure-native-runtime.mjs --runtime=electron + + - name: Package unpacked app + env: + ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' + run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir + + - name: Smoke packaged CLI + run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked + # Why: regression specs under tests/e2e/** used to merge green without ever # running — e2e.yml only fired on schedule/release (#10518). Path-filter so # ordinary PRs stay light; any E2E suite change still gets a full shard run. @@ -331,6 +383,7 @@ jobs: - shell_contracts - test - package + - package_windows runs-on: ubuntu-latest steps: @@ -350,6 +403,7 @@ jobs: SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} TEST: ${{ needs.test.result }} PACKAGE: ${{ needs.package.result }} + PACKAGE_WINDOWS: ${{ needs.package_windows.result }} run: | for result in \ "$STATIC_ANALYSIS" \ @@ -357,7 +411,8 @@ jobs: "$GIT_COMPATIBILITY" \ "$SHELL_CONTRACTS" \ "$TEST" \ - "$PACKAGE"; do + "$PACKAGE" \ + "$PACKAGE_WINDOWS"; do if [ "$result" != "success" ]; then exit 1 fi diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 7cb1e055e7e..1840514e5fd 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -72,6 +72,11 @@ jobs: tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }} latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }} + # Why: downstream SignPath Slack pings need the cut source (not only the + # new tag) so approvers know what they're signing and who cut it. + source_ref: ${{ steps.resolve.outputs.ref }} + source_sha: ${{ steps.resolve.outputs.sha }} + source_short_sha: ${{ steps.resolve.outputs.short_sha }} steps: # Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs): # this job runs with contents:write and secret scope, so avoid executing @@ -122,9 +127,12 @@ jobs: SERVER_URL: ${{ github.server_url }} run: | set -euo pipefail + input_ref="${INPUT_REF:-main}" sha="$(git rev-parse HEAD)" short_sha="$(git rev-parse --short=12 HEAD)" + echo "ref=$input_ref" >>"$GITHUB_OUTPUT" echo "sha=$sha" >>"$GITHUB_OUTPUT" + echo "short_sha=$short_sha" >>"$GITHUB_OUTPUT" # Why: only push the version-bump commit back to main when the # caller is releasing the exact tip of main. For any older or @@ -142,7 +150,6 @@ jobs: # `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA # inputs it also hides the human-readable names operators need # when auditing RC cuts. - input_ref="${INPUT_REF:-main}" repo_url="${SERVER_URL}/${REPO}" branches="$( @@ -1176,82 +1183,7 @@ jobs: - name: Install SignPath PowerShell module if: matrix.platform == 'win' - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - # Why: force TLS 1.2 so gallery downloads work on older hosted images. - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 - - # Why: on some hosted Windows images `Register-PSRepository -Default` - # fails inside the legacy nuget.exe provider with "Missing option value - # for: '-source'", so PSGallery is never registered and the install - # below dies with "No repository with the name 'PSGallery'". PSResourceGet - # (bundled with PowerShell 7.4+) has PSGallery registered by default and - # avoids that code path, so prefer it and fall back to PowerShellGet only - # when it is absent. - $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) - - if ($useResourceGet) { - if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { - Register-PSResourceRepository -PSGallery -Trusted - } else { - Set-PSResourceRepository -Name PSGallery -Trusted - } - } else { - Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null - if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { - Register-PSRepository -Default -InstallationPolicy Trusted - } - Set-PSRepository -Name PSGallery -InstallationPolicy Trusted - } - - $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) - $documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars) - $currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator | - Where-Object { - if ([string]::IsNullOrWhiteSpace($_)) { - $false - } else { - $candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars) - $candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase) - } - } | - Select-Object -First 1 - - if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) { - throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.' - } - - $signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath' - - for ($attempt = 1; $attempt -le 3; $attempt++) { - if ($attempt -eq 2) { - Start-Sleep -Seconds 15 - } elseif ($attempt -eq 3) { - Start-Sleep -Seconds 30 - } - - try { - if ($useResourceGet) { - Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop - } else { - Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop - } - Import-Module SignPath -ErrorAction Stop - Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop - break - } catch { - if ($attempt -eq 3) { - throw - } - - Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_" - if (Test-Path -LiteralPath $signPathModulePath) { - Write-Warning "Removing current-user SignPath module directory before retry: $signPathModulePath" - Remove-Item -LiteralPath $signPathModulePath -Recurse -Force - } - } - } + uses: ./.github/actions/install-signpath-module # ── Windows inner-binary signing (issue #7785) ───────────────────── # Why: SignPath cannot deep-sign inside NSIS installers, so inner PE @@ -1339,6 +1271,12 @@ jobs: SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }} SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }} TAG: ${{ needs.cut.outputs.tag }} + SOURCE_REF: ${{ needs.cut.outputs.source_ref }} + SOURCE_SHA: ${{ needs.cut.outputs.source_sha }} + SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }} + # Prefer triggering_actor so re-runs name who re-ran; fall back to actor. + CUT_BY: ${{ github.triggering_actor || github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) { @@ -1350,7 +1288,26 @@ jobs: $requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID" } - $message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>" + # Why: approvers need tag + source ref/commit + who cut, not only the tag. + $sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' } + $shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) { + $env:SOURCE_SHORT_SHA + } elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + $env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length)) + } else { + 'unknown' + } + $commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + "<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>" + } else { + "``$shortSha``" + } + $cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) { + "" + } else { + 'unknown' + } + $message = "Orca Windows release ``$($env:TAG)`` inner-binaries signing request (1 of 2) is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>" $payload = @{ text = $message blocks = @( @@ -1523,6 +1480,12 @@ jobs: SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }} SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }} TAG: ${{ needs.cut.outputs.tag }} + SOURCE_REF: ${{ needs.cut.outputs.source_ref }} + SOURCE_SHA: ${{ needs.cut.outputs.source_sha }} + SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }} + # Prefer triggering_actor so re-runs name who re-ran; fall back to actor. + CUT_BY: ${{ github.triggering_actor || github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }} run: | @@ -1537,7 +1500,26 @@ jobs: # Why: releases where inner signing fell through have only this one request. $stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' } - $message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>" + # Why: approvers need tag + source ref/commit + who cut, not only the tag. + $sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' } + $shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) { + $env:SOURCE_SHORT_SHA + } elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + $env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length)) + } else { + 'unknown' + } + $commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + "<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>" + } else { + "``$shortSha``" + } + $cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) { + "" + } else { + 'unknown' + } + $message = "Orca Windows release ``$($env:TAG)`` $stage is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>" $payload = @{ text = $message blocks = @( diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 73d66e466ec..508ac85575b 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -141,23 +141,7 @@ jobs: if-no-files-found: error - name: Install SignPath PowerShell module - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 - $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) - if ($useResourceGet) { - if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { - Register-PSResourceRepository -PSGallery -Trusted - } else { - Set-PSResourceRepository -Name PSGallery -Trusted - } - Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop - } else { - Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop - } - Import-Module SignPath -ErrorAction Stop - Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop + uses: ./.github/actions/install-signpath-module - name: Submit inner binaries signing request id: submit-inner-signing diff --git a/.gitignore b/.gitignore index 00b6dd02894..7f954a8df11 100644 --- a/.gitignore +++ b/.gitignore @@ -44,9 +44,15 @@ package-lock.json *~ # OS +# Keep these in step with isOsMetadataSkillEntryName (skill-package-identity.ts): both disk +# walkers skip a plain file with one of these names, the git-tree producer does not, and +# ignoring them here is what keeps `git add -A` from committing a stray one. *.stackdump .DS_Store +._* Thumbs.db +ehthumbs.db +desktop.ini # Lint/cache .oxlintcache diff --git a/build-plugins/plain-node-entry-guard.ts b/build-plugins/plain-node-entry-guard.ts index 6638cca97c5..a7ad171a402 100644 --- a/build-plugins/plain-node-entry-guard.ts +++ b/build-plugins/plain-node-entry-guard.ts @@ -21,7 +21,6 @@ type OutputChunk = Rollup.OutputChunk const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', - 'main-thread-hang-watchdog-entry', 'computer-sidecar', 'agent-hooks/managed-agent-hook-controls', 'codex/codex-app-server-grant-entry' diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 91cb45da285..39a6df73b05 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -14,10 +14,16 @@ const { const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') +const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') -const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' +// Why: hourly dev builds must carry the *release* identity — same bundle id and +// Developer ID signature — or Squirrel.Mac refuses to swap them over an installed +// Orca. Only notarization is skipped, which in-place updates never check. +const isMacHourly = process.env.ORCA_MAC_HOURLY === '1' +const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION +const hourlyBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION : undefined const appId = 'com.stablyai.orca' const featureWallResources = { from: 'resources/onboarding/feature-wall', @@ -64,7 +70,11 @@ const winSpeechNativeResource = { module.exports = { appId, productName: 'Orca', - ...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}), + ...(hourlyBuildVersion + ? { extraMetadata: { version: hourlyBuildVersion } } + : localBuildVersion + ? { extraMetadata: { version: localBuildVersion } } + : {}), directories: { buildResources: 'resources/build' }, @@ -141,6 +151,7 @@ module.exports = { 'out/main/agent-hooks/**', 'out/main/antigravity/**', 'out/main/claude/**', + 'out/main/claude-accounts/keychain.js', 'out/main/codex/**', 'out/main/copilot/**', 'out/main/cursor/**', @@ -148,12 +159,10 @@ module.exports = { 'out/main/gemini/**', 'out/main/grok/**', 'out/main/hermes/**', - 'out/main/win32-utils.js', 'out/main/daemon-entry.js', 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', 'out/main/parcel-watcher-process-entry.js', - 'out/main/main-thread-hang-watchdog-entry.js', 'out/main/chunks/**', 'resources/**', 'node_modules/ws/**', @@ -179,7 +188,7 @@ module.exports = { ) : join(context.appOutDir, 'resources') if (!existsSync(resourcesDir)) { - return + throw new Error(`Missing packaged resources directory: ${resourcesDir}`) } if (context.electronPlatformName === 'darwin') { const architectureByEnum = { 1: 'x64', 3: 'arm64' } @@ -209,7 +218,16 @@ module.exports = { // arm64=3, universal=4 (universal contains the host slice, so run it). const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 } const hostArchEnum = archEnumByNodeArch[process.arch] - if (context.arch === hostArchEnum || context.arch === 4) { + const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4 + verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, { + executeCommands: canExecuteTargetArch + }) + if (!canExecuteTargetArch) { + console.log( + `[verify-skills-cli-runtime] skipped command probes on cross-arch slice (target ${context.arch}, host ${process.arch})` + ) + } + if (canExecuteTargetArch) { verifyPackagedDaemonEntryBoots(resourcesDir) } else { // Why: a cross-arch slice can't be booted by the host Node, but the @@ -312,7 +330,11 @@ module.exports = { // explicit release path so production artifacts remain strict while dev // artifacts do not fail with broken ad-hoc launch behavior. hardenedRuntime: isMacRelease, - notarize: isMacRelease, + // Why: Squirrel.Mac validates the replacement bundle's signature, not its + // notarization, so hourly builds stay installable while skipping the ~10min + // notary round trip 24x a day. A manually-downloaded hourly zip will be + // Gatekeeper-quarantined — that is the accepted tradeoff for a dev channel. + notarize: isMacRelease && !isMacHourly, extraResources: [ ...commonExtraResources, ...createPackagedRuntimeNodeModuleResources('darwin'), @@ -452,8 +474,11 @@ module.exports = { publish: { provider: 'github', owner: 'stablyai', - repo: 'orca', - releaseType: 'release' + // Why: hourly tags must never enter the main repo's releases atom feed — it + // exposes only the 10 newest entries, so 24 hourly tags a day would evict + // every stable/RC entry and strand users on a feed with nothing to install. + repo: isMacHourly ? 'orca-hourly' : 'orca', + releaseType: isMacHourly ? 'prerelease' : 'release' } } diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index be7d1cd9080..73aca76ac6f 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -251,7 +251,6 @@ inline src/renderer/src/components/tab-bar/TabBar.tsx inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts inline src/renderer/src/components/tab-group/useTabDragSplit.ts inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts -inline src/renderer/src/components/task-page-cache-selectors.ts inline src/renderer/src/components/terminal-pane/TerminalPane.tsx inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 01efbe01e25..312f89c7882 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3079,6 +3079,7 @@ "paired remote terminal ordinary parking", "paired remote terminal bounded scrollback restore", "stalled paired terminal stream recovery", + "snapshot-probe sequence gap recovery", "hidden remote worktree retention", "remote terminal reveal and input", "manual server disconnect" @@ -3087,13 +3088,13 @@ "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", - "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", @@ -3101,6 +3102,7 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", @@ -3128,12 +3130,17 @@ "src/shared/remote-runtime-shared-control-connection.test.ts", "tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts", "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts", + "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts", "tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts", "tests/e2e/paired-remote-terminal-retention-memory.spec.ts", "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", "tests/e2e/terminal-parked-memory.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts", + "assertions": ["replaces a stale paired stream when the PTY snapshot advanced"] + }, { "file": "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts", "assertions": [ @@ -3155,12 +3162,16 @@ "file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts", "assertions": [ "ordinary-parks paired terminals and restores authoritative host scrollback", + "warm-mounted hidden paired streams schedule zero renderer output work under sustained host output", "client recovery completes while every target terminal remains unmounted in the host renderer" ] }, { "file": "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", - "assertions": ["ordinary-parks paired terminals against an isolated headless Orca host"] + "assertions": [ + "ordinary-parks paired terminals against an isolated headless Orca host", + "the headless host shares the zero-hidden-renderer-work and exact-restoration contract" + ] }, { "file": "tests/e2e/terminal-parked-memory.spec.ts", @@ -3231,6 +3242,9 @@ "restarts only the stream whose renderer delivery credit never settles", "probes then restarts a stream when an entered command receives no frames", "keeps a silent responsive stream after its authoritative snapshot probe", + "restarts a stream when the authoritative snapshot advanced without live output", + "establishes a probe baseline before recovering an unsequenced stream", + "keeps a responsive stream when its probe confirms zero output high-water", "classifies a capacity rejection followed by end as recoverable transport pressure" ] }, @@ -3245,6 +3259,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 17.3, + "summary": "The headed paired oracle dropped and acknowledged output only for the original client stream, proved the fixture consumed input while the host model advanced and the client stayed stale, then replaced the stream, repainted exact fixture output, preserved authoritative PTY identity and target-tab cardinality, and resumed live I/O." + }, { "date": "2026-07-29", "runner": "local", @@ -3316,6 +3339,24 @@ "result": "passed", "durationSeconds": 3.16, "summary": "All 27 encrypted shared-control tests passed. The new oracle was red on rc.1-equivalent behavior with the exact Refreshing remote runtime control transport error, then passed with one replacement connection, one delivered host RPC, and zero retained pending bytes." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 17.5, + "summary": "The headed six-PTY oracle kept all warm-mounted hidden streams at zero renderer scheduler enqueues/drains under sustained output and under 500 ms timer lag, then restored the authoritative flood tail and parked/live markers exactly once." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 14.0, + "summary": "The isolated headless host passed the same six-stream zero-hidden-renderer-work, bounded-lag, exact authoritative restoration, same-PTY, and continued-I/O oracle." } ], "runtimeBudget": { @@ -3328,25 +3369,25 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." + "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." }, "performanceBudget": { "required": true, - "evidence": "Capable paired hosts use ordinary parking: the host PTY and bounded 5,000-row provider model remain, while five hidden client xterms and their raw per-PTY multiplex streams are destroyed. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream." + "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.", "Run the paired topology on Windows and Linux and add one live SSH retention run.", "Run the stream-stall oracle with a v1.4.160-rc.3 host and v1.4.160-rc.4 client.", "Add a production-scale paired run with dozens of worktrees and explicit event-loop and renderer-memory budgets.", - "Keep the budget-off control, exact manager count, row-4,000 reveal marker, parked-time output, and post-reveal PTY input in both topology runs." + "Keep the budget-off control, exact manager count, final bounded-tail flood marker, parked-time output, and post-reveal PTY input in both topology runs." ], "knownGaps": [ "Paired reveal restores at most the requested 5,000 rows; older history is intentionally unavailable.", "The scaled paired scenarios use six worktrees and ordinary-park five; they prove real host-backed eviction and buffer release, not a 100-worktree soak.", "Display-off reveal latency is a separate atlas and viewport-reflow class and is not covered by this gate.", "Historical field trace archives are still required to order replay-wedge events against renderer heartbeat loss and deduplicate archived records.", - "The live windows-issues incident proves a healthy host PTY and dead existing client stream, but lacks per-stream ACK counters; ACK starvation is reproduced, not yet proven as that field incident's unique boundary." + "The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism." ], "demotionRule": "Keep experimental or demote to protection none if paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy host bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." }, @@ -6430,18 +6471,19 @@ "platforms": ["macos", "linux", "windows", "mobile"], "providers": ["remote-runtime", "ssh", "local", "daemon"], "coveredPlatforms": ["macos"], - "coveredProviders": [], - "coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets plus paired-renderer parse/discard credit. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.", + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets, paired-renderer parse/discard credit, and negotiated host-side suppression for hidden paired desktop panes. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, mixed-version pause negotiation, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", "https://github.com/stablyai/orca/pull/7009" ], - "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and fallback parity or explicit fallback deprecation.", - "oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.", + "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. A capability-negotiated hidden paired desktop stream must deliver zero raw output frames after host model ingestion, preserve side-effect facts, and restore from one authoritative snapshot before exact live output resumes. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and safe mixed-version fallback.", + "oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Pause three negotiated paired desktop streams, sustain output, and assert zero renderer frames; reveal one and assert one authoritative snapshot followed by exact live bytes with no loss or duplication. Prove old clients continue receiving output and new clients never send pause to old hosts. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1", "ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts", "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", "pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" @@ -6452,6 +6494,7 @@ "src/main/runtime/rpc/terminal-multiplex.test.ts", "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts", "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts", "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", @@ -6483,7 +6526,24 @@ "multibyte live output flushes when encoded bytes reach the batch budget", "adaptive credit grows only after ACK, stays globally bounded, and drains pending streams round-robin", "send and recovery serialization failures detach once instead of leaking credit or retrying forever", - "32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter" + "32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter", + "three negotiated hidden desktop streams emit zero output frames under sustained load while an unpaused stream remains live", + "an older client that omits pause negotiation continues receiving output" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "a runtime-owned hidden pane pauses output, consumes status/title/theme facts, and keeps input writable", + "reveal resumes before one authoritative snapshot and exact live output with no hidden raw write, loss, or duplication", + "dispose releases pause and unregisters the fact consumer" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", + "assertions": [ + "desired pause reapplies after either capability/snapshot ordering and across reconnect", + "reconnect delivers each authoritative snapshot and post-resume live marker exactly once" ] }, { @@ -6501,6 +6561,12 @@ "malformed frames, malformed transformed output, disposal, late parse, renderer delivery failure, and ACK transport failure release credit or close the owning stream without reordering output" ] }, + { + "file": "src/renderer/src/runtime/runtime-terminal-stream.test.ts", + "assertions": [ + "drops output only from the armed stream when its replacement reuses the stream ID" + ] + }, { "file": "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts", "assertions": [ @@ -6552,6 +6618,15 @@ "result": "passed", "durationSeconds": 0.91, "summary": "The 1/20/100 ms RTT x 1/4/8 viewer matrix stayed at or below 8 MiB in flight with zero completion spread. At 100 ms it modeled 18.8 MiB/s per viewer for 1-4 viewers and 9.7 MiB/s for 8 viewers. Measured @xterm/headless parsing was 26.7/63.6/95.3 aggregate MiB/s for 1/4/8 viewers, with 84.4/236.5/336.0 ms CPU and 2893/13409/28991 KiB retained heap for 4 MiB per viewer." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 9.05, + "summary": "All 58 terminal multiplex tests passed, including the three-stream hidden-output oracle. Its byte-identical title-selected command failed on current origin/main and failed again with the two causal host/protocol files reverted because hidden Output frames reached the renderer." } ], "runtimeBudget": { @@ -6564,11 +6639,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests cover mobile initial snapshot byte downgrade, requested binary snapshot byte downgrade, pending live-output cap while snapshot loads, output chunk size, output coalescing, abort cleanup, and stale resize re-stream suppression. Needs intentional-break proof plus JSON fallback coverage before promotion." + "evidence": "The hidden paired-output oracle is red on origin/main, green on the candidate, and red with its causal host/protocol files reverted. It proves three paused streams emit no raw frames while an unpaused control remains live; compatibility tests preserve old-client/new-host output and prevent new-client/old-host pause opcodes. The broader gate remains partial pending legacy JSON fallback and live Android coverage." }, "performanceBudget": { "required": true, - "evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB. The 2026-07-22 run modeled 9.7 MiB/s/viewer at 100 ms with eight viewers and measured @xterm/headless at 95.3 aggregate MiB/s, 336.0 ms parser CPU, and 28991 KiB retained heap for eight 4 MiB viewers." + "evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). Negotiated hidden paired streams add zero renderer output frames or scheduler drains after host ingestion; headed and headless six-stream runs enforce under 500 ms timer lag. The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB." }, "promotionCriteria": [ "Gate binary multiplex first.", diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index d6db7c5bbe9..a4230668bc7 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => { expect(triggerPaths).toEqual( expect.arrayContaining([ 'config/scripts/computer-e2e-workflow.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', 'config/scripts/computer-use-modifier-safety.test.mjs', 'config/scripts/computer-use-skill-guidance.test.mjs', 'config/scripts/computer-use-smoke.mjs', @@ -70,9 +71,14 @@ describe('computer-use e2e workflow', () => { const nativeSmokeRuns = workflow.jobs['native-smoke'].steps .map((step) => step.run) .filter((run) => typeof run === 'string') + const checkout = workflow.jobs['native-smoke'].steps.find( + (step) => step.uses === 'actions/checkout@v6' + ) const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run')) const expectedRegressionFiles = [ 'config/scripts/computer-e2e-workflow.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs', 'config/scripts/computer-use-modifier-safety.test.mjs', 'config/scripts/computer-use-skill-guidance.test.mjs', 'config/scripts/computer-use-smoke.test.mjs', @@ -106,12 +112,68 @@ describe('computer-use e2e workflow', () => { 'src/shared/remote-runtime-client.test.ts' ] + expect(checkout.with['persist-credentials']).toBe(false) expect(regressionRun).toBeTruthy() for (const file of expectedRegressionFiles) { expect(regressionRun).toContain(file) } }) + it('builds and tests the macOS helper on pull requests without TCC e2e', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') + ) + const job = workflow.jobs['mac-native-owner-smoke'] + const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string') + const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6') + + expect(job.if).toBe("github.event_name == 'pull_request'") + expect(job['runs-on']).toBe('macos-15') + expect(checkout.with['persist-credentials']).toBe(false) + expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1') + const cleanupRun = runs.find((run) => + run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs') + ) + expect(cleanupRun).toContain( + 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs' + ) + expect(runs).toContain('pnpm verify:computer-native') + expect(runs.join('\n')).not.toContain('test:e2e:computer') + expect(workflow.on.pull_request.paths).toEqual( + expect.arrayContaining([ + 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs', + 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs', + 'config/scripts/macos-computer-helper-owner-loss-processes.mjs', + 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs' + ]) + ) + }) + + it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => { + const benchmark = readFileSync( + join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'), + 'utf8' + ) + const cleanup = readFileSync( + join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'), + 'utf8' + ) + + expect(benchmark).toContain('spawnBenchmarkProcess(executable, [launcherDir]') + expect(benchmark).toContain("stdio: ['ignore', stdoutDescriptor, stderrDescriptor]") + expect(benchmark).toContain('cleanupOwnerLossTrial({') + const parseIndex = benchmark.indexOf('parseBenchmarkTrialResult(serializedResult)') + const cleanupIndex = benchmark.indexOf('cleanupOwnerLossTrial({') + expect(parseIndex).toBeGreaterThanOrEqual(0) + expect(cleanupIndex).toBeGreaterThanOrEqual(0) + expect(parseIndex).toBeLessThan(cleanupIndex) + expect(benchmark).toContain('trialCleanupSha256: artifactSha256(trialCleanupPath)') + expect(cleanup).toContain('killRecordedAndMatchingProcesses(options.recordPath') + expect(cleanup).toContain("signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL'") + }) + it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 9712e5edace..b9c4a499118 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -19,6 +19,39 @@ const { verifyPackagedMainRuntimeDeps } = require('../packaged-runtime-node-modules.cjs') +const MUTABLE_BUILD_ENV = [ + 'ORCA_MAC_HOURLY', + 'ORCA_MAC_RELEASE', + 'ORCA_HOURLY_BUILD_VERSION', + 'ORCA_LOCAL_BUILD_VERSION' +] + +/** Re-requires the config under a temporary env, then restores env and module cache. */ +function withEnv(env, assert) { + const configPath = require.resolve('../electron-builder.config.cjs') + const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]])) + try { + for (const key of MUTABLE_BUILD_ENV) { + delete process.env[key] + } + Object.assign(process.env, env) + delete require.cache[configPath] + assert(require('../electron-builder.config.cjs')) + } finally { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } +} + +const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert) + describe('electron-builder config', () => { it('keeps the packaged app identity aligned with local-build validation', () => { expect(electronBuilderConfig.appId).toBe( @@ -154,7 +187,12 @@ describe('electron-builder config', () => { it('unpacks the compiled CommonJS boundary with CLI runtime files', () => { expect(electronBuilderConfig.asarUnpack).toEqual( - expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**']) + expect.arrayContaining([ + 'out/package.json', + 'out/cli/**', + 'out/shared/**', + 'out/main/claude-accounts/keychain.js' + ]) ) }) @@ -166,11 +204,9 @@ describe('electron-builder config', () => { ) }) - // Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE - // fork resolves the entry from app.asar.unpacked — inside the asar it never runs. - it('unpacks the forked main-thread hang-watchdog entry', () => { - expect(electronBuilderConfig.asarUnpack).toEqual( - expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js']) + it('keeps the worker-thread hang watchdog inside app.asar', () => { + expect(electronBuilderConfig.asarUnpack).not.toContain( + 'out/main/main-thread-hang-watchdog-entry.js' ) }) @@ -264,6 +300,52 @@ describe('electron-builder config', () => { } }) + // Why: Squirrel.Mac swaps the .app in place only when the replacement carries the + // same bundle id and a valid Developer ID signature. A hourly built on the local + // (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real + // Orca — the whole point of the channel. + it('builds hourly artifacts with the release signing identity', () => { + withHourlyEnv((config) => { + expect(config.mac.appId).toBeUndefined() + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.forceCodeSigning).toBe(true) + }) + }) + + // Why: notarization is the one release step hourly skips; in-place updates never + // check it, and 24 notary round trips a day is the cost being avoided. + it('skips notarization only for hourly builds', () => { + withHourlyEnv((config) => { + expect(config.mac.notarize).toBe(false) + }) + withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { + expect(config.mac.notarize).toBe(true) + }) + }) + + // Why: the main repo's releases atom feed exposes only its 10 newest entries. + // Publishing 24 hourly tags a day there would evict every stable/RC entry and + // break update checks for every real user. + it('publishes hourly builds to the separate hourly repo', () => { + withHourlyEnv((config) => { + expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' }) + }) + expect(electronBuilderConfig.publish).toMatchObject({ + repo: 'orca', + releaseType: 'release' + }) + }) + + it('stamps hourly packages with the hourly version', () => { + withEnv( + { ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' }) + } + ) + }) + it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) @@ -477,6 +559,20 @@ describe('electron-builder config', () => { } }) + it('fails when the packaged resources directory is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) + try { + await expect( + electronBuilderConfig.afterPack({ + appOutDir: root, + electronPlatformName: 'win32' + }) + ).rejects.toThrow(/Missing packaged resources directory/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it.skipIf(process.platform === 'win32')( 'marks packaged Unix CLI launchers executable', async () => { @@ -500,6 +596,19 @@ describe('electron-builder config', () => { 'console.error("Usage: daemon-entry "); process.exit(1)\n', 'utf8' ) + const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true }) + await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8') + await writeFile( + join(unpackedCliDir, 'index.js'), + [ + 'const args = process.argv.slice(2)', + "if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))", + "else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)", + 'else console.log(JSON.stringify({ executed: false }))' + ].join('\n'), + 'utf8' + ) await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) await electronBuilderConfig.afterPack({ diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index eed66def4ed..1154ec9cc57 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -1,6 +1,9 @@ import { readFileSync } from 'node:fs' +import { EventEmitter } from 'node:events' +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' -import { electronViteConfig } from '../../electron.vite.config' +import { createBootstrapFatalExitBanner, electronViteConfig } from '../../electron.vite.config' +import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard' const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8') const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8') @@ -29,6 +32,35 @@ describe('Electron Vite output contract', () => { expect(external('node:fs', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) + expect(external('zod', undefined, false)).toBe(false) + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') + }) + + it('exits when a static import fails before source error guards load', () => { + const processMock = new EventEmitter() as EventEmitter & { + exit: (code: number) => void + exitCode?: number + } + let scheduledExit: (() => void) | null = null + let exitedWith: number | null = null + processMock.exit = (code) => { + exitedWith = code + } + const context = { + process: processMock, + setImmediate: (callback: () => void) => { + scheduledExit = callback + } + } + + runInNewContext(createBootstrapFatalExitBanner(), context) + processMock.emit('uncaughtException', new Error("Cannot find module 'zod'")) + + expect(processMock.exitCode).toBe(1) + expect(scheduledExit).not.toBeNull() + scheduledExit?.() + expect(exitedWith).toBe(1) + expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY) }) it('isolates renderer entry side effects behind strict facades', () => { diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index ee74cc85bcb..899cb6436df 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -124,6 +124,18 @@ function gitTreeSha(entries) { return hashDirectory(root).toString('hex') } +// Why: kept in step with isOsMetadataSkillEntryName in src/main/skills/skill-package-identity.ts. +// The scanner ignores these because the OS writes them into a live install; the generator +// ignores them so a stray one in a working tree cannot be committed into the manifest as +// content no user could ever match. Skipped rather than rejected: the file is not the +// developer's doing, so failing the build over it would be hostile. +const OS_METADATA_FILE_NAMES = new Set(['.ds_store', 'thumbs.db', 'ehthumbs.db', 'desktop.ini']) + +function isOsMetadataSkillEntryName(name) { + const folded = name.toLocaleLowerCase('en-US') + return OS_METADATA_FILE_NAMES.has(folded) || folded.startsWith('._') +} + async function collectPackageFiles(packageRoot) { const files = [] const caseFoldedPaths = new Map() @@ -135,6 +147,14 @@ async function collectPackageFiles(packageRoot) { entries.sort((left, right) => compareCodeUnits(left.name, right.name)) for (const entry of entries) { const absolutePath = path.join(directory, entry.name) + const fileStat = await lstat(absolutePath) + // Only a plain file is OS-authored, so the type decides and not the name alone: a + // directory or link wearing the name would otherwise drop its subtree out of the + // manifest and skip the guards below. Decided before the case-fold map so two + // spellings of one sidecar cannot collide. + if (isOsMetadataSkillEntryName(entry.name) && fileStat.isFile()) { + continue + } const relativePath = path.relative(packageRoot, absolutePath) assertSafeRelativePath(relativePath) const manifestPath = relativePath.split(path.sep).join('/') @@ -144,7 +164,6 @@ async function collectPackageFiles(packageRoot) { throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`) } caseFoldedPaths.set(foldedPath, manifestPath) - const fileStat = await lstat(absolutePath) if (fileStat.isSymbolicLink()) { throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`) } diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs index 3069c1cbb68..e8a88b4636c 100644 --- a/config/scripts/generate-skill-bundle-manifest.test.mjs +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -14,6 +14,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { observeSkillPackage } from '../../src/main/skills/skill-package-identity' import { appendReleaseRow, assertReleasedHistoryPreserved, @@ -436,6 +437,90 @@ describe('skill bundle manifest generator', () => { ) }) + it('ignores OS-authored sidecars a working tree may carry', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n') + await mkdir(path.join(packageRoot, 'references')) + await writeFile(path.join(packageRoot, 'references', 'guide.md'), 'nested\n') + const pristine = await collectPackageFiles(packageRoot) + expect(pristine.map((file) => file.path)).toEqual(['SKILL.md', 'references/guide.md']) + // Finder writes .DS_Store into any browsed folder, and it is gitignored — so without + // this the committed artifacts read as stale and lint fails for that developer, while + // the scanner would have no snapshot a real install could match. + await writeFile(path.join(packageRoot, '.DS_Store'), Buffer.from([0, 1, 2, 3])) + await writeFile(path.join(packageRoot, '._SKILL.md'), Buffer.from([0, 5])) + await writeFile(path.join(packageRoot, 'Thumbs.db'), Buffer.from([9])) + // Nested folders get browsed too, and a sidecar there shifts the same index-aligned list. + await writeFile(path.join(packageRoot, 'references', '.DS_Store'), Buffer.from([7])) + + expect(await collectPackageFiles(packageRoot)).toEqual(pristine) + }) + + it('still records an unexpected file that is not OS metadata', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n') + await writeFile(path.join(packageRoot, 'payload.sh'), 'echo hi\n') + + expect((await collectPackageFiles(packageRoot)).map((file) => file.path)).toEqual([ + 'SKILL.md', + 'payload.sh' + ]) + }) + + it('keeps guarding a directory or link that only wears an OS metadata name', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n') + // Only plain files are OS-authored, so a subtree behind one of these names is real + // content that must stay in the manifest instead of shipping unrecorded. + await mkdir(path.join(packageRoot, '.DS_Store')) + await writeFile(path.join(packageRoot, '.DS_Store', 'payload.sh'), 'echo hi\n') + + expect((await collectPackageFiles(packageRoot)).map((file) => file.path)).toEqual([ + '.DS_Store/payload.sh', + 'SKILL.md' + ]) + + if (process.platform !== 'win32') { + await rm(path.join(packageRoot, '.DS_Store'), { recursive: true }) + await symlink('SKILL.md', path.join(packageRoot, '._SKILL.md')) + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Symlink is not allowed in a shipped skill' + ) + } + }) + + // Why: the predicate is hand-copied from the scanner, and an asymmetric skip is worse than + // no skip — one side would bake in content the other can never observe, leaving every + // install permanently unrecognized. Compared through both walkers so ordering and the + // case-fold map are covered too, not just the name test. + it('skips exactly the names the scanner skips', async () => { + const packageRoot = await createPackage() + for (const name of [ + 'SKILL.md', + '.DS_Store', + '.ds_store', + '.DS_STORE', + 'Thumbs.db', + 'THUMBS.DB', + 'ehthumbs.db', + 'desktop.ini', + 'Desktop.INI', + '._SKILL.md', + '._', + // Near misses that both sides must keep. + '.dsstore', + 'ds_store.md', + '_SKILL.md', + '.DS_Store.md' + ]) { + await writeFile(path.join(packageRoot, name), `${name}\n`) + } + + const generated = (await collectPackageFiles(packageRoot)).map((file) => file.path) + expect(generated).toEqual((await observeSkillPackage(packageRoot)).files.map((f) => f.path)) + expect(generated).toEqual(['.DS_Store.md', '.dsstore', 'SKILL.md', '_SKILL.md', 'ds_store.md']) + }) + it('computes the same Git tree identity as Git', async () => { const packageRoot = path.resolve('skills', 'orca-cli') const files = await collectPackageFiles(packageRoot) diff --git a/config/scripts/hang-watchdog-memory-benchmark.mjs b/config/scripts/hang-watchdog-memory-benchmark.mjs new file mode 100644 index 00000000000..49a2dd63136 --- /dev/null +++ b/config/scripts/hang-watchdog-memory-benchmark.mjs @@ -0,0 +1,443 @@ +#!/usr/bin/env node +import { execFileSync, fork, spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Worker } from 'node:worker_threads' +import { + childRssBytes, + median, + physicalFootprintBytes, + sampleMemory, + sampleProductionPerformance +} from './hang-watchdog-process-metrics.mjs' + +const INTERNAL_ENV = 'ORCA_HANG_WATCHDOG_BENCH_INTERNAL' +const BOUNDARY_ENV = 'ORCA_HANG_WATCHDOG_BENCH_BOUNDARY' +const RESULT_PREFIX = 'ORCA_HANG_WATCHDOG_BENCH_RESULT=' +const DEFAULT_TRIALS = 7 +const SETTLE_MS = 2_000 +const SAMPLE_COUNT = 5 +const SAMPLE_INTERVAL_MS = 200 +const VERIFY_TIMEOUT_MS = 500 +const VERIFY_CHECK_INTERVAL_MS = 50 +const VERIFY_BLOCK_MS = 1_200 +const PRODUCTION_HEARTBEAT_INTERVAL_MS = 2_000 +const PRODUCTION_TIMEOUT_MS = 45_000 +const PRODUCTION_CHECK_INTERVAL_MS = 5_000 +const PRODUCTION_SAMPLE_MS = 30_000 +const MAX_LAUNCH_ATTEMPTS = 3 +const MIB = 1024 * 1024 +const scriptPath = import.meta.filename +const repoRoot = path.resolve(import.meta.dirname, '..', '..') +const entryPath = path.join(repoRoot, 'out', 'main', 'main-thread-hang-watchdog-entry.js') + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function forceGc() { + if (typeof global.gc !== 'function') { + throw new Error('Electron did not expose GC; keep --js-flags=--expose-gc in the harness') + } + global.gc() + global.gc() +} + +function blockMainThread(ms) { + const startedAt = Date.now() + while (Date.now() - startedAt < ms) { + // Intentional synchronous stall. + } + return { startedAt, endedAt: Date.now() } +} + +function readMarker(markerPath) { + try { + return JSON.parse(readFileSync(markerPath, 'utf8')) + } catch { + return null + } +} + +async function verifyBlockedMainDetection(markerPath, sendHeartbeat) { + const block = blockMainThread(VERIFY_BLOCK_MS) + const detected = readMarker(markerPath) + sendHeartbeat() + const deadline = Date.now() + VERIFY_TIMEOUT_MS + let resolved + do { + resolved = readMarker(markerPath) + if (resolved?.selfRecovered === true) { + break + } + await sleep(VERIFY_CHECK_INTERVAL_MS) + } while (Date.now() < deadline) + const verified = + detected?.detectedAt >= block.startedAt && + detected.detectedAt <= block.endedAt && + detected.selfRecovered === false && + resolved?.selfRecovered === true + if (!verified) { + throw new Error( + `Built watchdog failed blocked-main verification: ${JSON.stringify({ detected, resolved })}` + ) + } + return true +} + +function startChild(markerPath, timeoutMs, checkIntervalMs) { + const startedAt = process.hrtime.bigint() + const child = fork(entryPath, [], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid), + ORCA_HANG_WATCHDOG_MARKER_PATH: markerPath, + ORCA_HANG_WATCHDOG_TIMEOUT_MS: String(timeoutMs), + ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS: String(checkIntervalMs) + } + }) + const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6 + return { + pids: [process.pid, child.pid], + startupMs, + sendHeartbeat: () => child.send?.({ type: 'heartbeat' }), + shutdown: async () => { + if (child.exitCode !== null) { + return + } + const exitPromise = new Promise((resolve) => child.once('exit', resolve)) + child.send?.({ type: 'shutdown' }) + if (child.connected) { + child.disconnect() + } + await exitPromise + } + } +} + +function startWorker(markerPath, timeoutMs, checkIntervalMs) { + const startedAt = process.hrtime.bigint() + const worker = new Worker(entryPath, { + workerData: { + parentPid: process.pid, + markerPath, + timeoutMs, + checkIntervalMs + } + }) + const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6 + return { + pids: [process.pid], + startupMs, + sendHeartbeat: () => worker.postMessage({ type: 'heartbeat' }), + shutdown: async () => { + if (worker.threadId === -1) { + return + } + const exitPromise = new Promise((resolve) => worker.once('exit', resolve)) + worker.postMessage({ type: 'shutdown' }) + await exitPromise + } + } +} + +async function verifyBoundary(markerPath, startBoundary) { + const boundary = startBoundary(markerPath, VERIFY_TIMEOUT_MS, VERIFY_CHECK_INTERVAL_MS) + const heartbeat = setInterval(boundary.sendHeartbeat, 100) + try { + await sleep(SETTLE_MS) + return await verifyBlockedMainDetection(markerPath, boundary.sendHeartbeat) + } finally { + clearInterval(heartbeat) + await boundary.shutdown() + } +} + +async function measureChild(markerPath) { + forceGc() + await sleep(SETTLE_MS) + forceGc() + const before = await sampleMemory( + () => process.memoryUsage().rss, + () => physicalFootprintBytes([process.pid]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const child = startChild(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS) + let measurements + try { + await sleep(SETTLE_MS) + forceGc() + const childRss = await sampleMemory( + () => childRssBytes(child.pids[1]), + () => physicalFootprintBytes([child.pids[1]]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const total = await sampleMemory( + () => process.memoryUsage().rss + childRssBytes(child.pids[1]), + () => physicalFootprintBytes(child.pids), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const performance = await sampleProductionPerformance(child, { + heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS, + sampleMs: PRODUCTION_SAMPLE_MS, + sleep + }) + measurements = { + rssBytes: childRss.rssBytes, + summedProcessRssDeltaBytes: Math.max(0, total.rssBytes - before.rssBytes), + physicalFootprintDeltaBytes: Math.max( + 0, + total.physicalFootprintBytes - before.physicalFootprintBytes + ), + startupMs: child.startupMs, + ...performance + } + } finally { + await child.shutdown() + } + rmSync(markerPath, { force: true }) + return { + ...measurements, + blockedMainThreadVerified: await verifyBoundary(markerPath, startChild) + } +} + +async function measureWorker(markerPath) { + forceGc() + await sleep(SETTLE_MS) + forceGc() + const before = await sampleMemory( + () => process.memoryUsage().rss, + () => physicalFootprintBytes([process.pid]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const worker = startWorker(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS) + let measurements + try { + await sleep(SETTLE_MS) + forceGc() + const after = await sampleMemory( + () => process.memoryUsage().rss, + () => physicalFootprintBytes([process.pid]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const performance = await sampleProductionPerformance(worker, { + heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS, + sampleMs: PRODUCTION_SAMPLE_MS, + sleep + }) + const rssBytes = Math.max(0, after.rssBytes - before.rssBytes) + measurements = { + rssBytes, + summedProcessRssDeltaBytes: rssBytes, + physicalFootprintDeltaBytes: Math.max( + 0, + after.physicalFootprintBytes - before.physicalFootprintBytes + ), + startupMs: worker.startupMs, + ...performance + } + } finally { + await worker.shutdown() + } + rmSync(markerPath, { force: true }) + return { + ...measurements, + blockedMainThreadVerified: await verifyBoundary(markerPath, startWorker) + } +} + +async function runInternal() { + if (process.platform !== 'darwin') { + throw new Error('The production watchdog is macOS-only; run this benchmark on macOS') + } + const { app } = await import('electron') + const boundary = process.env[BOUNDARY_ENV] + const profileDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-')) + app.setPath('userData', profileDir) + try { + await app.whenReady() + const markerPath = path.join(profileDir, 'main-thread-hang.json') + const result = + boundary === 'child' + ? await measureChild(markerPath) + : boundary === 'worker' + ? await measureWorker(markerPath) + : (() => { + throw new Error(`Unsupported boundary: ${boundary}`) + })() + process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(result)}\n`) + } finally { + app.quit() + rmSync(profileDir, { recursive: true, force: true }) + } +} + +function parseArgs(argv) { + const options = { boundary: '', trials: DEFAULT_TRIALS, output: '' } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = argv[index + 1] + if (arg === '--boundary' || arg === '--trials' || arg === '--output') { + if (!value) { + throw new Error(`Missing value for ${arg}`) + } + options[arg.slice(2)] = arg === '--trials' ? Number(value) : value + index += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + if (!['child', 'worker'].includes(options.boundary)) { + throw new Error('--boundary must be child or worker') + } + if (!Number.isInteger(options.trials) || options.trials < 1) { + throw new Error('--trials must be a positive integer') + } + return options +} + +function electronPath() { + const requirePath = import.meta.resolve('electron') + const electronModulePath = fileURLToPath(requirePath) + return execFileSync( + process.execPath, + ['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`], + { + encoding: 'utf8' + } + ) +} + +function runTrial(executable, boundary) { + for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) { + const env = { ...process.env, [INTERNAL_ENV]: '1', [BOUNDARY_ENV]: boundary } + delete env.ELECTRON_RUN_AS_NODE + const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-launcher-')) + writeFileSync( + path.join(launcherDir, 'package.json'), + JSON.stringify({ name: 'orca-watchdog-benchmark', main: 'main.cjs' }) + ) + writeFileSync( + path.join(launcherDir, 'main.cjs'), + `import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => { + console.error(error) + process.exitCode = 1 +})\n` + ) + let result + try { + result = spawnSync(executable, ['--js-flags=--expose-gc', launcherDir], { + cwd: repoRoot, + env, + encoding: 'utf8', + timeout: 90_000 + }) + } finally { + rmSync(launcherDir, { recursive: true, force: true }) + } + if (result.status !== 0) { + throw new Error( + `Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n` + + `${result.stderr || result.stdout}` + ) + } + const line = result.stdout.split('\n').find((candidate) => candidate.startsWith(RESULT_PREFIX)) + if (line) { + return { ...JSON.parse(line.slice(RESULT_PREFIX.length)), launchAttempts: attempt } + } + if (attempt === MAX_LAUNCH_ATTEMPTS || result.stderr || result.stdout) { + throw new Error(`Electron trial did not report a result (status ${result.status})`) + } + } + throw new Error('Electron trial exhausted launcher attempts') +} + +function runBenchmark() { + if (process.platform !== 'darwin') { + throw new Error('The production watchdog is macOS-only; run this benchmark on macOS') + } + if (!existsSync(entryPath)) { + throw new Error(`Missing ${entryPath}; run pnpm exec electron-vite build first`) + } + const options = parseArgs(process.argv.slice(2)) + const builtEntry = readFileSync(entryPath, 'utf8') + const hasChildContract = builtEntry.includes('ORCA_HANG_WATCHDOG_PARENT_PID') + const hasWorkerContract = builtEntry.includes('workerData') && builtEntry.includes('parentPort') + if ( + (options.boundary === 'child' && !hasChildContract) || + (options.boundary === 'worker' && !hasWorkerContract) + ) { + throw new Error( + `Built watchdog does not implement the requested ${options.boundary} boundary; rebuild the matching revision` + ) + } + const executable = electronPath() + const results = Array.from({ length: options.trials }, () => + runTrial(executable, options.boundary) + ) + const rssBytes = results.map((result) => result.rssBytes) + const summedProcessRssDeltaBytes = results.map((result) => result.summedProcessRssDeltaBytes) + const physicalFootprintDeltaBytes = results.map((result) => result.physicalFootprintDeltaBytes) + const startupMs = results.map((result) => result.startupMs) + const cpuMs = results.map((result) => result.cpuMs) + const eventLoopDelayP95Ms = results.map((result) => result.eventLoopDelayP95Ms) + const eventLoopDelayP99Ms = results.map((result) => result.eventLoopDelayP99Ms) + const eventLoopDelayMaxMs = results.map((result) => result.eventLoopDelayMaxMs) + const report = { + benchmark: 'hang-watchdog-memory', + boundary: options.boundary, + revision: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8' + }).trim(), + electron: execFileSync(executable, ['-e', 'process.stdout.write(process.versions.electron)'], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + encoding: 'utf8' + }).trim(), + settleMs: SETTLE_MS, + samplesPerTrial: SAMPLE_COUNT, + productionHeartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS, + productionCheckIntervalMs: PRODUCTION_CHECK_INTERVAL_MS, + productionSampleMs: PRODUCTION_SAMPLE_MS, + trials: options.trials, + rssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))), + medianRssMiB: Number((median(rssBytes) / MIB).toFixed(2)), + summedProcessRssDeltaMiB: summedProcessRssDeltaBytes.map((value) => + Number((value / MIB).toFixed(2)) + ), + medianSummedProcessRssDeltaMiB: Number((median(summedProcessRssDeltaBytes) / MIB).toFixed(2)), + physicalFootprintDeltaMiB: physicalFootprintDeltaBytes.map((value) => + Number((value / MIB).toFixed(2)) + ), + medianPhysicalFootprintDeltaMiB: Number((median(physicalFootprintDeltaBytes) / MIB).toFixed(2)), + startupMs: startupMs.map((value) => Number(value.toFixed(3))), + medianStartupMs: Number(median(startupMs).toFixed(3)), + cpuMs: cpuMs.map((value) => Number(value.toFixed(2))), + medianCpuMs: Number(median(cpuMs).toFixed(2)), + eventLoopDelayP95Ms: eventLoopDelayP95Ms.map((value) => Number(value.toFixed(3))), + medianEventLoopDelayP95Ms: Number(median(eventLoopDelayP95Ms).toFixed(3)), + eventLoopDelayP99Ms: eventLoopDelayP99Ms.map((value) => Number(value.toFixed(3))), + medianEventLoopDelayP99Ms: Number(median(eventLoopDelayP99Ms).toFixed(3)), + eventLoopDelayMaxMs: eventLoopDelayMaxMs.map((value) => Number(value.toFixed(3))), + medianEventLoopDelayMaxMs: Number(median(eventLoopDelayMaxMs).toFixed(3)), + heartbeatCounts: results.map((result) => result.heartbeatCount), + launchAttempts: results.map((result) => result.launchAttempts), + blockedMainThreadVerified: results.every((result) => result.blockedMainThreadVerified) + } + const serialized = `${JSON.stringify(report, null, 2)}\n` + process.stdout.write(serialized) + if (options.output) { + writeFileSync(path.resolve(options.output), serialized) + } +} + +if (process.env[INTERNAL_ENV] === '1') { + await runInternal() +} else { + runBenchmark() +} diff --git a/config/scripts/hang-watchdog-process-metrics.mjs b/config/scripts/hang-watchdog-process-metrics.mjs new file mode 100644 index 00000000000..7e5ee4de53f --- /dev/null +++ b/config/scripts/hang-watchdog-process-metrics.mjs @@ -0,0 +1,108 @@ +import { execFileSync } from 'node:child_process' +import { monitorEventLoopDelay } from 'node:perf_hooks' + +export function median(values) { + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +export async function sampleMemory(readRss, readPhysicalFootprint, options) { + const rssSamples = [] + const physicalFootprintSamples = [] + for (let index = 0; index < options.sampleCount; index += 1) { + rssSamples.push(readRss()) + physicalFootprintSamples.push(readPhysicalFootprint()) + await options.sleep(options.sampleIntervalMs) + } + return { + rssBytes: median(rssSamples), + physicalFootprintBytes: median(physicalFootprintSamples) + } +} + +export function childRssBytes(pid) { + const raw = execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], { + encoding: 'utf8' + }).trim() + const rssKiB = Number(raw) + if (!Number.isFinite(rssKiB) || rssKiB <= 0) { + throw new Error(`Could not read watchdog child RSS for PID ${pid}`) + } + return rssKiB * 1024 +} + +export function parsePhysicalFootprintBytes(output, processCount) { + const match = + processCount > 1 + ? output.match(/^Summary Footprint:\s+(\d+) B$/m) + : output.match(/^[^\s].*\sFootprint:\s+(\d+) B/m) + const bytes = Number(match?.[1]) + return Number.isFinite(bytes) && bytes > 0 ? bytes : null +} + +export function physicalFootprintBytes(pids) { + const pidArgs = pids.flatMap((pid) => ['--pid', String(pid)]) + const output = execFileSync( + '/usr/bin/footprint', + [...pidArgs, '--format', 'bytes', '--noCategories'], + { encoding: 'utf8' } + ) + const bytes = parsePhysicalFootprintBytes(output, pids.length) + if (bytes === null) { + throw new Error(`Could not read physical footprint for PIDs ${pids.join(', ')}`) + } + return bytes +} + +export function parseProcessCpuTimeMs(raw) { + if (!raw.trim()) { + return null + } + const parts = raw.split(':').map(Number) + if (!parts.length || parts.some((part) => !Number.isFinite(part))) { + return null + } + const seconds = parts.reduce((total, part) => total * 60 + part, 0) + const milliseconds = seconds * 1_000 + return milliseconds >= 0 ? milliseconds : null +} + +function processCpuTimeMs(pid) { + const raw = execFileSync('ps', ['-o', 'time=', '-p', String(pid)], { + encoding: 'utf8' + }).trim() + const milliseconds = parseProcessCpuTimeMs(raw) + if (milliseconds === null) { + throw new Error(`Could not read CPU time for PID ${pid}`) + } + return milliseconds +} + +function combinedCpuTimeMs(pids) { + return pids.reduce((total, pid) => total + processCpuTimeMs(pid), 0) +} + +export async function sampleProductionPerformance(boundary, options) { + const loopDelay = monitorEventLoopDelay({ resolution: 10 }) + let heartbeatCount = 0 + const heartbeat = setInterval(() => { + heartbeatCount += 1 + boundary.sendHeartbeat() + }, options.heartbeatIntervalMs) + const cpuBefore = combinedCpuTimeMs(boundary.pids) + loopDelay.enable() + try { + await options.sleep(options.sampleMs) + } finally { + loopDelay.disable() + clearInterval(heartbeat) + } + return { + cpuMs: Math.max(0, combinedCpuTimeMs(boundary.pids) - cpuBefore), + heartbeatCount, + eventLoopDelayP95Ms: loopDelay.percentile(95) / 1e6, + eventLoopDelayP99Ms: loopDelay.percentile(99) / 1e6, + eventLoopDelayMaxMs: loopDelay.max / 1e6 + } +} diff --git a/config/scripts/hang-watchdog-process-metrics.test.mjs b/config/scripts/hang-watchdog-process-metrics.test.mjs new file mode 100644 index 00000000000..2d54e691d00 --- /dev/null +++ b/config/scripts/hang-watchdog-process-metrics.test.mjs @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + parsePhysicalFootprintBytes, + parseProcessCpuTimeMs +} from './hang-watchdog-process-metrics.mjs' + +describe('hang watchdog process metrics', () => { + it('uses the de-duplicated summary for multiple processes', () => { + const output = ` +Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page) + phys_footprint: 5100000 B +Electron Helper [102]: 64-bit Footprint: 2000000 B (16384 bytes per page) + phys_footprint: 2100000 B +Summary Footprint: 6259264 B +` + expect(parsePhysicalFootprintBytes(output, 2)).toBe(6_259_264) + }) + + it('uses the process footprint rather than auxiliary accounting for one process', () => { + const output = ` +Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page) + phys_footprint: 5100000 B +` + expect(parsePhysicalFootprintBytes(output, 1)).toBe(5_000_000) + }) + + it('rejects missing or zero footprint summaries', () => { + expect(parsePhysicalFootprintBytes('phys_footprint: 100 B', 2)).toBeNull() + expect(parsePhysicalFootprintBytes('Summary Footprint: 0 B', 2)).toBeNull() + }) + + it.each([ + ['0:00.04', 40], + ['1:02.50', 62_500], + ['2:01:02.50', 7_262_500] + ])('parses ps CPU time %s', (value, expected) => { + expect(parseProcessCpuTimeMs(value)).toBe(expected) + }) + + it('rejects invalid CPU times', () => { + expect(parseProcessCpuTimeMs('')).toBeNull() + expect(parseProcessCpuTimeMs('not-a-time')).toBeNull() + expect(parseProcessCpuTimeMs('-1:00')).toBeNull() + }) +}) diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs new file mode 100644 index 00000000000..6ed30a05916 --- /dev/null +++ b/config/scripts/hourly-build-version.mjs @@ -0,0 +1,43 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically + * by semver and every build is uniquely versioned. */ +export function createHourlyBuildVersion(baseVersion, date) { + const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion) + if (!match) { + throw new Error(`Package version is not valid semver: ${baseVersion}`) + } + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new Error('Hourly build timestamp is invalid.') + } + const pad = (value, width = 2) => String(value).padStart(width, '0') + const stamp = [ + pad(date.getUTCFullYear(), 4), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()) + ].join('') + // Why: drop any -rc.N tail. Keeping it makes every hourly semver-NEWER than the + // RC it was cut from (1.4.160-rc.3-hourly.X > 1.4.160-rc.3), which would let an + // ordinary RC-channel check offer untested hourly builds to RC users. Stripping + // to the base parks hourlies below both rc.N and stable ('hourly' < 'rc' + // alphabetically), reachable only by an explicit pinned jump. + return `${match[1]}-hourly.${stamp}` +} + +export function getHourlyBuildIdentity(now = new Date()) { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + return { commit, version: createHourlyBuildVersion(packageJson.version, now) } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const identity = getHourlyBuildIdentity() + // Consumed by the workflow via $GITHUB_OUTPUT. + process.stdout.write(`version=${identity.version}\ncommit=${identity.commit}\n`) +} diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs new file mode 100644 index 00000000000..e75951c481a --- /dev/null +++ b/config/scripts/hourly-build-version.test.mjs @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { createHourlyBuildVersion } from './hourly-build-version.mjs' +import { compareAppVersions } from '../../src/shared/app-version' + +describe('createHourlyBuildVersion', () => { + it('stamps the version with a zero-padded UTC timestamp', () => { + expect(createHourlyBuildVersion('1.4.160', new Date('2026-07-28T04:05:00Z'))).toBe( + '1.4.160-hourly.202607280405' + ) + }) + + // Why: main's package.json carries the in-flight RC tail. Keeping it would make + // every hourly semver-NEWER than the RC it was cut from (1.4.160-rc.3-hourly.X > + // 1.4.160-rc.3), so an ordinary RC-channel check would offer untested hourly + // builds to RC users. Dropping it parks hourlies below both rc.N and stable, + // reachable only by an explicit pinned jump. + it('drops an in-flight rc tail so hourlies never outrank the rc series', () => { + const version = createHourlyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z')) + expect(version).toBe('1.4.160-hourly.202607281400') + expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0) + expect(compareAppVersions('1.4.160-rc.3-hourly.202607281400', '1.4.160-rc.3')).toBeGreaterThan( + 0 + ) + }) + + it('rejects invalid input', () => { + expect(() => createHourlyBuildVersion('nope', new Date())).toThrow(/valid semver/) + expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/) + }) +}) diff --git a/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs new file mode 100644 index 00000000000..8724cf8faf4 --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs @@ -0,0 +1,614 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { execFileSync, fork } from 'node:child_process' +import { existsSync, mkdtempSync, openSync, readFileSync, writeFileSync } from 'node:fs' +import net from 'node:net' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { + median, + percentile, + processSnapshot, + sampleProcess +} from './macos-computer-helper-owner-loss-metrics.mjs' +import { + benchmarkTrialNeedsCleanup, + parseBenchmarkTrialResult, + processIdentityIsCurrent, + signalProcessIdentity, + spawnBenchmarkProcess, + throwBenchmarkTrialFailures, + writeProcessRecord +} from './macos-computer-helper-owner-loss-processes.mjs' +import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs' + +const INTERNAL_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_INTERNAL' +const EXPECTATION_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_EXPECTATION' +const HELPER_RECORD_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_HELPER_RECORD_PATH' +const RESULT_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_RESULT_PATH' +const ACTIVE_REQUEST_COUNT = 100_000 +const DEFAULT_TRIALS = 3 +const OWNER_HOLD_MS = 31_000 +const PROCESS_EXIT_TIMEOUT_MS = 5_000 +const RETAIN_PROOF_MS = 3_000 +const TRIAL_TIMEOUT_MS = OWNER_HOLD_MS + 4 * PROCESS_EXIT_TIMEOUT_MS + 120_000 +const MIB = 1024 * 1024 +const scriptPath = import.meta.filename +const repoRoot = path.resolve(import.meta.dirname, '..', '..') +const metricsPath = path.join(import.meta.dirname, 'macos-computer-helper-owner-loss-metrics.mjs') +const processCleanupPath = path.join( + import.meta.dirname, + 'macos-computer-helper-owner-loss-processes.mjs' +) +const trialCleanupPath = path.join( + import.meta.dirname, + 'macos-computer-helper-owner-loss-trial-cleanup.mjs' +) +const sidecarPath = path.join(repoRoot, 'out', 'main', 'computer-sidecar.js') +const helperAppPath = path.join( + repoRoot, + 'native', + 'computer-use-macos', + '.build', + 'release', + 'Orca Computer Use.app' +) +const helperPath = path.join(helperAppPath, 'Contents', 'MacOS', 'orca-computer-use-macos') + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function waitForProcessExit(identity, timeoutMs) { + const startedAt = performance.now() + while (performance.now() - startedAt < timeoutMs) { + if (!processIdentityIsCurrent(identity)) { + return performance.now() - startedAt + } + await sleep(50) + } + return null +} + +async function stopProcess(identity) { + if (!identity || !processIdentityIsCurrent(identity)) { + return + } + signalProcessIdentity(identity, helperPath, 'SIGTERM') + if ((await waitForProcessExit(identity, 2_000)) !== null) { + return + } + signalProcessIdentity(identity, helperPath, 'SIGKILL') + await waitForProcessExit(identity, 2_000) +} + +function startSidecar() { + const errors = [] + const child = fork(sidecarPath, [], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ORCA_COMPUTER_SIDECAR: '1', + ORCA_COMPUTER_MACOS_HELPER_APP_PATH: helperAppPath + } + }) + child.on('error', (error) => errors.push(error.stack ?? error.message)) + child.stderr?.on('data', (chunk) => errors.push(String(chunk))) + return { child, errors } +} + +function requestSidecar(sidecar, id, method) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup() + reject(new Error(`Sidecar ${method} request timed out: ${sidecar.errors.join('')}`)) + }, 10_000) + const onMessage = (message) => { + if (message?.id !== id) { + return + } + cleanup() + if (message.ok) { + resolve(message.result) + } else { + reject(new Error(`Sidecar ${method} failed: ${JSON.stringify(message.error)}`)) + } + } + const onExit = (code, signal) => { + cleanup() + reject( + new Error( + `Sidecar exited during ${method}: ${JSON.stringify({ code, signal, stderr: sidecar.errors.join('') })}` + ) + ) + } + const onError = (error) => { + cleanup() + reject(new Error(`Sidecar ${method} process error: ${error.message}`)) + } + const cleanup = () => { + clearTimeout(timeout) + sidecar.child.off('message', onMessage) + sidecar.child.off('exit', onExit) + sidecar.child.off('error', onError) + } + sidecar.child.on('message', onMessage) + sidecar.child.once('exit', onExit) + sidecar.child.once('error', onError) + try { + sidecar.child.send({ id, method, params: {} }) + } catch (error) { + onError(error) + } + }) +} + +async function waitForHelper(sidecarPid) { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const output = execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,command='], { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }) + for (const line of output.split('\n')) { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/) + if ( + match && + Number(match[2]) === sidecarPid && + Number(match[3]) === Number(match[1]) && + match[4].includes(helperPath) && + match[4].includes(' --agent ') + ) { + return { pid: Number(match[1]), pgid: Number(match[3]), command: match[4] } + } + } + await sleep(50) + } + throw new Error(`Could not find helper owned by sidecar ${sidecarPid}`) +} + +function socketPathFromCommand(command) { + const match = command.match(/ --agent (.+?) --token-file /) + if (!match) { + throw new Error(`Could not read helper socket path from command: ${command}`) + } + return match[1] +} + +function connectInvalidPeer(socketPath) { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath) + let accepted = false + const timeout = setTimeout(() => { + socket.destroy() + reject(new Error('Invalid-peer connection timed out')) + }, 5_000) + let buffer = '' + const cleanup = () => { + clearTimeout(timeout) + socket.off('error', onError) + socket.off('data', onData) + } + const onError = (error) => { + if (accepted) { + return + } + cleanup() + reject(error) + } + const onData = (chunk) => { + buffer += chunk + const newline = buffer.indexOf('\n') + if (newline < 0) { + return + } + let response + try { + response = JSON.parse(buffer.slice(0, newline)) + } catch (error) { + cleanup() + socket.destroy() + reject(error) + return + } + if (response.ok !== false || response.error?.code !== 'permission_denied') { + cleanup() + socket.destroy() + reject(new Error(`Invalid peer was not rejected: ${JSON.stringify(response)}`)) + return + } + clearTimeout(timeout) + socket.off('data', onData) + accepted = true + resolve(socket) + } + socket.setEncoding('utf8') + socket.on('error', onError) + socket.on('data', onData) + socket.once('connect', () => { + socket.write( + `${JSON.stringify({ id: 991, method: 'handshake', params: {}, token: 'invalid' })}\n` + ) + }) + }) +} + +async function waitForChildExit(child, timeoutMs) { + if (child.exitCode !== null || child.signalCode !== null) { + return true + } + return await Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve(true))), + sleep(timeoutMs).then(() => false) + ]) +} + +async function startAuthenticatedSession() { + const sidecar = startSidecar() + let helper + try { + const capabilitiesResult = requestSidecar(sidecar, 1, 'capabilities').then( + (capabilities) => ({ capabilities }), + (error) => ({ error }) + ) + helper = await waitForHelper(sidecar.child.pid) + const helperRecordPath = process.env[HELPER_RECORD_PATH_ENV] + if (!helperRecordPath) { + throw new Error('Missing helper process record path') + } + writeProcessRecord(helperRecordPath, helper) + const { capabilities, error } = await capabilitiesResult + if (error) { + throw error + } + if (capabilities?.protocolVersion !== 1) { + throw new Error(`Unexpected helper handshake: ${JSON.stringify(capabilities)}`) + } + return { authenticated: capabilities.protocolVersion === 1, sidecar, helper } + } catch (error) { + sidecar.child.kill('SIGKILL') + await stopProcess(helper) + throw error + } +} + +async function exerciseActiveRequests(sidecar) { + const latencies = [] + const startedAt = performance.now() + for (let index = 0; index < ACTIVE_REQUEST_COUNT; index += 1) { + const requestStartedAt = performance.now() + const result = await requestSidecar(sidecar, 10_000 + index, 'listApps') + if (!Array.isArray(result?.apps)) { + throw new Error(`Unexpected listApps response: ${JSON.stringify(result)}`) + } + latencies.push(performance.now() - requestStartedAt) + } + const totalMs = performance.now() - startedAt + return { + totalMs, + requestsPerSecond: (ACTIVE_REQUEST_COUNT * 1_000) / totalMs, + medianLatencyMs: median(latencies), + p95LatencyMs: percentile(latencies, 0.95), + maxLatencyMs: Math.max(...latencies) + } +} + +async function verifyGracefulClose() { + const { sidecar, helper } = await startAuthenticatedSession() + try { + const startedAt = performance.now() + sidecar.child.disconnect() + if (!(await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS))) { + throw new Error('Sidecar did not exit after graceful IPC close') + } + const helperExitMs = await waitForProcessExit(helper, PROCESS_EXIT_TIMEOUT_MS) + if (helperExitMs === null) { + throw new Error('Helper did not exit after graceful owner close') + } + return Math.round(performance.now() - startedAt) + } finally { + sidecar.child.kill('SIGKILL') + await stopProcess(helper) + } +} + +async function runInternalTrial(expectation) { + let sidecar + let helper + let invalidPeer + let invalidPeerRejected = false + try { + const session = await startAuthenticatedSession() + sidecar = session.sidecar + helper = session.helper + const authenticatedAt = performance.now() + const initial = await sampleProcess(helper.pid) + const activeRequests = await exerciseActiveRequests(sidecar) + const remainingHoldMs = Math.max(0, OWNER_HOLD_MS - (performance.now() - authenticatedAt)) + await sleep(remainingHoldMs) + const connected = await sampleProcess(helper.pid) + const invalidSocketPath = socketPathFromCommand(helper.command) + invalidPeer = await connectInvalidPeer(invalidSocketPath) + invalidPeerRejected = true + const survivedClaimDeadline = + performance.now() - authenticatedAt >= OWNER_HOLD_MS && isProcessAlive(helper.pid) + + sidecar.child.kill('SIGKILL') + await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS) + const abruptExitMs = await waitForProcessExit( + helper, + expectation === 'reaped' ? PROCESS_EXIT_TIMEOUT_MS : RETAIN_PROOF_MS + ) + const helperExitedAfterAbruptLoss = abruptExitMs !== null + if (expectation === 'reaped' && !helperExitedAfterAbruptLoss) { + throw new Error('Expected helper to exit after abrupt authenticated owner loss') + } + if (expectation === 'retained' && helperExitedAfterAbruptLoss) { + throw new Error('Expected baseline helper to remain after abrupt owner loss') + } + const postLossRssBytes = helperExitedAfterAbruptLoss ? 0 : processSnapshot(helper.pid).rssBytes + await stopProcess(helper) + helper = null + invalidPeer.destroy() + invalidPeer = null + + const gracefulExitMs = await verifyGracefulClose() + return { + authenticated: session.authenticated, + survivedClaimDeadline, + invalidPeerRejectedAndDidNotRetain: invalidPeerRejected && helperExitedAfterAbruptLoss, + connectedRssBytes: connected.rssBytes, + connectedCpuMilliseconds: Math.max( + 0, + Math.round((connected.cpuTimeSeconds - initial.cpuTimeSeconds) * 1_000) + ), + activeRequests, + cpuSampleMs: Math.round(performance.now() - authenticatedAt), + helperExitedAfterAbruptLoss, + abruptExitMs: abruptExitMs === null ? null : Math.round(abruptExitMs), + postLossRssBytes, + gracefulExitMs + } + } finally { + invalidPeer?.destroy() + sidecar?.child.kill('SIGKILL') + await stopProcess(helper) + } +} + +function parseArgs(argv) { + const options = { expect: '', trials: DEFAULT_TRIALS, output: '' } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = argv[index + 1] + if (arg === '--expect' || arg === '--trials' || arg === '--output') { + if (!value) { + throw new Error(`Missing value for ${arg}`) + } + options[arg.slice(2)] = arg === '--trials' ? Number(value) : value + index += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + if (!['retained', 'reaped'].includes(options.expect)) { + throw new Error('--expect must be retained or reaped') + } + if (!Number.isInteger(options.trials) || options.trials < 1) { + throw new Error('--trials must be a positive integer') + } + return options +} + +function electronPath() { + const electronModulePath = fileURLToPath(import.meta.resolve('electron')) + return execFileSync( + process.execPath, + ['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`], + { encoding: 'utf8' } + ) +} + +function buildArtifacts() { + execFileSync('pnpm', ['exec', 'electron-vite', 'build'], { + cwd: repoRoot, + stdio: 'inherit' + }) + execFileSync('pnpm', ['build:computer-macos'], { + cwd: repoRoot, + stdio: 'inherit' + }) +} + +function artifactSha256(artifactPath) { + return createHash('sha256').update(readFileSync(artifactPath)).digest('hex') +} + +function runTrial(executable, expectation) { + let launcherDir + let trialTempDir + let helperRecordPath + let resultPath + let stderrPath + let stdoutPath + let result + let serializedResult + let parsedResult + let parsedResultAvailable = false + let trialError + let cleanupError + let stderrDescriptor + let stdoutDescriptor + let trialOutput = '' + try { + launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-helper-owner-bench-launcher-')) + trialTempDir = mkdtempSync(path.join(path.sep, 'tmp', 'orca-owner-bench-')) + helperRecordPath = path.join(launcherDir, 'helper.json') + resultPath = path.join(launcherDir, 'result.json') + stderrPath = path.join(launcherDir, 'stderr.log') + stdoutPath = path.join(launcherDir, 'stdout.log') + writeFileSync( + path.join(launcherDir, 'package.json'), + JSON.stringify({ name: 'orca-helper-owner-benchmark', main: 'main.cjs' }) + ) + writeFileSync( + path.join(launcherDir, 'main.cjs'), + `import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => { + console.error(error) + process.exitCode = 1 +})\n` + ) + const env = { + ...process.env, + TMPDIR: trialTempDir, + [INTERNAL_ENV]: '1', + [EXPECTATION_ENV]: expectation, + [HELPER_RECORD_PATH_ENV]: helperRecordPath, + [RESULT_PATH_ENV]: resultPath + } + delete env.ELECTRON_RUN_AS_NODE + stderrDescriptor = openSync(stderrPath, 'w') + stdoutDescriptor = openSync(stdoutPath, 'w') + result = spawnBenchmarkProcess(executable, [launcherDir], { + cwd: repoRoot, + env, + stdio: ['ignore', stdoutDescriptor, stderrDescriptor], + timeout: TRIAL_TIMEOUT_MS + }) + if (result.status === 0 && existsSync(resultPath)) { + serializedResult = readFileSync(resultPath, 'utf8') + parsedResult = parseBenchmarkTrialResult(serializedResult) + parsedResultAvailable = true + } + } catch (error) { + trialError = error + } finally { + const failedTrial = benchmarkTrialNeedsCleanup(result, parsedResultAvailable) + const trialMarker = trialTempDir ? `TMPDIR=${trialTempDir}` : undefined + const cleanup = cleanupOwnerLossTrial({ + failed: failedTrial, + pid: result?.pid, + marker: trialMarker, + recordPath: helperRecordPath, + helperPath, + tempDir: trialTempDir, + stderrDescriptor, + stdoutDescriptor, + outputPaths: [stderrPath, stdoutPath], + launcherDir + }) + cleanupError = cleanup.error + trialOutput = cleanup.output + } + if (!trialError && result?.status !== 0) { + trialError = new Error( + `Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n${trialOutput}` + ) + } + if (!trialError && !serializedResult) { + trialError = new Error(`Electron trial did not write a result:\n${trialOutput}`) + } + throwBenchmarkTrialFailures(trialError, cleanupError) + return parsedResult +} + +function runBenchmark() { + if (process.platform !== 'darwin') { + throw new Error('The computer-use helper owner benchmark is macOS-only') + } + const options = parseArgs(process.argv.slice(2)) + const dirty = execFileSync('git', ['status', '--porcelain'], { + cwd: repoRoot, + encoding: 'utf8' + }).trim() + if (dirty) { + throw new Error('Commit or stash changes before running the provenance-bound benchmark') + } + buildArtifacts() + if (!existsSync(sidecarPath) || !existsSync(helperPath)) { + throw new Error('Fresh production sidecar/helper build did not produce the expected artifacts') + } + const executable = electronPath() + const results = Array.from({ length: options.trials }, () => runTrial(executable, options.expect)) + const rssBytes = results.map((result) => result.connectedRssBytes) + const cpuMilliseconds = results.map((result) => result.connectedCpuMilliseconds) + const activeRequestTotals = results.map((result) => result.activeRequests.totalMs) + const activeRequestRates = results.map((result) => result.activeRequests.requestsPerSecond) + const activeRequestMedians = results.map((result) => result.activeRequests.medianLatencyMs) + const activeRequestP95s = results.map((result) => result.activeRequests.p95LatencyMs) + const activeRequestMaxes = results.map((result) => result.activeRequests.maxLatencyMs) + const postLossRssBytes = results.map((result) => result.postLossRssBytes) + const report = { + benchmark: 'macos-computer-helper-authenticated-owner-loss', + revision: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8' + }).trim(), + artifacts: { + sidecarSha256: artifactSha256(sidecarPath), + helperSha256: artifactSha256(helperPath) + }, + sources: { + benchmarkSha256: artifactSha256(scriptPath), + metricsSha256: artifactSha256(metricsPath), + processCleanupSha256: artifactSha256(processCleanupPath), + trialCleanupSha256: artifactSha256(trialCleanupPath) + }, + expectation: options.expect, + trials: options.trials, + ownerHoldMs: OWNER_HOLD_MS, + activeRequestCount: ACTIVE_REQUEST_COUNT, + authenticated: results.every((result) => result.authenticated), + survivedClaimDeadline: results.every((result) => result.survivedClaimDeadline), + invalidPeerRejectedAndDidNotRetain: results.every( + (result) => result.invalidPeerRejectedAndDidNotRetain + ), + connectedRssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))), + medianConnectedRssMiB: Number((median(rssBytes) / MIB).toFixed(2)), + connectedCpuMilliseconds: cpuMilliseconds, + medianConnectedCpuMilliseconds: median(cpuMilliseconds), + activeRequestTotalMs: activeRequestTotals.map((value) => Number(value.toFixed(3))), + medianActiveRequestTotalMs: Number(median(activeRequestTotals).toFixed(3)), + activeRequestsPerSecond: activeRequestRates.map((value) => Number(value.toFixed(2))), + medianActiveRequestsPerSecond: Number(median(activeRequestRates).toFixed(2)), + activeRequestMedianLatencyMs: activeRequestMedians.map((value) => Number(value.toFixed(3))), + medianActiveRequestMedianLatencyMs: Number(median(activeRequestMedians).toFixed(3)), + activeRequestP95LatencyMs: activeRequestP95s.map((value) => Number(value.toFixed(3))), + medianActiveRequestP95LatencyMs: Number(median(activeRequestP95s).toFixed(3)), + activeRequestMaxLatencyMs: activeRequestMaxes.map((value) => Number(value.toFixed(3))), + helperExitedAfterAbruptLoss: results.map((result) => result.helperExitedAfterAbruptLoss), + abruptExitMs: results.map((result) => result.abruptExitMs), + postLossRssMiB: postLossRssBytes.map((value) => Number((value / MIB).toFixed(2))), + medianPostLossRssMiB: Number((median(postLossRssBytes) / MIB).toFixed(2)), + gracefulExitMs: results.map((result) => result.gracefulExitMs) + } + const serialized = `${JSON.stringify(report, null, 2)}\n` + process.stdout.write(serialized) + if (options.output) { + writeFileSync(path.resolve(options.output), serialized) + } +} + +if (process.env[INTERNAL_ENV] === '1') { + const { app } = await import('electron') + await app.whenReady() + try { + const result = await runInternalTrial(process.env[EXPECTATION_ENV]) + writeFileSync(process.env[RESULT_PATH_ENV], JSON.stringify(result)) + } finally { + app.quit() + } +} else { + runBenchmark() +} diff --git a/config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs b/config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs new file mode 100644 index 00000000000..512a663cddb --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { signalValidatedProcessGroup } from './macos-computer-helper-owner-loss-processes.mjs' + +describe('macOS helper owner-loss benchmark group recovery', () => { + it('retains uncertain stop state across cleanup stage failures', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }] + const groupState = { stopped: false } + let scanCount = 0 + let continueAttempts = 0 + const operations = { + processIdentities: () => { + scanCount += 1 + if (scanCount >= 3) { + throw new Error( + scanCount === 3 ? 'post-stop inspection failed' : 'final inspection failed' + ) + } + return members + }, + signalProcess: (pid, signal) => { + if (pid === -41 && signal === 'SIGCONT') { + continueAttempts += 1 + if (continueAttempts === 1) { + throw new Error('first compensation failed') + } + } + } + } + + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations) + ).toThrow('Benchmark process group signal recovery failed') + expect(groupState.stopped).toBe(true) + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations) + ).toThrow('final inspection failed') + expect(continueAttempts).toBe(2) + expect(groupState.stopped).toBe(false) + }) + + it('retains an uncertain anchor stop across cleanup stage failures', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }] + const groupState = { stopped: false, anchorPid: null } + let scanCount = 0 + let continueAttempts = 0 + const operations = { + processIdentities: () => { + scanCount += 1 + if (scanCount >= 2) { + throw new Error( + scanCount === 2 ? 'post-anchor inspection failed' : 'final inspection failed' + ) + } + return members + }, + signalProcess: (pid, signal) => { + if (pid === 41 && signal === 'SIGCONT') { + continueAttempts += 1 + if (continueAttempts === 1) { + throw new Error('first anchor compensation failed') + } + } + } + } + + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations) + ).toThrow('Benchmark process group signal recovery failed') + expect(groupState.anchorPid).toBe(41) + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations) + ).toThrow('final inspection failed') + expect(continueAttempts).toBe(2) + expect(groupState.anchorPid).toBeNull() + }) + + it('recovers a retained anchor before selecting a new one', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const firstAnchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` } + const finalAnchor = { pid: 42, pgid: 41, command: `/child ${marker}` } + const groupState = { stopped: false, anchorPid: null } + let scanCount = 0 + let firstAnchorContinues = 0 + const operations = { + processIdentities: () => { + scanCount += 1 + if (scanCount === 2) { + throw new Error('post-anchor inspection failed') + } + return scanCount === 1 ? [firstAnchor] : [finalAnchor] + }, + signalProcess: (pid, signal) => { + if (pid === 41 && signal === 'SIGCONT') { + firstAnchorContinues += 1 + if (firstAnchorContinues === 1) { + throw new Error('first anchor compensation failed') + } + } + } + } + + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations) + ).toThrow('Benchmark process group signal recovery failed') + expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(true) + expect(firstAnchorContinues).toBe(2) + expect(groupState).toEqual({ stopped: false, anchorPid: null }) + }) + + it('preserves group authority errors when compensation fails', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const ownershipError = 'Benchmark process group no longer belongs to this trial' + let thrown + + try { + signalValidatedProcessGroup( + 41, + marker, + 'SIGKILL', + { stopped: true }, + { + processIdentities: () => [{ pid: 41, pgid: 41, command: '/unrelated' }], + signalProcess: () => { + throw new Error('resume denied') + } + } + ) + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(AggregateError) + expect(thrown.errors.map((error) => error.message)).toEqual([ownershipError, 'resume denied']) + }) +}) diff --git a/config/scripts/macos-computer-helper-owner-loss-metrics.mjs b/config/scripts/macos-computer-helper-owner-loss-metrics.mjs new file mode 100644 index 00000000000..b65a062ad34 --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-metrics.mjs @@ -0,0 +1,62 @@ +import { execFileSync } from 'node:child_process' + +const SAMPLE_COUNT = 5 +const SAMPLE_INTERVAL_MS = 200 + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function median(values) { + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +export function percentile(values, fraction) { + const sorted = [...values].sort((left, right) => left - right) + return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)] +} + +function parseCpuTimeSeconds(value) { + const [dayOrTime, clock] = value.includes('-') ? value.split('-', 2) : [null, value] + const days = dayOrTime === null ? 0 : Number(dayOrTime) + const parts = clock.split(':').map(Number) + if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) { + throw new Error(`Invalid process CPU time: ${value}`) + } + const seconds = parts.pop() ?? 0 + const minutes = parts.pop() ?? 0 + const hours = parts.pop() ?? 0 + return days * 86_400 + hours * 3_600 + minutes * 60 + seconds +} + +export function processSnapshot(pid) { + const raw = execFileSync( + 'ps', + ['-o', 'rss=', '-o', 'time=', '-o', 'command=', '-p', String(pid)], + { encoding: 'utf8' } + ).trim() + const match = raw.match(/^(\d+)\s+(\S+)\s+(.+)$/) + if (!match) { + throw new Error(`Could not inspect process ${pid}: ${raw}`) + } + return { + rssBytes: Number(match[1]) * 1024, + cpuTimeSeconds: parseCpuTimeSeconds(match[2]), + command: match[3] + } +} + +export async function sampleProcess(pid) { + const samples = [] + for (let index = 0; index < SAMPLE_COUNT; index += 1) { + samples.push(processSnapshot(pid)) + await sleep(SAMPLE_INTERVAL_MS) + } + return { + rssBytes: median(samples.map((sample) => sample.rssBytes)), + cpuTimeSeconds: samples.at(-1).cpuTimeSeconds, + command: samples.at(-1).command + } +} diff --git a/config/scripts/macos-computer-helper-owner-loss-processes.mjs b/config/scripts/macos-computer-helper-owner-loss-processes.mjs new file mode 100644 index 00000000000..952d726d28d --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-processes.mjs @@ -0,0 +1,418 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs' + +const PROCESS_EXIT_TIMEOUT_MS = 2_000 +const PROCESS_POLL_MS = 25 +const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) + +const processIdentityOperations = { + executePs: execFileSync, + signalProcess: process.kill.bind(process) +} + +export function processIdentity(pid, operations = processIdentityOperations) { + if (!Number.isInteger(pid) || pid <= 0) { + return null + } + try { + const output = operations + .executePs('ps', ['-p', String(pid), '-o', 'pid=,pgid=,command='], { + encoding: 'utf8' + }) + .trim() + const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/) + if (!match) { + throw new Error(`Could not parse process identity for ${pid}`) + } + return { pid: Number(match[1]), pgid: Number(match[2]), command: match[3] } + } catch (error) { + try { + operations.signalProcess(pid, 0) + } catch (lookupError) { + if (lookupError?.code === 'ESRCH') { + return null + } + } + throw error + } +} + +function matchingDetachedProcesses(identities, expectedCommandFragments) { + return identities.filter( + (identity) => + identity.pgid === identity.pid && + expectedCommandFragments.every((fragment) => identity.command.includes(fragment)) + ) +} + +const matchingProcessOperations = { + processIdentities, + signalProcessIdentity, + waitForIdentityExit +} + +export function killProcessMatchingCommand( + expectedCommandFragments, + operations = matchingProcessOperations +) { + const matches = matchingDetachedProcesses( + operations.processIdentities(), + expectedCommandFragments + ) + if (matches.length === 0) { + return false + } + const errors = [] + for (const match of matches) { + try { + if (operations.signalProcessIdentity(match, expectedCommandFragments[0], 'SIGKILL')) { + operations.waitForIdentityExit(match) + } + } catch (error) { + errors.push(error) + } + } + try { + const remaining = matchingDetachedProcesses( + operations.processIdentities(), + expectedCommandFragments + ) + if (remaining.length > 0) { + errors.push( + new Error( + `Benchmark helper cleanup left matching processes: ${remaining + .map((identity) => identity.pid) + .join(', ')}` + ) + ) + } + } catch (error) { + errors.push(error) + } + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Benchmark exact-command cleanup failed') + } + return true +} + +function sleepSync(milliseconds) { + Atomics.wait(sleepBuffer, 0, 0, milliseconds) +} + +function validateDetachedIdentity(identity, expectedCommandFragment) { + if ( + !Number.isInteger(identity?.pid) || + identity.pid <= 0 || + identity.pgid !== identity.pid || + typeof identity.command !== 'string' || + !identity.command.includes(expectedCommandFragment) + ) { + throw new Error('Recorded benchmark helper identity is invalid') + } +} + +function sameIdentity(left, right) { + return left?.pid === right?.pid && left?.pgid === right?.pgid && left?.command === right?.command +} + +function processIdentities(includeEnvironment = false) { + const args = includeEnvironment + ? ['eww', '-axo', 'pid=,pgid=,command='] + : ['-axo', 'pid=,pgid=,command='] + const output = execFileSync('ps', args, { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }) + return output + .split('\n') + .map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)) + .filter(Boolean) + .map((match) => ({ + pid: Number(match[1]), + pgid: Number(match[2]), + command: match[3] + })) +} + +function waitForIdentityExit(identity) { + const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS + while (Date.now() < deadline) { + if (!processIdentityIsCurrent(identity)) { + return true + } + sleepSync(PROCESS_POLL_MS) + } + throw new Error(`Recorded benchmark helper ${identity.pid} did not exit`) +} + +export function spawnBenchmarkProcess(executable, args, options) { + return spawnSync(executable, args, { + ...options, + detached: true, + killSignal: 'SIGKILL' + }) +} + +export function runBenchmarkCleanupStages(stages) { + const errors = [] + for (const stage of stages) { + try { + stage() + } catch (error) { + errors.push(error) + } + } + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Benchmark trial cleanup failed') + } +} + +export function throwBenchmarkTrialFailures(trialError, cleanupError) { + if (trialError && cleanupError) { + throw new AggregateError([trialError, cleanupError], 'Electron trial and cleanup failed') + } + if (trialError) { + throw trialError + } + if (cleanupError) { + throw cleanupError + } +} + +export function parseBenchmarkTrialResult(serializedResult) { + return JSON.parse(serializedResult) +} + +export function benchmarkTrialNeedsCleanup(spawnResult, parsedResultAvailable) { + return spawnResult?.status !== 0 || !parsedResultAvailable +} + +const processGroupSignalOperations = { + processIdentities, + signalProcess: process.kill.bind(process) +} + +function compensateStoppedGroup(pgid, groupState, operations) { + const errors = [] + const targets = [ + groupState.stopped ? [-pgid, 'stopped'] : null, + groupState.anchorPid ? [groupState.anchorPid, 'anchorPid'] : null + ].filter(Boolean) + for (const [pid, stateKey] of targets) { + try { + operations.signalProcess(pid, 'SIGCONT') + groupState[stateKey] = stateKey === 'stopped' ? false : null + } catch (error) { + if (error?.code === 'ESRCH') { + groupState[stateKey] = stateKey === 'stopped' ? false : null + } else { + errors.push(error) + } + } + } + return errors +} + +export function signalValidatedProcessGroup( + pgid, + environmentFragment, + signal, + groupState = { stopped: false, anchorPid: null }, + operations = processGroupSignalOperations +) { + if (!Number.isInteger(pgid) || pgid <= 0) { + return false + } + let members + try { + members = operations.processIdentities(true).filter((identity) => identity.pgid === pgid) + } catch (error) { + const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations) + if (recoveryErrors.length > 0) { + throw new AggregateError( + [error, ...recoveryErrors], + 'Benchmark process group recovery failed before validation' + ) + } + throw error + } + if (members.length === 0) { + const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations) + if (recoveryErrors.length > 0) { + throw new AggregateError(recoveryErrors, 'Benchmark missing process group recovery failed') + } + return false + } + if (members.some((identity) => !identity.command.includes(environmentFragment))) { + const ownershipError = new Error('Benchmark process group no longer belongs to this trial') + const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations) + if (recoveryErrors.length > 0) { + throw new AggregateError( + [ownershipError, ...recoveryErrors], + 'Benchmark process group authority recovery failed' + ) + } + throw ownershipError + } + if (groupState.anchorPid) { + try { + operations.signalProcess(groupState.anchorPid, 'SIGCONT') + groupState.anchorPid = null + } catch (error) { + if (error?.code === 'ESRCH') { + groupState.anchorPid = null + } else { + throw new AggregateError([error], 'Benchmark pending anchor recovery failed') + } + } + } + const anchor = members[0] + try { + operations.signalProcess(anchor.pid, 'SIGSTOP') + groupState.anchorPid = anchor.pid + const stoppedAnchor = operations + .processIdentities(true) + .find((identity) => identity.pid === anchor.pid) + if (!sameIdentity(stoppedAnchor, anchor)) { + throw new Error('Benchmark process group anchor changed before signaling') + } + operations.signalProcess(-pgid, 'SIGSTOP') + groupState.stopped = true + groupState.anchorPid = null + const stoppedMembers = operations + .processIdentities(true) + .filter((identity) => identity.pgid === pgid) + if ( + stoppedMembers.length === 0 || + stoppedMembers.some((identity) => !identity.command.includes(environmentFragment)) + ) { + throw new Error('Benchmark process group changed before signaling') + } + if (signal !== 'SIGSTOP') { + operations.signalProcess(-pgid, signal) + if (signal !== 'SIGKILL') { + operations.signalProcess(-pgid, 'SIGCONT') + } + groupState.stopped = false + groupState.anchorPid = null + } + return true + } catch (error) { + const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations) + if (recoveryErrors.length > 0) { + throw new AggregateError( + [error, ...recoveryErrors], + 'Benchmark process group signal recovery failed' + ) + } + if (error.code === 'ESRCH') { + return false + } + throw error + } +} + +export function writeProcessRecord(recordPath, processIdentity) { + const temporaryPath = `${recordPath}.${process.pid}.tmp` + writeFileSync(temporaryPath, JSON.stringify(processIdentity)) + renameSync(temporaryPath, recordPath) +} + +export function processIdentityIsCurrent(identity) { + return sameIdentity(processIdentity(identity?.pid), identity) +} + +const processSignalOperations = { + processIdentity, + signalProcess: process.kill.bind(process) +} + +export function signalProcessIdentity( + identity, + expectedCommandFragment, + signal, + operations = processSignalOperations +) { + validateDetachedIdentity(identity, expectedCommandFragment) + const currentIdentity = operations.processIdentity(identity.pid) + if (!currentIdentity) { + return false + } + if (!sameIdentity(currentIdentity, identity)) { + throw new Error('Recorded benchmark helper PID now belongs to another process') + } + let stopped = false + try { + operations.signalProcess(identity.pid, 'SIGSTOP') + stopped = true + const stoppedIdentity = operations.processIdentity(identity.pid) + if (!sameIdentity(stoppedIdentity, identity)) { + throw new Error('Recorded benchmark helper PID changed before signaling') + } + operations.signalProcess(-identity.pgid, signal) + if (signal !== 'SIGKILL') { + operations.signalProcess(-identity.pgid, 'SIGCONT') + } + stopped = false + return true + } catch (error) { + let resumeError + if (stopped) { + try { + operations.signalProcess(identity.pid, 'SIGCONT') + } catch (caught) { + if (caught?.code !== 'ESRCH') { + resumeError = caught + } + } + } + if (resumeError) { + throw new AggregateError([error, resumeError], 'Benchmark helper signal recovery failed') + } + if (error.code === 'ESRCH') { + return false + } + throw error + } +} + +export function killRecordedProcess(recordPath, expectedCommandFragment) { + if (!existsSync(recordPath)) { + return false + } + const record = JSON.parse(readFileSync(recordPath, 'utf8')) + if (!signalProcessIdentity(record, expectedCommandFragment, 'SIGKILL')) { + return false + } + return waitForIdentityExit(record) +} + +export function killRecordedAndMatchingProcesses( + recordPath, + recordedCommandFragment, + matchingCommandFragments +) { + const errors = [] + try { + killRecordedProcess(recordPath, recordedCommandFragment) + } catch (error) { + errors.push(error) + } + try { + killProcessMatchingCommand(matchingCommandFragments) + } catch (error) { + errors.push(error) + } + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Benchmark helper cleanup failed') + } +} diff --git a/config/scripts/macos-computer-helper-owner-loss-processes.test.mjs b/config/scripts/macos-computer-helper-owner-loss-processes.test.mjs new file mode 100644 index 00000000000..764eb73009f --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-processes.test.mjs @@ -0,0 +1,558 @@ +import { execFileSync, spawn } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + benchmarkTrialNeedsCleanup, + killProcessMatchingCommand, + killRecordedAndMatchingProcesses, + killRecordedProcess, + parseBenchmarkTrialResult, + processIdentity, + runBenchmarkCleanupStages, + signalProcessIdentity, + signalValidatedProcessGroup, + spawnBenchmarkProcess, + throwBenchmarkTrialFailures, + writeProcessRecord +} from './macos-computer-helper-owner-loss-processes.mjs' +import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs' + +const describeMacOS = process.platform === 'darwin' ? describe : describe.skip +const spawnedPids = new Set() +const temporaryDirectories = new Set() + +afterEach(() => { + for (const pid of spawnedPids) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + spawnedPids.clear() + for (const temporaryDirectory of temporaryDirectories) { + rmSync(temporaryDirectory, { recursive: true, force: true }) + } + temporaryDirectories.clear() +}) + +describeMacOS('macOS helper owner-loss benchmark process cleanup', () => { + it('enforces a hard timeout when the trial ignores SIGTERM', () => { + const startedAt = Date.now() + const result = spawnBenchmarkProcess( + process.execPath, + ['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)"], + { stdio: 'ignore', timeout: 100 } + ) + expect(result.error?.code).toBe('ETIMEDOUT') + expect(result.signal).toBe('SIGKILL') + expect(Date.now() - startedAt).toBeLessThan(2_000) + expect(() => process.kill(result.pid, 0)).toThrow() + }) + + it('runs every cleanup stage before aggregating errors', () => { + const completed = [] + let thrown + + try { + runBenchmarkCleanupStages([ + () => { + completed.push(1) + throw new Error('first failure') + }, + () => { + completed.push(2) + }, + () => { + completed.push(3) + throw new Error('last failure') + } + ]) + } catch (error) { + thrown = error + } + expect(completed).toEqual([1, 2, 3]) + expect(thrown).toBeInstanceOf(AggregateError) + expect(thrown.errors.map((error) => error.message)).toEqual(['first failure', 'last failure']) + }) + + it('preserves malformed-result and cleanup failures', () => { + let trialError + try { + parseBenchmarkTrialResult('{malformed') + } catch (error) { + trialError = error + } + const cleanupError = new Error('cleanup failed') + let thrown + + try { + throwBenchmarkTrialFailures(trialError, cleanupError) + } catch (error) { + thrown = error + } + expect(trialError).toBeInstanceOf(SyntaxError) + expect(thrown).toBeInstanceOf(AggregateError) + expect(thrown.errors).toEqual([trialError, cleanupError]) + }) + + it('cleans up a status-zero trial whose result could not be parsed', () => { + expect(benchmarkTrialNeedsCleanup(undefined, false)).toBe(true) + expect(benchmarkTrialNeedsCleanup({ status: 0 }, false)).toBe(true) + expect(benchmarkTrialNeedsCleanup({ status: 0 }, true)).toBe(false) + expect(benchmarkTrialNeedsCleanup({ status: 1 }, true)).toBe(true) + }) + + it('removes a launcher directory after partial trial setup', () => { + const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-owner-partial-setup-test-')) + temporaryDirectories.add(launcherDir) + + const cleanup = cleanupOwnerLossTrial({ + failed: true, + launcherDir, + outputPaths: [] + }) + + expect(cleanup.error).toBeUndefined() + expect(existsSync(launcherDir)).toBe(false) + temporaryDirectories.delete(launcherDir) + }) + + it('kills a timed-out trial group only after validating its environment', async () => { + const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'orca-owner-benchmark-group-test-')) + temporaryDirectories.add(temporaryDirectory) + const childPidPath = path.join(temporaryDirectory, 'child.pid') + const environmentName = `ORCA_OWNER_GROUP_${process.pid}` + const environmentValue = `${Date.now()}` + const fixture = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore' + }) + writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid)) + setInterval(() => {}, 1000) + ` + const result = spawnBenchmarkProcess(process.execPath, ['-e', fixture], { + env: { ...process.env, [environmentName]: environmentValue }, + stdio: 'ignore', + timeout: 100 + }) + const childPid = Number(readFileSync(childPidPath, 'utf8')) + spawnedPids.add(childPid) + const environmentFragment = `${environmentName}=${environmentValue}` + const groupState = { stopped: false } + + expect(() => + signalValidatedProcessGroup(result.pid, `${environmentName}=wrong`, 'SIGSTOP') + ).toThrow('Benchmark process group no longer belongs to this trial') + expect(() => process.kill(childPid, 0)).not.toThrow() + expect( + signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGSTOP', groupState) + ).toBe(true) + expect( + signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGKILL', groupState) + ).toBe(true) + await expect + .poll(() => { + try { + process.kill(childPid, 0) + return true + } catch { + return false + } + }) + .toBe(false) + + spawnedPids.delete(childPid) + }) + + it('resumes the group after post-stop revalidation fails', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [ + { pid: 41, pgid: 41, command: `/launcher ${marker}` }, + { pid: 42, pgid: 41, command: `/child ${marker}` } + ] + const signals = [] + let scanCount = 0 + + expect(() => + signalValidatedProcessGroup( + 41, + marker, + 'SIGKILL', + { stopped: false }, + { + processIdentities: () => { + scanCount += 1 + if (scanCount === 3) { + throw new Error('transient group inspection failure') + } + return members + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + } + ) + ).toThrow('transient group inspection failure') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [-41, 'SIGSTOP'], + [-41, 'SIGCONT'] + ]) + }) + + it('compensates a possible stop after group anchor replacement', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` } + const replacement = { pid: 41, pgid: 99, command: '/unrelated' } + const signals = [] + let scanCount = 0 + + expect(() => + signalValidatedProcessGroup( + 41, + marker, + 'SIGKILL', + { stopped: false }, + { + processIdentities: () => { + scanCount += 1 + return scanCount === 1 ? [anchor] : [replacement] + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + } + ) + ).toThrow('Benchmark process group anchor changed before signaling') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [41, 'SIGCONT'] + ]) + }) + + it('resumes a previously frozen group when final inspection fails', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }] + const signals = [] + let scanCount = 0 + const groupState = { stopped: false } + const operations = { + processIdentities: () => { + scanCount += 1 + if (scanCount === 4) { + throw new Error('transient final inspection failure') + } + return members + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + } + + expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true) + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations) + ).toThrow('transient final inspection failure') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [-41, 'SIGSTOP'], + [-41, 'SIGCONT'] + ]) + }) + + it('resumes a previously frozen group when final anchor stop fails', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [ + { pid: 41, pgid: 41, command: `/launcher ${marker}` }, + { pid: 42, pgid: 41, command: `/child ${marker}` } + ] + const signals = [] + let finalCall = false + const groupState = { stopped: false } + const missingProcessError = Object.assign(new Error('anchor exited'), { code: 'ESRCH' }) + const operations = { + processIdentities: () => members, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + if (finalCall && pid === 41 && signal === 'SIGSTOP') { + throw missingProcessError + } + } + } + + expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true) + finalCall = true + expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(false) + expect(signals.at(-1)).toEqual([-41, 'SIGCONT']) + }) + + it('resumes a previously frozen group after final anchor replacement', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` } + const child = { pid: 42, pgid: 41, command: `/child ${marker}` } + const replacement = { pid: 41, pgid: 99, command: '/unrelated' } + const signals = [] + let scanCount = 0 + const groupState = { stopped: false } + const operations = { + processIdentities: () => { + scanCount += 1 + return scanCount === 5 ? [replacement, child] : [anchor, child] + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + } + + expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true) + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations) + ).toThrow('Benchmark process group anchor changed before signaling') + expect(signals.slice(-2)).toEqual([ + [-41, 'SIGCONT'], + [41, 'SIGCONT'] + ]) + }) + + it('kills a recorded helper in a separate process group', async () => { + const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), 'orca-owner-benchmark-cleanup-test-') + ) + temporaryDirectories.add(temporaryDirectory) + const recordPath = path.join(temporaryDirectory, 'helper.json') + const marker = `orca-owner-cleanup-${process.pid}-${Date.now()}` + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], { + detached: true, + stdio: 'ignore' + }) + spawnedPids.add(helper.pid) + helper.unref() + const exited = new Promise((resolve) => helper.once('exit', resolve)) + const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], { + encoding: 'utf8' + }).trim() + const processGroup = Number( + execFileSync('ps', ['-p', String(helper.pid), '-o', 'pgid='], { + encoding: 'utf8' + }).trim() + ) + writeProcessRecord(recordPath, { pid: helper.pid, pgid: processGroup, command }) + + expect(processGroup).toBe(helper.pid) + expect(killRecordedProcess(recordPath, marker)).toBe(true) + await exited + expect(() => process.kill(helper.pid, 0)).toThrow() + + spawnedPids.delete(helper.pid) + }) + + it('kills every unrecorded helper using its unique trial command', async () => { + const marker = `orca-owner-unrecorded-${process.pid}-${Date.now()}` + const helpers = Array.from({ length: 2 }, () => + spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], { + detached: true, + stdio: 'ignore' + }) + ) + for (const helper of helpers) { + spawnedPids.add(helper.pid) + helper.unref() + } + const exited = Promise.all( + helpers.map((helper) => new Promise((resolve) => helper.once('exit', resolve))) + ) + + expect(killProcessMatchingCommand([process.execPath, marker])).toBe(true) + await exited + for (const helper of helpers) { + expect(() => process.kill(helper.pid, 0)).toThrow() + spawnedPids.delete(helper.pid) + } + }) + + it('continues exact-match cleanup after an earlier match fails', () => { + const marker = `orca-owner-multiple-${process.pid}-${Date.now()}` + const matches = [ + { pid: 41, pgid: 41, command: `/helper ${marker}` }, + { pid: 42, pgid: 42, command: `/helper ${marker}` } + ] + const attempted = [] + let scanCount = 0 + + expect(() => + killProcessMatchingCommand(['/helper', marker], { + processIdentities: () => { + scanCount += 1 + return scanCount === 1 ? matches : [matches[0]] + }, + signalProcessIdentity: (identity) => { + attempted.push(identity.pid) + if (identity.pid === matches[0].pid) { + throw new Error('identity changed') + } + return true + }, + waitForIdentityExit: () => {} + }) + ).toThrow('Benchmark exact-command cleanup failed') + expect(attempted).toEqual([41, 42]) + }) + + it('does not treat an identity query failure as process exit', () => { + const queryError = new Error('transient ps failure') + + expect(() => + processIdentity(41, { + executePs: () => { + throw queryError + }, + signalProcess: () => {} + }) + ).toThrow(queryError) + }) + + it('resumes a helper when post-stop identity inspection fails', () => { + const identity = { pid: 41, pgid: 41, command: '/helper marker' } + const signals = [] + let inspectionCount = 0 + + expect(() => + signalProcessIdentity(identity, 'marker', 'SIGKILL', { + processIdentity: () => { + inspectionCount += 1 + if (inspectionCount === 2) { + throw new Error('transient ps failure after stop') + } + return identity + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + }) + ).toThrow('transient ps failure after stop') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [41, 'SIGCONT'] + ]) + }) + + it('compensates a possible stop after helper PID replacement', () => { + const identity = { pid: 41, pgid: 41, command: '/helper marker' } + const replacement = { pid: 41, pgid: 41, command: '/unrelated' } + const signals = [] + let inspectionCount = 0 + + expect(() => + signalProcessIdentity(identity, 'marker', 'SIGKILL', { + processIdentity: () => { + inspectionCount += 1 + return inspectionCount === 1 ? identity : replacement + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + }) + ).toThrow('Recorded benchmark helper PID changed before signaling') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [41, 'SIGCONT'] + ]) + }) + + it('preserves helper identity errors when compensation fails', () => { + const identity = { pid: 41, pgid: 41, command: '/helper marker' } + const replacement = { pid: 41, pgid: 41, command: '/unrelated' } + let inspectionCount = 0 + let thrown + + try { + signalProcessIdentity(identity, 'marker', 'SIGKILL', { + processIdentity: () => { + inspectionCount += 1 + return inspectionCount === 1 ? identity : replacement + }, + signalProcess: (_pid, signal) => { + if (signal === 'SIGCONT') { + throw new Error('resume denied') + } + } + }) + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(AggregateError) + expect(thrown.errors.map((error) => error.message)).toEqual([ + 'Recorded benchmark helper PID changed before signaling', + 'resume denied' + ]) + }) + + it('treats a missing PID as process exit after an identity query failure', () => { + const missingProcessError = Object.assign(new Error('missing process'), { code: 'ESRCH' }) + + expect( + processIdentity(41, { + executePs: () => { + throw new Error('ps found no process') + }, + signalProcess: () => { + throw missingProcessError + } + }) + ).toBeNull() + }) + + it('runs unique-command cleanup after an invalid process record', async () => { + const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), 'orca-owner-benchmark-fallback-test-') + ) + temporaryDirectories.add(temporaryDirectory) + const recordPath = path.join(temporaryDirectory, 'helper.json') + const marker = `orca-owner-invalid-record-${process.pid}-${Date.now()}` + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], { + detached: true, + stdio: 'ignore' + }) + spawnedPids.add(helper.pid) + helper.unref() + const exited = new Promise((resolve) => helper.once('exit', resolve)) + const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], { + encoding: 'utf8' + }).trim() + writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command }) + + expect(() => + killRecordedAndMatchingProcesses(recordPath, marker, [process.execPath, marker]) + ).toThrow('Recorded benchmark helper identity is invalid') + await exited + expect(() => process.kill(helper.pid, 0)).toThrow() + + spawnedPids.delete(helper.pid) + }) + + it('rejects a record that is not a detached process-group identity', () => { + const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), 'orca-owner-benchmark-identity-test-') + ) + temporaryDirectories.add(temporaryDirectory) + const recordPath = path.join(temporaryDirectory, 'helper.json') + const marker = `orca-owner-invalid-identity-${process.pid}-${Date.now()}` + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], { + stdio: 'ignore' + }) + spawnedPids.add(helper.pid) + helper.unref() + const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], { + encoding: 'utf8' + }).trim() + writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command }) + + expect(() => killRecordedProcess(recordPath, marker)).toThrow( + 'Recorded benchmark helper identity is invalid' + ) + expect(() => process.kill(helper.pid, 0)).not.toThrow() + }) +}) diff --git a/config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs b/config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs new file mode 100644 index 00000000000..c3fb4aaf384 --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs @@ -0,0 +1,63 @@ +import { closeSync, existsSync, readFileSync, rmSync } from 'node:fs' +import { + killRecordedAndMatchingProcesses, + runBenchmarkCleanupStages, + signalValidatedProcessGroup +} from './macos-computer-helper-owner-loss-processes.mjs' + +export function cleanupOwnerLossTrial(options) { + const groupState = { stopped: false, anchorPid: null } + let error + let output = '' + try { + runBenchmarkCleanupStages([ + () => { + if (options.failed && Number.isInteger(options.pid) && options.marker) { + signalValidatedProcessGroup(options.pid, options.marker, 'SIGSTOP', groupState) + } + }, + () => { + if (options.failed && options.recordPath && options.tempDir) { + killRecordedAndMatchingProcesses(options.recordPath, options.helperPath, [ + options.helperPath, + options.tempDir + ]) + } + }, + () => { + if (options.failed && Number.isInteger(options.pid) && options.marker) { + signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL', groupState) + } + }, + () => { + if (options.stderrDescriptor !== undefined) { + closeSync(options.stderrDescriptor) + } + }, + () => { + if (options.stdoutDescriptor !== undefined) { + closeSync(options.stdoutDescriptor) + } + }, + () => { + output = (options.outputPaths ?? []) + .filter((outputPath) => outputPath && existsSync(outputPath)) + .map((outputPath) => readFileSync(outputPath, 'utf8')) + .join('') + }, + () => { + if (options.launcherDir) { + rmSync(options.launcherDir, { recursive: true, force: true }) + } + }, + () => { + if (options.tempDir) { + rmSync(options.tempDir, { recursive: true, force: true }) + } + } + ]) + } catch (caught) { + error = caught + } + return { error, output } +} diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 74ad245ec77..516aac21a23 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -297,116 +297,6 @@ describe('Electron runtime package contract', () => { expect(releaseMacWorkflowText).not.toContain('SIGNPATH_') }) - it('preflights SignPath module install before Windows signing side effects', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const installStepIndexes = stepNames.flatMap((name, index) => - name === 'Install SignPath PowerShell module' ? [index] : [] - ) - const buildIndex = stepNames.indexOf('Build Windows release artifacts') - const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime') - const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath') - const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath') - - expect(verifyNodePtyIndex).toBe(buildIndex + 1) - expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1]) - expect(installStepIndexes[0]).toBeLessThan(uploadIndex) - - expect(steps[verifyNodePtyIndex].run).toContain( - 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' - ) - expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') - - const uploadThroughDownloadScript = steps - .slice(uploadIndex, downloadIndex + 1) - .map((step) => step.run ?? '') - .join('\n') - - expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath') - - const installStep = steps[installStepIndexes[0]] - const installRun = installStep.run - const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map( - ([, seconds]) => seconds - ) - - expect(installStep.if).toBe("matrix.platform == 'win'") - expect(installStep.shell).toBe('pwsh') - expect(installRun).toContain( - 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' - ) - expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') - expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') - expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) - expect(installRun).toContain( - "$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'" - ) - expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/) - expect(sleepSeconds).toEqual(['15', '30']) - expect(installRun).toContain( - 'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop' - ) - expect(installRun).toContain('Import-Module SignPath') - expect(installRun).toContain( - 'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop' - ) - expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force') - expect(installRun).not.toContain('SignPath*') - expect(installRun.indexOf('if ($attempt -eq 3)')).toBeLessThan( - installRun.indexOf('Remove-Item -LiteralPath $signPathModulePath') - ) - expect(installRun).toMatch(/if \(\$attempt -eq 3\) {\s+throw\s+}/) - expect(installRun).not.toMatch(/throw\s+\$_/) - }) - - it('verifies Windows inner binary signatures fail-open before publishing', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') - const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures') - const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence') - const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') - - expect(outerVerifyIndex).toBeGreaterThan(-1) - expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) - expect(evidenceIndex).toBe(innerVerifyIndex + 1) - expect(publishIndex).toBe(evidenceIndex + 1) - - // Why fail-open: unsigned inner binaries must warn, not block, until the - // flow is proven on a real release (issue #7785). Flip this to 'true' - // together with the workflow env to make the gate required. - expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false') - - // Why: every step in the inner-signing chain must be unable to fail the - // release — a SignPath outage or timeout falls through to today's - // unsigned-inner flow instead of blocking the cut. - const innerChainStepNames = [ - 'Stage unsigned inner PE files for signing', - 'Upload unsigned inner binaries for SignPath', - 'Submit inner binaries signing request', - 'Notify Slack that inner-binary signing is waiting for approval', - 'Download signed inner binaries from SignPath', - 'Restore signed inner binaries into unpacked app', - 'Replace cached elevate.exe with the signed copy', - 'Rebuild NSIS installer from signed unpacked app' - ] - for (const stepName of innerChainStepNames) { - const step = steps[stepNames.indexOf(stepName)] - expect(step, stepName).toBeDefined() - expect(step['continue-on-error'], stepName).toBe(true) - } - }) - it('publishes both Linux release matrix entries', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), diff --git a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs new file mode 100644 index 00000000000..1ae558c7691 --- /dev/null +++ b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +describe('packaged hang watchdog worker contract', () => { + it('boots the worker from app.asar in PR checks', () => { + const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + const smokeSource = readFileSync( + 'config/scripts/smoke-packaged-hang-watchdog-worker.mjs', + 'utf8' + ) + const smokeStep = workflow.jobs.package.steps.find( + (step) => step.name === 'Smoke packaged hang watchdog worker' + ) + + expect(smokeStep.run).toBe( + 'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked' + ) + expect(smokeSource).toContain( + "process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]" + ) + }) + + // Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion. + it('fails the smoke when the packaged worker never reports success', () => { + const smokeSource = readFileSync( + 'config/scripts/smoke-packaged-hang-watchdog-worker.mjs', + 'utf8' + ) + + expect(smokeSource).toContain('app.exit(1)') + expect(smokeSource).not.toContain('app.quit()') + expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))') + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 6c758a74fe1..dbece23cafd 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -177,7 +177,8 @@ describe('PR workflow parallelism', () => { 'git_compatibility', 'shell_contracts', 'test', - 'package' + 'package', + 'package_windows' ]) }) }) diff --git a/config/scripts/release-cut-signpath-slack.test.mjs b/config/scripts/release-cut-signpath-slack.test.mjs new file mode 100644 index 00000000000..0795ac8895a --- /dev/null +++ b/config/scripts/release-cut-signpath-slack.test.mjs @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') + +describe('release-cut SignPath Slack approval pings', () => { + it('includes cut source ref/commit and who triggered the cut', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') + ) + + const cutOutputs = workflow.jobs.cut.outputs + expect(cutOutputs.source_ref).toContain('steps.resolve.outputs.ref') + expect(cutOutputs.source_sha).toContain('steps.resolve.outputs.sha') + expect(cutOutputs.source_short_sha).toContain('steps.resolve.outputs.short_sha') + + const steps = workflow.jobs.build.steps + const notifySteps = steps.filter( + (step) => + step.name === 'Notify Slack that inner-binary signing is waiting for approval' || + step.name === 'Notify Slack that Windows signing is waiting for approval' + ) + expect(notifySteps).toHaveLength(2) + + for (const step of notifySteps) { + expect(step.env.SOURCE_REF).toContain('needs.cut.outputs.source_ref') + expect(step.env.SOURCE_SHA).toContain('needs.cut.outputs.source_sha') + expect(step.env.SOURCE_SHORT_SHA).toContain('needs.cut.outputs.source_short_sha') + expect(step.env.CUT_BY).toMatch(/github\.(triggering_actor|actor)/) + expect(step.run).toContain('Source:') + expect(step.run).toContain('cut by') + } + }) +}) diff --git a/config/scripts/setup-hourly-release-token.sh b/config/scripts/setup-hourly-release-token.sh new file mode 100755 index 00000000000..f21ce7b5bb7 --- /dev/null +++ b/config/scripts/setup-hourly-release-token.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Provisions the credentials hourly-mac-build.yml uses to publish into +# stablyai/orca-hourly. GITHUB_TOKEN cannot be used: it is scoped to the repo +# running the workflow, and hourly artifacts are published to a different one. +# +# A GitHub App is used rather than a PAT because its private key does not expire +# — no yearly rotation — and it belongs to the org rather than to the person who +# created it, so it survives that person leaving. +# +# The key is read from a file and piped straight into `gh secret set`. It is never +# echoed, never passed as a command-line argument (argv is world-readable via +# `ps`), and never copied anywhere on disk. +# +# Usage: bash config/scripts/setup-hourly-release-token.sh [path/to/key.pem] +# +set -euo pipefail + +# Guard: xtrace would echo the key to stderr on every expansion. Test before +# disabling, or the check reads the state this line just cleared and never fires. +if [[ -o xtrace ]]; then + echo "Refusing to run with xtrace enabled; it would echo the private key." >&2 + exit 1 +fi +set +x + +MAIN_REPO="stablyai/orca" +HOURLY_REPO="stablyai/orca-hourly" +APP_ID_SECRET="HOURLY_RELEASE_APP_ID" +APP_KEY_SECRET="HOURLY_RELEASE_APP_PRIVATE_KEY" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com" +gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login" + +# Setting repo secrets requires admin; check before asking for anything. +if [[ "$(gh api "repos/$MAIN_REPO" --jq '.permissions.admin' 2>/dev/null)" != "true" ]]; then + fail "You need admin on $MAIN_REPO to set repository secrets." +fi +gh api "repos/$HOURLY_REPO" --jq '.full_name' >/dev/null 2>&1 || + fail "$HOURLY_REPO does not exist or you cannot see it." + +cat < Contents: Read and write + (leave everything else alone) + 4. "Where can this app be installed?" -> Only on this account + 5. Create, then note the App ID shown at the top of the page. + 6. Generate a private key (bottom of the page) — a .pem downloads. + 7. Install App -> Only select repositories -> $HOURLY_REPO + +EOF + +read -rp "App ID (numeric): " APP_ID +[[ "$APP_ID" =~ ^[0-9]+$ ]] || fail "App ID must be numeric, got: ${APP_ID:-}" + +KEY_PATH="${1:-}" +if [[ -z "$KEY_PATH" ]]; then + read -rp "Path to the downloaded .pem: " KEY_PATH +fi +# Expand a leading ~ so a pasted path works without quoting rules. +KEY_PATH="${KEY_PATH/#\~/$HOME}" +[[ -r "$KEY_PATH" ]] || fail "Cannot read key file: $KEY_PATH" +grep -q "BEGIN.*PRIVATE KEY" "$KEY_PATH" || + fail "$KEY_PATH does not look like a PEM private key." + +echo "Storing $APP_ID_SECRET in $MAIN_REPO..." +printf '%s' "$APP_ID" | gh secret set "$APP_ID_SECRET" --repo "$MAIN_REPO" || + fail "Could not set $APP_ID_SECRET." + +# Piped on stdin so the key never appears in argv or in shell history. +echo "Storing $APP_KEY_SECRET in $MAIN_REPO..." +gh secret set "$APP_KEY_SECRET" --repo "$MAIN_REPO" <"$KEY_PATH" || + fail "Could not set $APP_KEY_SECRET." + +echo +echo "Done. Both secrets are set on $MAIN_REPO." +echo +echo "Delete your local copy of the key — the workflow reads it from the secret," +echo "and a .pem sitting in ~/Downloads is a standing credential:" +echo " rm '$KEY_PATH'" +echo +echo "Smoke-test the pipeline without waiting for the hour (after this merges):" +echo " gh workflow run hourly-mac-build.yml --repo $MAIN_REPO -f force=true" +echo " gh run watch --repo $MAIN_REPO" diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs new file mode 100644 index 00000000000..72e6cf25829 --- /dev/null +++ b/config/scripts/skills-cli-package-workflow.test.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + +describe('packaged skills CLI PR gates', () => { + it('builds and executes the Windows packaged CLI', () => { + const job = workflow.jobs.package_windows + const buildStep = job.steps.find((step) => step.name === 'Build package inputs') + const prepareStep = job.steps.find((step) => step.name === 'Prepare Electron native runtime') + const packageStep = job.steps.find((step) => step.name === 'Package unpacked app') + const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI') + + expect(job['runs-on']).toBe('windows-2022') + expect(buildStep.run).toBe('pnpm run build:release') + expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') + expect(packageStep.run).toContain('electron-builder') + expect(packageStep.run).toContain('--dir') + expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') + expect(smokeStep.run).toBe( + 'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked' + ) + + const aggregateStep = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(aggregateStep.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}') + expect(aggregateStep.run).toContain('"$PACKAGE_WINDOWS"') + }) +}) diff --git a/config/scripts/smoke-packaged-cli.mjs b/config/scripts/smoke-packaged-cli.mjs index 269118d867e..4cecf49ad4b 100644 --- a/config/scripts/smoke-packaged-cli.mjs +++ b/config/scripts/smoke-packaged-cli.mjs @@ -3,6 +3,7 @@ import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' import { promisify } from 'node:util' +import assert from 'node:assert/strict' const execFileAsync = promisify(execFile) @@ -37,10 +38,41 @@ const copiedAppDir = join(tempRoot, basename(appDir)) try { await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true }) const cliPath = getPackagedCliPath(copiedAppDir) - await execFileAsync(cliPath, ['--help'], { - env: { ...process.env, NODE_PATH: '' } - }) - console.log(`[packaged-cli-smoke] ${cliPath} --help succeeded outside the repo`) + const env = { ...process.env, NODE_PATH: '' } + delete env.ORCA_CLI_CWD + const run = (args) => + execFileAsync(cliPath, args, { + env, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + timeout: 30_000 + }) + + await run(['--help']) + const list = JSON.parse((await run(['skills', 'list', '--json'])).stdout) + assert(list.topics.some((topic) => topic.name === 'orca-cli')) + assert.match((await run(['skills', 'get', 'orca-cli'])).stdout, /name: orca-cli/) + assert.match((await run(['skills', 'get', 'computer-use'])).stdout, /name: computer-use/) + const install = JSON.parse( + ( + await run([ + 'skills', + 'install', + '--skill', + 'orca-cli', + '--agent', + 'codex', + '--dry-run', + '--json' + ]) + ).stdout + ) + const update = JSON.parse( + (await run(['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])).stdout + ) + assert.equal(install.executed, false) + assert.equal(update.executed, false) + console.log(`[packaged-cli-smoke] help and skills commands passed via ${cliPath}`) } finally { await rm(tempRoot, { recursive: true, force: true }) } diff --git a/config/scripts/smoke-packaged-hang-watchdog-worker.mjs b/config/scripts/smoke-packaged-hang-watchdog-worker.mjs new file mode 100644 index 00000000000..ae9b340785a --- /dev/null +++ b/config/scripts/smoke-packaged-hang-watchdog-worker.mjs @@ -0,0 +1,179 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Worker } from 'node:worker_threads' + +const INTERNAL_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_INTERNAL' +const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR' +const TIMEOUT_MS = 100 +const CHECK_INTERVAL_MS = 20 +const POLL_TIMEOUT_MS = 5_000 +const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall' + +function sleep(ms) { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)) +} + +function readAppDirArg(argv) { + const explicit = argv.find((arg) => arg.startsWith('--app-dir=')) + if (explicit) { + return explicit.slice('--app-dir='.length) + } + if (process.platform === 'darwin') { + return 'dist/mac-arm64/Orca.app' + } + if (process.platform === 'win32') { + return 'dist/win-unpacked' + } + return 'dist/linux-unpacked' +} + +function getResourcesDir(appDir) { + return process.platform === 'darwin' || appDir.endsWith('.app') + ? join(appDir, 'Contents', 'Resources') + : join(appDir, 'resources') +} + +function readMarker(markerPath) { + try { + return JSON.parse(readFileSync(markerPath, 'utf8')) + } catch { + return null + } +} + +async function waitForMarker(markerPath, predicate, workerError) { + const deadline = Date.now() + POLL_TIMEOUT_MS + while (Date.now() < deadline) { + if (workerError.current) { + throw workerError.current + } + const marker = readMarker(markerPath) + if (predicate(marker)) { + return marker + } + await sleep(CHECK_INTERVAL_MS) + } + throw new Error(`Timed out waiting for packaged watchdog marker at ${markerPath}`) +} + +async function waitForExit(worker, workerError) { + const exitCode = await Promise.race([ + new Promise((resolveExit) => worker.once('exit', resolveExit)), + sleep(POLL_TIMEOUT_MS).then(() => { + throw new Error('Timed out waiting for packaged watchdog worker to exit') + }) + ]) + if (workerError.current) { + throw workerError.current + } + if (exitCode !== 0) { + throw new Error(`Packaged watchdog worker exited with code ${exitCode}`) + } +} + +async function runInternal() { + const appAsar = process.env[ASAR_ENV] + if (!appAsar) { + throw new Error(`Missing ${ASAR_ENV}`) + } + const { app } = await import('electron') + const tempRoot = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-smoke-')) + const markerPath = join(tempRoot, 'main-thread-hang.json') + const entryPath = join(appAsar, 'out', 'main', 'main-thread-hang-watchdog-entry.js') + let worker + try { + await app.whenReady() + if (!existsSync(entryPath)) { + throw new Error(`Packaged watchdog entry is missing from app.asar: ${entryPath}`) + } + const workerError = { current: null } + worker = new Worker(entryPath, { + workerData: { + parentPid: process.pid, + markerPath, + timeoutMs: TIMEOUT_MS, + checkIntervalMs: CHECK_INTERVAL_MS + } + }) + worker.once('error', (error) => { + workerError.current = error + }) + await waitForMarker(markerPath, (marker) => marker?.selfRecovered === false, workerError) + worker.postMessage({ type: 'heartbeat' }) + await waitForMarker(markerPath, (marker) => marker?.selfRecovered === true, workerError) + worker.postMessage({ type: 'shutdown' }) + await waitForExit(worker, workerError) + worker = undefined + console.log(SUCCESS_LINE) + } finally { + await worker?.terminate() + rmSync(tempRoot, { recursive: true, force: true }) + } +} + +function runSmoke() { + const appDir = resolve(readAppDirArg(process.argv.slice(2))) + const appAsar = join(getResourcesDir(appDir), 'app.asar') + if (!existsSync(appAsar)) { + throw new Error(`Packaged app archive is missing: ${appAsar}`) + } + const require = createRequire(import.meta.url) + const executable = require('electron') + const launcherDir = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-launcher-')) + const launcherPath = join(launcherDir, 'main.cjs') + writeFileSync( + join(launcherDir, 'package.json'), + JSON.stringify({ name: 'orca-packaged-watchdog-smoke', main: 'main.cjs' }) + ) + writeFileSync( + launcherPath, + `import(${JSON.stringify(pathToFileURL(import.meta.filename).href)}).catch((error) => { + console.error(error) + process.exitCode = 1 +})\n` + ) + const env = { ...process.env, [INTERNAL_ENV]: '1', [ASAR_ENV]: appAsar, NODE_PATH: '' } + delete env.ELECTRON_RUN_AS_NODE + try { + const electronArgs = + process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir] + const result = spawnSync(executable, electronArgs, { + env, + encoding: 'utf8', + timeout: 15_000 + }) + if (result.status !== 0) { + throw new Error( + `Packaged watchdog smoke failed (${result.error?.message ?? result.signal ?? result.status}):\n` + + `${result.stderr || result.stdout}` + ) + } + // Why: Electron discards process.exitCode, so status 0 alone can't prove the worker ran. + if (!result.stdout.includes(SUCCESS_LINE)) { + throw new Error( + `Packaged watchdog smoke did not report success:\n${result.stderr || result.stdout}` + ) + } + process.stdout.write(result.stdout) + } finally { + rmSync(launcherDir, { recursive: true, force: true }) + } +} + +if (process.env[INTERNAL_ENV] === '1') { + // Why: a graceful quit exits 0 regardless of process.exitCode; only app.exit propagates failure. + const { app } = await import('electron') + try { + await runInternal() + app.exit(0) + } catch (error) { + console.error(error) + app.exit(1) + } +} else { + runSmoke() +} diff --git a/config/scripts/verify-skills-cli-runtime.cjs b/config/scripts/verify-skills-cli-runtime.cjs new file mode 100644 index 00000000000..0a76078d318 --- /dev/null +++ b/config/scripts/verify-skills-cli-runtime.cjs @@ -0,0 +1,229 @@ +const { existsSync, readFileSync, realpathSync } = require('node:fs') +const { builtinModules, createRequire, isBuiltin } = require('node:module') +const { dirname, isAbsolute, join, relative, resolve, sep } = require('node:path') +const { spawnSync } = require('node:child_process') +const ts = require('typescript-api') + +const BUILTINS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])) +const CLI_COMMAND_TIMEOUT_MS = 30_000 + +function artifactPath(outDir, file) { + return relative(outDir, file).split(sep).join('/') +} + +function runtimeImportSpecifiers(source, file) { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.JS + ) + const specifiers = [] + + function visit(node) { + if (ts.isCallExpression(node) && node.arguments.length > 0) { + const [argument] = node.arguments + const expression = node.expression + const isRequire = ts.isIdentifier(expression) && expression.text === 'require' + const isRequireResolve = + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === 'require' && + expression.name.text === 'resolve' + const isDynamicImport = expression.kind === ts.SyntaxKind.ImportKeyword + + if ((isRequire || isRequireResolve || isDynamicImport) && ts.isStringLiteralLike(argument)) { + specifiers.push(argument.text) + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return specifiers +} + +function isOutsideRoot(root, target) { + const pathFromRoot = relative(root, target) + return isAbsolute(pathFromRoot) || pathFromRoot === '..' || pathFromRoot.startsWith(`..${sep}`) +} + +function isOptionalPackageImport(artifactRoot, importer, specifier) { + if (specifier.startsWith('.') || isAbsolute(specifier)) { + return false + } + const segments = specifier.split('/') + const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] + let directory = realpathSync(dirname(importer)) + + while (!isOutsideRoot(artifactRoot, directory)) { + const packageJson = join(directory, 'package.json') + if (existsSync(packageJson)) { + try { + const manifest = JSON.parse(readFileSync(packageJson, 'utf8')) + return ( + Object.hasOwn(manifest.optionalDependencies ?? {}, packageName) || + manifest.peerDependenciesMeta?.[packageName]?.optional === true + ) + } catch { + return false + } + } + if (directory === artifactRoot) { + break + } + directory = dirname(directory) + } + return false +} + +function resolveRuntimeImport(outDir, artifactRoot, importer, specifier) { + if (BUILTINS.has(specifier) || isBuiltin(specifier)) { + return null + } + let resolved + try { + resolved = createRequire(importer).resolve(specifier) + } catch (error) { + if (isOptionalPackageImport(artifactRoot, importer, specifier)) { + return null + } + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `[verify-skills-cli-runtime] missing runtime import "${specifier}" from ` + + `${artifactPath(outDir, importer)}: ${detail}` + ) + } + if (isOutsideRoot(artifactRoot, resolved)) { + throw new Error( + `[verify-skills-cli-runtime] import "${specifier}" from ` + + `${artifactPath(outDir, importer)} resolved outside ${artifactRoot}: ${resolved}` + ) + } + return resolved +} + +function collectRuntimeClosure(outDir, artifactRoot = dirname(outDir)) { + outDir = realpathSync(outDir) + artifactRoot = realpathSync(artifactRoot) + if (isOutsideRoot(artifactRoot, outDir)) { + throw new Error(`[verify-skills-cli-runtime] ${outDir} is outside ${artifactRoot}`) + } + const entry = resolve(outDir, 'cli', 'index.js') + if (!existsSync(entry)) { + throw new Error(`[verify-skills-cli-runtime] missing entry ${entry}`) + } + const pending = [entry] + const visited = new Set() + + while (pending.length > 0) { + const file = pending.pop() + if (!file || visited.has(file)) { + continue + } + visited.add(file) + const source = readFileSync(file, 'utf8') + for (const specifier of runtimeImportSpecifiers(source, file)) { + const resolved = resolveRuntimeImport(outDir, artifactRoot, file, specifier) + if (resolved && !isOutsideRoot(artifactRoot, resolved) && /\.(?:c|m)?js$/.test(resolved)) { + pending.push(resolved) + } + } + } + + return [...visited].sort() +} + +function runCli(outDir, args, timeoutMs = CLI_COMMAND_TIMEOUT_MS) { + const entry = resolve(outDir, 'cli', 'index.js') + const env = { ...process.env, NODE_PATH: '' } + delete env.ORCA_CLI_CWD + const result = spawnSync(process.execPath, [entry, ...args], { + cwd: dirname(outDir), + encoding: 'utf8', + env, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + timeout: timeoutMs + }) + if (result.error || result.signal || result.status !== 0) { + const detail = [ + result.error?.message, + result.signal ? `terminated by ${result.signal}` : null, + result.stdout, + result.stderr + ] + .filter(Boolean) + .join('\n') + throw new Error( + `[verify-skills-cli-runtime] ${args.join(' ')} exited ${String(result.status)}\n${detail}` + ) + } + return result.stdout +} + +function parseJson(label, output) { + try { + return JSON.parse(output) + } catch { + throw new Error(`[verify-skills-cli-runtime] ${label} emitted invalid JSON:\n${output}`) + } +} + +function verifySkillsCliRuntime(outDir, artifactRoot = dirname(outDir), options = {}) { + const absoluteOutDir = resolve(outDir) + const closure = collectRuntimeClosure(absoluteOutDir, resolve(artifactRoot)) + if (options.executeCommands === false) { + return { closureFiles: closure.length, commands: 0 } + } + const list = parseJson('skills list', runCli(absoluteOutDir, ['skills', 'list', '--json'])) + const topicNames = new Set(list.topics?.map((topic) => topic.name)) + for (const topic of ['orca-cli', 'computer-use']) { + if (!topicNames.has(topic)) { + throw new Error(`[verify-skills-cli-runtime] skills list omitted ${topic}`) + } + const guide = runCli(absoluteOutDir, ['skills', 'get', topic]) + if (!guide.includes(`name: ${topic}`)) { + throw new Error(`[verify-skills-cli-runtime] skills get ${topic} returned the wrong guide`) + } + } + + const install = parseJson( + 'skills install --dry-run', + runCli(absoluteOutDir, [ + 'skills', + 'install', + '--skill', + 'orca-cli', + '--agent', + 'codex', + '--dry-run', + '--json' + ]) + ) + const update = parseJson( + 'skills update --dry-run', + runCli(absoluteOutDir, ['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json']) + ) + if (install.executed !== false || update.executed !== false) { + throw new Error('[verify-skills-cli-runtime] a dry-run reported execution') + } + + return { closureFiles: closure.length, commands: 5 } +} + +if (require.main === module) { + try { + const result = verifySkillsCliRuntime(process.argv[2] ?? 'out') + console.log( + `[verify-skills-cli-runtime] ${result.closureFiles} closure files and ` + + `${result.commands} commands passed` + ) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} + +module.exports = { collectRuntimeClosure, runCli, verifySkillsCliRuntime } diff --git a/config/scripts/verify-skills-cli-runtime.test.mjs b/config/scripts/verify-skills-cli-runtime.test.mjs new file mode 100644 index 00000000000..f60adfd38ac --- /dev/null +++ b/config/scripts/verify-skills-cli-runtime.test.mjs @@ -0,0 +1,230 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + collectRuntimeClosure, + runCli, + verifySkillsCliRuntime +} = require('./verify-skills-cli-runtime.cjs') + +async function writeSkillsCliFixture(outDir, handlerSource) { + const cliDir = join(outDir, 'cli') + const handlerDir = join(cliDir, 'handlers') + await mkdir(handlerDir, { recursive: true }) + await writeFile(join(cliDir, 'index.js'), "require('./handlers/skills')\n", 'utf8') + await writeFile(join(handlerDir, 'skills.js'), handlerSource, 'utf8') +} + +describe('skills CLI runtime closure', () => { + it('runs after Electron composes the final output', async () => { + const packageJson = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8') + ) + for (const scriptName of ['build:desktop', 'build:release']) { + const script = packageJson.scripts[scriptName] + expect(script.indexOf('build:electron-vite')).toBeLessThan( + script.indexOf('verify:built-skills-cli') + ) + } + }) + + it('reports the missing final-artifact import and its owner', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture(root, "require('../../main/codex-cli/command')\n") + + expect(() => collectRuntimeClosure(root)).toThrow( + /missing runtime import "\.\.\/\.\.\/main\/codex-cli\/command" from cli\/handlers\/skills\.js/ + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('walks static and dynamic relative imports', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const sharedDir = join(root, 'shared') + await mkdir(sharedDir, { recursive: true }) + await writeSkillsCliFixture( + root, + "require('../../shared/first.js'); import('../../shared/second.js')\n" + ) + await writeFile(join(sharedDir, 'first.js'), '', 'utf8') + await writeFile(join(sharedDir, 'second.js'), '', 'utf8') + + expect( + collectRuntimeClosure(root) + .map((file) => relative(realpathSync(root), file)) + .sort() + ).toEqual(['cli/handlers/skills.js', 'cli/index.js', 'shared/first.js', 'shared/second.js']) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('ignores import-shaped text in comments and strings', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture( + root, + [ + "// require('../../missing-comment.js')", + 'const message = "import(\'../../missing-string.js\')"', + "const template = `require.resolve('../../missing-template.js')`" + ].join('\n') + ) + + expect(collectRuntimeClosure(root)).toHaveLength(2) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('can inspect a cross-arch artifact without executing it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture(root, '') + + expect(verifySkillsCliRuntime(root, undefined, { executeCommands: false })).toEqual({ + closureFiles: 2, + commands: 0 + }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('bounds command execution time', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture(root, 'setInterval(() => {}, 1_000)\n') + + expect(() => runCli(root, [], 50)).toThrow(/ETIMEDOUT|terminated by SIGKILL/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects bare imports resolved outside the artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const externalPackageDir = join(root, 'node_modules', 'external-package') + await mkdir(externalPackageDir, { recursive: true }) + await writeSkillsCliFixture(outDir, "require('external-package')\n") + await writeFile( + join(externalPackageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8') + + expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow( + /external-package.*resolved outside/s + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects package dependencies resolved outside the artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const packageDir = join(artifactRoot, 'node_modules', 'inside-package') + const externalPackageDir = join(root, 'node_modules', 'ancestor-dependency') + await writeSkillsCliFixture(outDir, "require('inside-package')\n") + await mkdir(packageDir, { recursive: true }) + await mkdir(externalPackageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(packageDir, 'index.js'), "require('ancestor-dependency')\n", 'utf8') + await writeFile( + join(externalPackageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8') + + expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow( + /ancestor-dependency.*resolved outside/s + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('allows absent dependencies declared optional by their package', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const packageDir = join(artifactRoot, 'node_modules', 'inside-package') + await writeSkillsCliFixture(outDir, "require('inside-package')\n") + await mkdir(packageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ + main: 'index.js', + peerDependencies: { 'optional-native': '*' }, + peerDependenciesMeta: { 'optional-native': { optional: true } } + }), + 'utf8' + ) + await writeFile( + join(packageDir, 'index.js'), + "try { require('optional-native') } catch {}\n", + 'utf8' + ) + + expect(collectRuntimeClosure(outDir, artifactRoot)).toHaveLength(3) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects optional dependencies resolved only outside the artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const packageDir = join(artifactRoot, 'node_modules', 'inside-package') + const externalPackageDir = join(root, 'node_modules', 'optional-native') + await writeSkillsCliFixture(outDir, "require('inside-package')\n") + await mkdir(packageDir, { recursive: true }) + await mkdir(externalPackageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ + main: 'index.js', + optionalDependencies: { 'optional-native': '*' } + }), + 'utf8' + ) + await writeFile(join(packageDir, 'index.js'), "require('optional-native')\n", 'utf8') + await writeFile( + join(externalPackageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8') + + expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow( + /optional-native.*resolved outside/s + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs new file mode 100644 index 00000000000..fc2087379f8 --- /dev/null +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -0,0 +1,161 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') + +const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8')) + +describe('Windows signing workflow contract', () => { + it('preflights SignPath module install before Windows signing side effects', () => { + const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const installStepIndexes = stepNames.flatMap((name, index) => + name === 'Install SignPath PowerShell module' ? [index] : [] + ) + const buildIndex = stepNames.indexOf('Build Windows release artifacts') + const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime') + const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath') + const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath') + + expect(verifyNodePtyIndex).toBe(buildIndex + 1) + expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1]) + expect(installStepIndexes[0]).toBeLessThan(uploadIndex) + + expect(steps[verifyNodePtyIndex].run).toContain( + 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' + ) + expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') + + const uploadThroughDownloadScript = steps + .slice(uploadIndex, downloadIndex + 1) + .map((step) => step.run ?? '') + .join('\n') + + expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath') + + const installStep = steps[installStepIndexes[0]] + + expect(installStep.if).toBe("matrix.platform == 'win'") + expect(installStep.uses).toBe('./.github/actions/install-signpath-module') + expect(installStep.run).toBeUndefined() + + const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') + const actionStep = installAction.runs.steps[0] + const installRun = actionStep.run + const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map( + ([, seconds]) => seconds + ) + + expect(installAction.runs.using).toBe('composite') + expect(actionStep.shell).toBe('pwsh') + expect(installRun).toContain( + 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' + ) + expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') + expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') + expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) + expect(installRun).toContain( + "$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'" + ) + expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/) + expect(sleepSeconds).toContain('15') + expect(sleepSeconds).toContain('30') + expect(installRun).toContain( + 'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop' + ) + expect(installRun).toContain('Import-Module SignPath -ErrorAction Stop') + expect(installRun).toContain( + 'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop' + ) + expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force') + expect(installRun).not.toContain('SignPath*') + expect(installRun).not.toMatch(/throw\s+\$_/) + }) + + it('falls back to a hash-pinned SignPath nupkg when the gallery API is down', () => { + const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') + const installRun = installAction.runs.steps[0].run + + // Why: the gallery API 403s during Azure Front Door incidents while its CDN + // stays up, so a pinned nupkg is the fallback. The hash pin is the only + // integrity check on that route — losing it would let any payload install. + const { 'fallback-version': version, 'fallback-sha256': sha256 } = installAction.inputs + expect(version.default).toMatch(/^4\.\d+\.\d+$/) + expect(sha256.default).toMatch(/^[0-9a-f]{64}$/) + expect(installRun).toContain('Get-FileHash -LiteralPath $nupkg -Algorithm SHA256') + expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()') + expect(installRun).toContain('throw "SHA-256 mismatch for $source') + expect(installRun).toContain( + 'https://cdn.powershellgallery.com/packages/signpath.$version.nupkg' + ) + + // The module only resolves by name when the folder matches its ModuleVersion. + expect(installRun).toContain( + '$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version' + ) + // The fallback only runs after the gallery route is exhausted, and still + // fails the job when neither route produced a usable module. + expect(installRun.indexOf('$installed = $true')).toBeLessThan( + installRun.indexOf('if (-not $installed)') + ) + expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module') + }) + + it('shares one SignPath module install path between release and rehearsal', () => { + const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') + const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name) + const installIndex = stepNames.indexOf('Install SignPath PowerShell module') + + // Why: the rehearsal exists to prove the real signing flow, so it must + // install the module exactly the way the release job does. + expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].uses).toBe( + './.github/actions/install-signpath-module' + ) + expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].run).toBeUndefined() + expect(installIndex).toBeLessThan( + stepNames.indexOf('Download signed inner binaries from SignPath') + ) + }) + + it('verifies Windows inner binary signatures fail-open before publishing', () => { + const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') + const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures') + const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence') + const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') + + expect(outerVerifyIndex).toBeGreaterThan(-1) + expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) + expect(evidenceIndex).toBe(innerVerifyIndex + 1) + expect(publishIndex).toBe(evidenceIndex + 1) + + // Why fail-open: unsigned inner binaries must warn, not block, until the + // flow is proven on a real release (issue #7785). Flip this to 'true' + // together with the workflow env to make the gate required. + expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false') + + // Why: every step in the inner-signing chain must be unable to fail the + // release — a SignPath outage or timeout falls through to today's + // unsigned-inner flow instead of blocking the cut. + const innerChainStepNames = [ + 'Stage unsigned inner PE files for signing', + 'Upload unsigned inner binaries for SignPath', + 'Submit inner binaries signing request', + 'Notify Slack that inner-binary signing is waiting for approval', + 'Download signed inner binaries from SignPath', + 'Restore signed inner binaries into unpacked app', + 'Replace cached elevate.exe with the signed copy', + 'Rebuild NSIS installer from signed unpacked app' + ] + for (const stepName of innerChainStepNames) { + const step = steps[stepNames.indexOf(stepName)] + expect(step, stepName).toBeDefined() + expect(step['continue-on-error'], stepName).toBe(true) + } + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 646d612e215..f4247281300 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,13 +12,12 @@ "../src/main/agent-hooks/local-agent-cli-presence.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", - "../src/main/ipc/local-agent-install-dir-detection.ts", - "../src/main/ipc/tui-agent-detection-commands.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", "../src/main/claude/statusline-script.ts", + "../src/main/claude-accounts/keychain.ts", "../src/main/codex/codex-app-server-capability-cache.ts", "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index f4fb2195223..39756947028 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -11,6 +11,8 @@ "../src/main/ipc/worktree-linked-work-item-metadata.ts", "../src/main/ipc/worktree-metadata-merge.ts", "../src/main/ipc/worktree-path-comparison.ts", + "../src/main/wsl-distro-list-output.ts", + "../src/main/wsl-distro-retry.ts", "../src/main/wsl.ts" ], "compilerOptions": { diff --git a/docs/agent-status-over-wsl.md b/docs/agent-status-over-wsl.md index e5a6ac9aa3b..5ea8992eda1 100644 --- a/docs/agent-status-over-wsl.md +++ b/docs/agent-status-over-wsl.md @@ -286,9 +286,12 @@ Four independent review lenses over the full diff; confirmed findings fixed: Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced install dirs accrete across upgrades (~200KB each); an outdated running daemon /p-translates the guest endpoint path until it restarts (hook scripts fall back to env -coords, which same-port binding keeps correct); `wslDistroCache` caches a transient -empty list for the app run (pre-existing semantics, now load-bearing for default-distro -resolution); default-distro resolution caches the first answer for the app run. +coords, which same-port binding keeps correct); default-distro resolution caches the +first non-empty answer for the app run. + +`wslDistroCache` no longer caches a transient empty list for the app run — an empty +result is re-probed after a short window, so a distro registered mid-session is picked +up. `isWslAvailable` likewise re-probes instead of latching a failure. ## 2026-07-09 round-4 external adversarial review diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 559da5e20be..c2e937384ec 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 11m + + downloads: 12m @@ -15,7 +15,7 @@ downloads downloads - 11m - 11m + 12m + 12m diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7388211487a..957e3f60b38 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -6,10 +6,17 @@ import tailwindcss from '@tailwindcss/vite' import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard' import packageJson from './package.json' with { type: 'json' } -const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize']) +const BUNDLED_MAIN_DEPENDENCIES = new Set([ + '@xterm/headless', + '@xterm/addon-serialize', + // Why: Windows NSIS deploys app.asar before external resources; bootstrap must + // not race the later resources/node_modules copy. + 'zod' +]) const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter( (dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency) ) +const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__' function isExternalMainModule(source: string): boolean { if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) { @@ -164,19 +171,47 @@ function createStartupDiagnosticsBanner(chunkName: string): string { ` } -function createStartupDiagnosticsBootstrapPlugin() { +export function createBootstrapFatalExitBanner(): string { + // Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement. + return ` +;(() => { + const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)} + if (typeof globalThis[guardKey] === 'function') { + return + } + let exitScheduled = false + const exitAfterBootstrapFailure = () => { + if (exitScheduled) { + return + } + exitScheduled = true + process.exitCode = 1 + setImmediate(() => process.exit(1)) + } + globalThis[guardKey] = () => { + process.off('uncaughtException', exitAfterBootstrapFailure) + delete globalThis[guardKey] + } + process.once('uncaughtException', exitAfterBootstrapFailure) +})(); +` +} + +function createMainBootstrapPlugin() { return { - name: 'orca-startup-diagnostics-bootstrap', + name: 'orca-main-bootstrap', generateBundle(_options, bundle) { const mainChunk = bundle['index.js'] if (!mainChunk || mainChunk.type !== 'chunk') { return } - // Why: source-level startup diagnostics run after Rollup's generated - // prelude and require() list. Mutate the final emitted chunk so macOS - // launch failures can identify the earliest JS boundary reached. - mainChunk.code = createStartupDiagnosticsBanner(mainChunk.fileName) + mainChunk.code + // Why: source guards and diagnostics run after Rollup's generated require + // prelude, too late to handle a missing bootstrap dependency. + mainChunk.code = + createBootstrapFatalExitBanner() + + createStartupDiagnosticsBanner(mainChunk.fileName) + + mainChunk.code } } } @@ -186,10 +221,10 @@ export const electronViteConfig: UserConfig = { build: { // Why: daemon-entry.js is asar-unpacked so child_process.fork() can // execute it from disk. Node's module resolution from the unpacked - // directory cannot reach into app.asar, so pure-JS dependencies used - // by the daemon must be bundled rather than externalized. + // directory cannot reach into app.asar; startup-critical pure JS must + // also survive a partially copied Windows resources tree. externalizeDeps: { - exclude: ['@xterm/headless', '@xterm/addon-serialize'] + exclude: [...BUNDLED_MAIN_DEPENDENCIES] }, rollupOptions: { // Why: native dependencies must resolve from packaged node_modules, @@ -208,9 +243,8 @@ export const electronViteConfig: UserConfig = { // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), - // Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked - // main thread (macOS 26 AppKit scene-update deadlock) and can record - // the stall for the next launch to report. + // Why: a worker thread survives the macOS 26 AppKit main-thread deadlock + // without paying for another Electron process. 'main-thread-hang-watchdog-entry': resolve( 'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts' ), @@ -224,7 +258,9 @@ export const electronViteConfig: UserConfig = { // this path for `orca agent hooks ...`, so it must survive rebuilds. 'agent-hooks/managed-agent-hook-controls': resolve( 'src/main/agent-hooks/managed-agent-hook-controls.ts' - ) + ), + // Why: account import mutates the user's macOS Keychain from the CLI. + 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts') }, // Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers // consume these stable CommonJS paths. @@ -233,7 +269,7 @@ export const electronViteConfig: UserConfig = { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' }, - plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()] + plugins: [createMainBootstrapPlugin(), createPlainNodeEntryGuardPlugin()] } }, // Why: compile-time substitution for the telemetry gate. See the block diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0eb894a487b..6a7bfc3a54f 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -114,7 +114,7 @@ import { loadTerminalAccessoryLayout } from '../../../../src/terminal/terminal-accessory-layout' import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input' -import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' +import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send' import { clearTerminalLiveInputFocusTimer, focusTerminalLiveInputTarget, @@ -127,6 +127,11 @@ import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-sen import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' +import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate' +import { + buildTerminalSendParams, + TERMINAL_INPUT_SEND_OPTIONS +} from '../../../../src/terminal/terminal-send-request' import { getTerminalCommandKeyboardType, getTerminalLiveInputKeyboardType @@ -198,6 +203,11 @@ import { confirmsMirroredTabSelection, type AppliedSnapshotMarker } from '../../../../src/session/session-tab-snapshot-gate' +import { + createInitialSessionAutoCreateState, + useInitialSessionTerminalAutoCreate, + useWorktreeSessionTabsLoaded +} from '../../../../src/session/use-initial-session-terminal-autocreate' import { buildMarkdownDiskFallbackDoc, shouldReadMarkdownFromDiskAfterReadTabFailure @@ -239,6 +249,7 @@ import { getActiveTabIdForHandle, isFileExistsErrorMessage, isGestureMouseTrackingMode, + isTerminalPhoneDisplayMode, MOBILE_SESSION_STATUS_LABELS, TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY, TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS, @@ -890,7 +901,7 @@ export default function SessionScreen() { const appliedSessionTabsRevisionRef = useRef(0) // Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back. const closedTabTombstonesRef = useRef>(new Map()) - const [terminalsLoaded, setTerminalsLoaded] = useState(false) + const [terminalsLoaded, setTerminalsLoaded] = useWorktreeSessionTabsLoaded(worktreeId) const [input, setInput] = useState('') // Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted). const [terminalTextScale, setTerminalTextScale] = useState(1) @@ -1032,7 +1043,7 @@ export default function SessionScreen() { // Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render). const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null) - const initialEmptySessionAutoCreateRef = useRef(null) + const initialSessionAutoCreateRef = useRef(createInitialSessionAutoCreateState()) const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(new Set()) const subscribeSeqRef = useRef>(new Map()) @@ -1058,18 +1069,18 @@ export default function SessionScreen() { activeHandleRef, activeSessionTabType: activeSessionTab?.type, activeSessionTabTypeRef, + connected: connState === 'connected', liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, sendLiveTerminalInputRef, setLiveInputCapture }) - const canSend = - connState === 'connected' && - activeHandle != null && - activeSessionTab?.type !== 'markdown' && - activeSessionTab?.type !== 'file' && - activeSessionTab?.type !== 'browser' + const { canCompose, canSend } = resolveMobileTerminalInputGate({ + connState, + activeHandle, + activeSessionTabType: activeSessionTab?.type + }) const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel. @@ -1774,6 +1785,7 @@ export default function SessionScreen() { nextTabs = [...orphanedDraftTabs, ...nextTabs] } sessionTabsRef.current = nextTabs + initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0 // Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop. setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs)) const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs) @@ -2632,7 +2644,7 @@ export default function SessionScreen() { pendingActiveTerminalHandleRef.current = null pendingBrowserFocusPageIdRef.current = null pendingTerminalActivationAttemptRef.current = null - initialEmptySessionAutoCreateRef.current = null + initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState() terminalDiagnosticsRef.current.resetRoute() appliedSnapshotMarkerRef.current = { epoch: null, version: -1 } closedTabTombstonesRef.current.clear() @@ -2991,7 +3003,8 @@ export default function SessionScreen() { }, [activeSessionTab, fileDocs, readFileTab]) async function handleSend() { - if (!client || !activeHandle || sendingRef.current) { + // Why: the return key still submits while offline; hold the composed text instead of firing a doomed RPC (#6713). + if (!client || !activeHandle || sendingRef.current || !canSend) { return } sendingRef.current = true @@ -3000,15 +3013,17 @@ export default function SessionScreen() { setInput('') try { - await client.sendRequest('terminal.send', { - terminal: activeHandle, - text, - enter: true, - // Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor. - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + // Why: fail now and restore the text — a send parked across a reconnect would execute long after the tap. + await client.sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: activeHandle, + text, + enter: true, + deviceToken: deviceTokenRef.current + }), + TERMINAL_INPUT_SEND_OPTIONS + ) } catch { setInput(text) } finally { @@ -3025,29 +3040,15 @@ export default function SessionScreen() { if (accessoryCommit.kind !== 'allow-raw') { return } - const currentClient = clientRef.current - // Why: async IME flushing can outlive the original terminal selection. - const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ + await sendTerminalLiveAccessoryRawBytes({ + client: clientRef.current, targetHandle, activeHandle: activeHandleRef.current, - activeSessionTabType: activeSessionTabTypeRef.current + activeSessionTabType: activeSessionTabTypeRef.current, + connState: connStateRef.current, + bytes: input.bytes, + deviceToken: deviceTokenRef.current }) - if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') { - return - } - await currentClient - .sendRequest('terminal.send', { - terminal: rawSendTarget, - text: input.bytes, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) - .then( - () => undefined, - () => undefined - ) } const sendLiveTerminalInput = useCallback( @@ -3071,15 +3072,19 @@ export default function SessionScreen() { ) { return false } + // Why: live-mirror deltas queued behind a dying send drain into the connect + // wait and replay stale bytes after reconnect (#6713's `YZZYecho …` corruption). return rpc - .sendRequest('terminal.send', { - terminal: handle, - text, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + .sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: handle, + text, + enter: false, + deviceToken: deviceTokenRef.current + }), + TERMINAL_INPUT_SEND_OPTIONS + ) .then(isTerminalSendRpcAccepted, () => false) }, [showToast] @@ -3341,14 +3346,17 @@ export default function SessionScreen() { terminalGestureInputInFlightRef.current.add(handle) try { - await rpc.sendRequest('terminal.send', { - terminal: handle, - text: queued.bytes, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + // Why: gesture arrows parked across a reconnect would move a TUI long after the swipe. + await rpc.sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: handle, + text: queued.bytes, + enter: false, + deviceToken: deviceTokenRef.current + }), + TERMINAL_INPUT_SEND_OPTIONS + ) } catch { // Transient failure } finally { @@ -3830,14 +3838,15 @@ export default function SessionScreen() { subscribeToTerminal(createdHandle) if (options?.initialPrompt?.trim()) { void client - .sendRequest('terminal.send', { - terminal: createdHandle, - text: options.initialPrompt, - enter: options.enter !== false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + .sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: createdHandle, + text: options.initialPrompt, + enter: options.enter !== false, + deviceToken: deviceTokenRef.current + }) + ) .then((sendResponse) => { if (!sendResponse.ok) { throw new Error( @@ -4161,14 +4170,6 @@ export default function SessionScreen() { }) const closeWithBulkActions = createCloseWithBulkActions(handleCloseSessionTab, bulkCloseActions) - const isPhoneMode = (handle: string | null): boolean => { - if (!handle) { - return false - } - const mode = terminalModes.get(handle) - return mode === 'auto' || mode === 'phone' || mode === undefined - } - const visibleTabs: MobileSessionTab[] = sessionTabs const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null @@ -4228,22 +4229,20 @@ export default function SessionScreen() { const showEmptyState = connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle - useEffect(() => { - if ( - !client || - !showEmptyState || - creating || - creatingBrowser || - creatingMarkdown || - initialEmptySessionAutoCreateRef.current === worktreeId - ) { - return - } - // Why: a sleeping/new workspace can hydrate with zero tabs; create the first terminal once so mobile isn't blank. - initialEmptySessionAutoCreateRef.current = worktreeId - setCreateError('') - void handleCreateTerminal() - }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) + // Why: a newly created workspace can hydrate with zero tabs before its first terminal exists. + useInitialSessionTerminalAutoCreate({ + client, + newlyCreatedWorkspace: created === '1', + connState, + terminalsLoaded, + visibleTabCount: visibleTabs.length, + activeHandle, + createInFlight: creating || creatingBrowser || creatingMarkdown, + stateRef: initialSessionAutoCreateRef, + worktreeId, + consumeCreationRoute: () => router.setParams({ created: undefined }), + createTerminal: () => void handleCreateTerminal() + }) // Why: reconnect trickles to 90s at its give-up cap; surface tap-to-retry so recovery needn't wait it out (issue #5049). const connectionVerdict = classifyConnection({ @@ -4828,12 +4827,12 @@ export default function SessionScreen() { } }} accessibilityLabel={ - isPhoneMode(activeHandle) + isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? 'Switch to desktop mode' : 'Switch to phone mode' } > - {isPhoneMode(activeHandle) ? ( + {isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? ( void handleSend()} /> setActionTarget(null), onToggleChat: toggleTabChatView, - isPhoneMode, + isPhoneMode: (handle) => isTerminalPhoneDisplayMode(handle, terminalModes), onToggleDisplayMode: (handle) => void toggleDisplayMode(handle), onRename: setRenameTarget, onClear: (target) => void handleClearTerminal(target), onClose: (target) => void handleCloseTerminal(target), + onCloseSessionTab: (tab) => void handleCloseSessionTab(tab), bulkCloseActions })} onClose={() => setActionTarget(null)} diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index f3980bb31b8..c41b165cbf9 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -60,6 +60,13 @@ import { resolveMobileSyntaxLanguage } from '../../../src/session/mobile-file-syntax' import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' +import { buildGitLabCheckSummary } from '../../../src/tasks/gitlab-check-summary' +import { + getHostedMergeLabel, + getHostedReviewLabel, + getHostedReviewSignalTone, + getHostedChecksLabel +} from '../../../src/tasks/mobile-hosted-check-status' import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability' import { @@ -138,11 +145,13 @@ import { import type { BaseRefSearchResult, GitHubOwnerRepo, + ProviderCheckSummary, PersistedTrustedOrcaHooks, SparsePreset, TuiAgent } from '../../../../src/shared/types' import type { SshConnectionState } from '../../../../src/shared/ssh-types' +import type { HostedReviewDecision } from '../../../../src/shared/hosted-review' import { githubProjectHost, githubProjectIdentityKey as githubProjectKey @@ -181,31 +190,20 @@ type GitHubWorkItem = { reviewDecision?: string | null reviewRequests?: GitHubAssignableUser[] latestReviews?: GitHubPRReviewSummary[] - checksSummary?: GitHubPRCheckSummary + checksSummary?: ProviderCheckSummary mergeable?: GitHubPRMergeableState mergeStateStatus?: string | null } - type GitHubAssignableUser = { login: string name?: string | null avatarUrl?: string | null } - type GitHubPRReviewSummary = { login: string state?: string | null avatarUrl?: string | null } - -type GitHubPRCheckSummary = { - state: 'success' | 'failure' | 'pending' | 'none' - total: number - passed: number - failed: number - pending: number -} - type GitHubPRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' type GitHubPRReviewerRow = { @@ -214,13 +212,11 @@ type GitHubPRReviewerRow = { avatarUrl?: string | null stateLabel: string } - type GitHubRepoSources = { issues: GitHubOwnerRepo | null prs: GitHubOwnerRepo | null upstreamCandidate: GitHubOwnerRepo | null } - type TaskRuntimeStatus = { capabilities?: string[] } @@ -229,7 +225,6 @@ type TasksSupportState = | { kind: 'unknown'; client: RpcClient | null } | { kind: 'supported'; client: RpcClient } | { kind: 'unsupported'; client: RpcClient } - type GitLabWorkItem = { id: string type: 'issue' | 'mr' @@ -244,6 +239,10 @@ type GitLabWorkItem = { baseRefName?: string isCrossRepository?: boolean projectRef?: { host: string; path: string } + checksSummary?: ProviderCheckSummary + mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' + reviewDecision?: HostedReviewDecision + reviewerCount?: number repoId: string repoName: string } @@ -1262,23 +1261,6 @@ function hostedBranchSummary(item: TaskItem): { head: string; base: string } | n return null } -function getGitHubChecksLabel(item: GitHubWorkItem): string { - const summary = item.checksSummary - if (!summary) { - return 'Checks' - } - if (summary.total === 0) { - return 'No checks' - } - if (summary.failed > 0) { - return `${summary.failed} failing` - } - if (summary.pending > 0) { - return `${summary.pending} pending` - } - return `${summary.passed}/${summary.total} passed` -} - function getGitHubMergeLabel(item: GitHubWorkItem): string { if (item.mergeable === undefined && item.mergeStateStatus === undefined) { return 'Merge' @@ -1382,46 +1364,6 @@ function getHostedStateConfirmLabel(pending: PendingHostedStateChange): string { return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget}` } -function getGitHubPRSignalTone( - item: GitHubWorkItem, - signal: 'review' | 'checks' | 'merge' -): 'neutral' | 'success' | 'warning' | 'danger' { - if (signal === 'review') { - if (item.reviewDecision === 'APPROVED') { - return 'success' - } - if (item.reviewDecision === 'CHANGES_REQUESTED') { - return 'danger' - } - if (item.reviewRequests && item.reviewRequests.length > 0) { - return 'warning' - } - return 'neutral' - } - if (signal === 'checks') { - if (item.checksSummary?.state === 'success') { - return 'success' - } - if (item.checksSummary?.state === 'failure') { - return 'danger' - } - if (item.checksSummary?.state === 'pending') { - return 'warning' - } - return 'neutral' - } - if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') { - return 'danger' - } - if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { - return 'warning' - } - if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { - return 'success' - } - return 'neutral' -} - function mergeGitHubAssignableUsers( users: GitHubAssignableUser[], seeds: GitHubAssignableUser[] @@ -4345,7 +4287,7 @@ export default function MobileTasksScreen() { const details = response.result as { body?: string comments?: DetailComment[] - item?: { labels?: string[] } + item?: { labels?: string[]; mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' } assignees?: string[] pipelineJobs?: Array<{ id?: number @@ -4355,6 +4297,8 @@ export default function MobileTasksScreen() { webUrl?: string | null duration?: number | null }> + reviewers?: unknown[] + approvalState?: { approvalsRequired: number | null; approvalsLeft: number | null } } | null if (!details) { throw new Error('Details not found') @@ -4368,6 +4312,44 @@ export default function MobileTasksScreen() { assignees: details.assignees ?? [], pipelineJobs: details.pipelineJobs ?? [] }) + const checksSummary = buildGitLabCheckSummary(details.pipelineJobs ?? []) + const reviewDecision: Exclude | undefined = + details.approvalState?.approvalsRequired && details.approvalState.approvalsLeft === 0 + ? 'approved' + : details.approvalState?.approvalsLeft && details.approvalState.approvalsLeft > 0 + ? 'review_required' + : undefined + const hydratedStatus = { + ...(details.item?.mergeable !== undefined ? { mergeable: details.item.mergeable } : {}), + ...(reviewDecision !== undefined ? { reviewDecision } : {}), + ...(details.reviewers !== undefined ? { reviewerCount: details.reviewers.length } : {}) + } + setActionItem((current) => + current?.provider === 'gitlab' && current.source.id === actionItem.source.id + ? { + ...current, + source: { + ...current.source, + checksSummary, + ...hydratedStatus + } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'gitlab' && candidate.source.id === actionItem.source.id + ? { + ...candidate, + source: { + ...candidate.source, + checksSummary, + ...hydratedStatus + } + } + : candidate + ) + ) } return } @@ -9689,6 +9671,7 @@ export default function MobileTasksScreen() { const item = entry.item const repo = taskRepositoryMeta(item, reposById) const isGitHubPr = item.provider === 'github' && item.source.type === 'pr' + const isGitLabMr = item.provider === 'gitlab' && item.source.type === 'mr' const githubPrDelta = isGitHubPr ? formatGitHubPRDelta(item.source) : null const branchSummary = hostedBranchSummary(item) return ( @@ -9734,45 +9717,53 @@ export default function MobileTasksScreen() { ) : null} - {isGitHubPr ? ( + {isGitHubPr || isGitLabMr ? ( - {githubPrDelta ? ( + {isGitHubPr && githubPrDelta ? ( {githubPrDelta} ) : null} + {isGitHubPr || isGitLabMr ? ( + + + {isGitHubPr + ? getGitHubReviewSummary(item.source) + : getHostedReviewLabel(item.source)} + + + ) : null} - {getGitHubReviewSummary(item.source)} - - - - - {getGitHubChecksLabel(item.source)} - - - - - {getGitHubMergeLabel(item.source)} + {getHostedChecksLabel(item.source)} + {isGitHubPr || isGitLabMr ? ( + + + {isGitHubPr + ? getGitHubMergeLabel(item.source) + : getHostedMergeLabel(item.source)} + + + ) : null} ) : null} diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index 1756d51c59a..e1884829272 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -1,7 +1,7 @@ import type { CheckStatus, GitHubAssignableUser, - GitHubPRCheckSummary, + ProviderCheckSummary, GitHubPRMergeMethod, GitHubPRMergeMethodSettings, GitHubPRReviewSummary, @@ -182,12 +182,18 @@ export function readMergeMethodSettings(value: unknown): GitHubPRMergeMethodSett } } -export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefined { +export function readCheckSummary(value: unknown): ProviderCheckSummary | undefined { if (!isRecord(value)) { return undefined } const state = value.state - if (state !== 'success' && state !== 'failure' && state !== 'pending' && state !== 'none') { + if ( + state !== 'success' && + state !== 'failure' && + state !== 'pending' && + state !== 'neutral' && + state !== 'none' + ) { return undefined } return { @@ -195,6 +201,7 @@ export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefin total: readNumber(value.total) ?? 0, passed: readNumber(value.passed) ?? 0, failed: readNumber(value.failed) ?? 0, - pending: readNumber(value.pending) ?? 0 + pending: readNumber(value.pending) ?? 0, + neutral: readNumber(value.neutral) ?? 0 } } diff --git a/mobile/src/session/initial-session-terminal.test.ts b/mobile/src/session/initial-session-terminal.test.ts new file mode 100644 index 00000000000..f8f569cbb48 --- /dev/null +++ b/mobile/src/session/initial-session-terminal.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { + shouldAutoCreateInitialSessionTerminal, + type InitialSessionTerminalAutoCreateInput +} from './initial-session-terminal' + +function baseInput( + overrides: Partial = {} +): InitialSessionTerminalAutoCreateInput { + return { + newlyCreatedWorkspace: true, + connected: true, + tabsLoaded: true, + visibleTabCount: 0, + hasActiveTerminalHandle: false, + createInFlight: false, + sawSessionTabs: false, + autoCreatedForWorktree: false, + ...overrides + } +} + +/** + * Replays the route state the session screen actually holds + * (`app/h/[hostId]/session/[worktreeId].tsx`): `applySessionTabs` publishes the + * tab list and flips `terminalsLoaded`, `handleCloseSessionTab` prunes the + * closed tab and nulls the active handle. + */ +class SessionRouteState { + newlyCreatedWorkspace = true + tabsLoaded = false + tabIds: string[] = [] + activeHandle: string | null = null + sawSessionTabs = false + autoCreatedForWorktree = false + + applySessionTabs(tabIds: string[], activeHandle: string | null = tabIds[0] ? 'pty-1' : null) { + this.tabIds = tabIds + this.activeHandle = activeHandle + if (tabIds.length > 0) { + this.sawSessionTabs = true + } + this.tabsLoaded = true + } + + closeSessionTab(tabId: string) { + this.tabIds = this.tabIds.filter((candidate) => candidate !== tabId) + this.activeHandle = null + } + + gate(): boolean { + return shouldAutoCreateInitialSessionTerminal({ + newlyCreatedWorkspace: this.newlyCreatedWorkspace, + connected: true, + tabsLoaded: this.tabsLoaded, + visibleTabCount: this.tabIds.length, + hasActiveTerminalHandle: this.activeHandle !== null, + createInFlight: false, + sawSessionTabs: this.sawSessionTabs, + autoCreatedForWorktree: this.autoCreatedForWorktree + }) + } +} + +describe('shouldAutoCreateInitialSessionTerminal', () => { + it('creates the first terminal for a workspace that hydrates with nothing', () => { + const route = new SessionRouteState() + route.applySessionTabs([]) + expect(route.gate()).toBe(true) + }) + + it('does not re-create after the user closes the last tab (#9717, #7345)', () => { + const route = new SessionRouteState() + route.applySessionTabs(['tab-1']) + expect(route.gate()).toBe(false) + + route.closeSessionTab('tab-1') + expect(route.tabIds).toHaveLength(0) + expect(route.activeHandle).toBeNull() + // Emptiness that follows a populated list is a close, not a cold hydrate. + expect(route.gate()).toBe(false) + }) + + it('does not re-create after the consumed creation route remounts empty', () => { + const creationRoute = new SessionRouteState() + creationRoute.applySessionTabs([]) + expect(creationRoute.gate()).toBe(true) + creationRoute.newlyCreatedWorkspace = false + creationRoute.autoCreatedForWorktree = true + creationRoute.applySessionTabs(['tab-1']) + creationRoute.closeSessionTab('tab-1') + expect(creationRoute.gate()).toBe(false) + + const reopenedRoute = new SessionRouteState() + reopenedRoute.newlyCreatedWorkspace = creationRoute.newlyCreatedWorkspace + reopenedRoute.applySessionTabs([]) + expect(reopenedRoute.gate()).toBe(false) + }) + + it('does not create from stale empty state while switching ordinary workspace routes', () => { + expect( + shouldAutoCreateInitialSessionTerminal( + baseInput({ + newlyCreatedWorkspace: false, + tabsLoaded: true, + visibleTabCount: 0 + }) + ) + ).toBe(false) + }) + + it('does not re-create when the host drops every tab mid-session', () => { + const route = new SessionRouteState() + route.applySessionTabs(['tab-1', 'tab-2']) + route.applySessionTabs([], null) + expect(route.gate()).toBe(false) + }) + + it('stays armed while an empty workspace is still loading', () => { + expect(shouldAutoCreateInitialSessionTerminal(baseInput({ tabsLoaded: false }))).toBe(false) + }) + + it('waits for the host connection', () => { + expect(shouldAutoCreateInitialSessionTerminal(baseInput({ connected: false }))).toBe(false) + }) + + it('defers to a lagging snapshot that still has a streaming terminal', () => { + expect( + shouldAutoCreateInitialSessionTerminal(baseInput({ hasActiveTerminalHandle: true })) + ).toBe(false) + }) + + it('does not stack a second create on an in-flight one', () => { + expect(shouldAutoCreateInitialSessionTerminal(baseInput({ createInFlight: true }))).toBe(false) + }) + + it('fires at most once per workspace on this route', () => { + expect( + shouldAutoCreateInitialSessionTerminal(baseInput({ autoCreatedForWorktree: true })) + ).toBe(false) + }) + + it('does not fire while tabs are visible', () => { + expect(shouldAutoCreateInitialSessionTerminal(baseInput({ visibleTabCount: 2 }))).toBe(false) + }) +}) diff --git a/mobile/src/session/initial-session-terminal.ts b/mobile/src/session/initial-session-terminal.ts new file mode 100644 index 00000000000..483c3dae41a --- /dev/null +++ b/mobile/src/session/initial-session-terminal.ts @@ -0,0 +1,46 @@ +// Mirrors the desktop `shouldAutoCreateInitialTerminal` gate: a newly created +// workspace that hydrates empty gets one terminal so the session isn't blank. +// Kept pure (no react-native imports) so the gate is unit-testable. + +export type InitialSessionTerminalAutoCreateInput = { + /** Navigation came directly from creating this workspace. */ + newlyCreatedWorkspace: boolean + /** Host connection is up and an RPC client is attached. */ + connected: boolean + /** At least one session-tab snapshot has been applied on this route. */ + tabsLoaded: boolean + /** Session tabs currently rendered on the phone. */ + visibleTabCount: number + /** A terminal is still streaming even though the tab list reads empty. */ + hasActiveTerminalHandle: boolean + /** A terminal, browser, or markdown create is already in flight. */ + createInFlight: boolean + /** This route has shown at least one session tab for this workspace. */ + sawSessionTabs: boolean + /** This route already auto-created a terminal for this workspace. */ + autoCreatedForWorktree: boolean +} + +/** + * Whether to auto-create the first terminal of a newly created mobile session. + * + * `sawSessionTabs` is the resurrection guard: emptiness that *follows* a + * populated tab list was produced by a close (the user's, or the host dropping + * a dead pane), and re-creating there spawns a brand-new terminal the user + * never asked for — issues #9717 / #7345. Only a workspace that has shown + * nothing since its creation route opened is eligible. + */ +export function shouldAutoCreateInitialSessionTerminal( + input: InitialSessionTerminalAutoCreateInput +): boolean { + if (!input.newlyCreatedWorkspace || !input.connected || !input.tabsLoaded) { + return false + } + if (input.visibleTabCount > 0 || input.hasActiveTerminalHandle) { + return false + } + if (input.createInFlight || input.autoCreatedForWorktree) { + return false + } + return !input.sawSessionTabs +} diff --git a/mobile/src/session/mobile-file-language.ts b/mobile/src/session/mobile-file-language.ts index 30e4656c0a2..03b368c9411 100644 --- a/mobile/src/session/mobile-file-language.ts +++ b/mobile/src/session/mobile-file-language.ts @@ -10,6 +10,8 @@ function extname(filePath: string): string { const EXT_TO_LANGUAGE: Record = { '.ts': 'typescript', '.tsx': 'typescript', + '.cts': 'typescript', + '.mts': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', diff --git a/mobile/src/session/mobile-file-syntax.test.ts b/mobile/src/session/mobile-file-syntax.test.ts index 59509742539..f01a240e10f 100644 --- a/mobile/src/session/mobile-file-syntax.test.ts +++ b/mobile/src/session/mobile-file-syntax.test.ts @@ -11,9 +11,12 @@ import type { MobileDiffLine } from './mobile-diff-lines' describe('mobile file syntax highlighting', () => { it('detects common source languages from file paths', () => { expect(detectMobileFileLanguage('src/App.tsx')).toBe('typescript') + expect(detectMobileFileLanguage('config/vitest.config.mts')).toBe('typescript') + expect(detectMobileFileLanguage('C:\\repo\\scripts\\postinstall.CTS')).toBe('typescript') expect(detectMobileFileLanguage('scripts/deploy.sh')).toBe('shell') expect(detectMobileFileLanguage('Dockerfile')).toBe('dockerfile') expect(resolveMobileSyntaxLanguage('src/App.tsx')).toBe('typescript') + expect(resolveMobileSyntaxLanguage('worktrees/feature/build.cts')).toBe('typescript') expect(resolveMobileSyntaxLanguage('Dockerfile')).toBe('plaintext') }) diff --git a/mobile/src/session/mobile-session-route-helpers.test.ts b/mobile/src/session/mobile-session-route-helpers.test.ts new file mode 100644 index 00000000000..6ae7a930844 --- /dev/null +++ b/mobile/src/session/mobile-session-route-helpers.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers' + +describe('isTerminalPhoneDisplayMode', () => { + it('uses phone mode for automatic, phone, and unreported terminals', () => { + const modes = new Map([ + ['auto', 'auto'], + ['phone', 'phone'] + ] as const) + + expect(isTerminalPhoneDisplayMode('auto', modes)).toBe(true) + expect(isTerminalPhoneDisplayMode('phone', modes)).toBe(true) + expect(isTerminalPhoneDisplayMode('missing', modes)).toBe(true) + }) + + it('rejects absent handles and desktop terminals', () => { + const modes = new Map([['desktop', 'desktop']] as const) + + expect(isTerminalPhoneDisplayMode(null, modes)).toBe(false) + expect(isTerminalPhoneDisplayMode('desktop', modes)).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-session-route-helpers.ts b/mobile/src/session/mobile-session-route-helpers.ts index 52b277bd6b8..413e1316beb 100644 --- a/mobile/src/session/mobile-session-route-helpers.ts +++ b/mobile/src/session/mobile-session-route-helpers.ts @@ -34,6 +34,17 @@ export function isGestureMouseTrackingMode( return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any' } +export function isTerminalPhoneDisplayMode( + handle: string | null, + terminalModes: ReadonlyMap +): boolean { + if (!handle) { + return false + } + const mode = terminalModes.get(handle) + return mode === undefined || mode === 'auto' || mode === 'phone' +} + export function getActiveTabIdForHandle( tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>, terminalHandle: string | null diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index 07b5cf1737c..80484431c62 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -9,6 +9,10 @@ const reconciliationHookSource = readFileSync( new URL('./use-mobile-session-tabs-reconciliation.ts', import.meta.url), 'utf8' ) +const autoCreateHookSource = readFileSync( + new URL('./use-initial-session-terminal-autocreate.ts', import.meta.url), + 'utf8' +) function sliceBetween(startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) @@ -19,18 +23,49 @@ function sliceBetween(startPattern: string, endPattern: string): string { } describe('mobile session startup', () => { - it('auto-creates one terminal for an initially empty connected session', () => { - expect(source).toContain('const initialEmptySessionAutoCreateRef = useRef(null)') - expect(source).toContain('initialEmptySessionAutoCreateRef.current = null') - - const autoCreateEffect = sliceBetween( - 'if (\n !client ||\n !showEmptyState', - 'const terminalSummary =' + it('auto-creates one terminal for a newly created empty session', () => { + expect(source).toContain('useWorktreeSessionTabsLoaded(worktreeId)') + expect(source).toContain( + 'initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()' ) - expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current === worktreeId') - expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current = worktreeId') - expect(autoCreateEffect).toContain("setCreateError('')") - expect(autoCreateEffect).toContain('void handleCreateTerminal()') + + const autoCreateCall = sliceBetween( + 'useInitialSessionTerminalAutoCreate({', + 'const connectionVerdict =' + ) + expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef') + expect(autoCreateCall).toContain( + 'consumeCreationRoute: () => router.setParams({ created: undefined })' + ) + expect(autoCreateCall).toContain("newlyCreatedWorkspace: created === '1'") + expect(autoCreateCall).toContain('visibleTabCount: visibleTabs.length') + expect(autoCreateCall).toContain('createTerminal: () => void handleCreateTerminal()') + + expect(autoCreateHookSource).toContain('shouldAutoCreateInitialSessionTerminal({') + expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree === worktreeId') + expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree = worktreeId') + expect(autoCreateHookSource).toContain("connState === 'connected'") + expect(autoCreateHookSource).toContain('(visibleTabCount > 0 || activeHandle !== null)') + // Why: both callbacks are re-created every render, so the effect must reach them + // through useEffectEvent rather than deps or a render-time ref write. + expect(autoCreateHookSource).toContain('useEffectEvent(args.consumeCreationRoute)') + expect(autoCreateHookSource).toContain('useEffectEvent(args.createTerminal)') + expect(autoCreateHookSource).toContain('consumeCreationRoute()') + expect(autoCreateHookSource).toContain('createTerminal()') + }) + + it('arms the auto-create only until the route has published a tab (#9717)', () => { + // Emptiness after a populated list is a close, not a cold hydrate. + expect(source).toContain( + 'initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0' + ) + + const autoCreateCall = sliceBetween( + 'useInitialSessionTerminalAutoCreate({', + 'const connectionVerdict =' + ) + expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef') + expect(autoCreateHookSource).toContain('sawSessionTabs: stateRef.current.sawSessionTabs') }) it('delegates stream ownership while retaining the exact terminal polling cadence', () => { diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.test.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.test.ts index c6cbc1188ba..b7a59307340 100644 --- a/mobile/src/session/mobile-terminal-action-sheet-actions.test.ts +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.test.ts @@ -9,24 +9,38 @@ vi.mock('lucide-react-native', () => ({ SquareTerminal: vi.fn() })) +type SheetArgs = Parameters[0] + +function buildActions(overrides: Partial = {}) { + return getMobileTerminalActionSheetActions({ + target: { handle: 'terminal-1' }, + tabs: [], + isTabChatView: () => false, + nativeChatTranscriptIsLocalReadable: true, + onDismiss: vi.fn(), + onToggleChat: vi.fn(), + isPhoneMode: () => false, + onToggleDisplayMode: vi.fn(), + onRename: vi.fn(), + onClear: vi.fn(), + onClose: vi.fn(), + onCloseSessionTab: vi.fn(), + ...overrides + }) +} + +const terminalTab = (id: string, handle: string | null) => ({ + type: 'terminal', + id, + terminal: handle +}) + describe('getMobileTerminalActionSheetActions', () => { it('defers Rename until after the action sheet closes', () => { const target = { handle: 'terminal-1' } const onDismiss = vi.fn() const onRename = vi.fn() - const actions = getMobileTerminalActionSheetActions({ - target, - tabs: [], - isTabChatView: () => false, - nativeChatTranscriptIsLocalReadable: true, - onDismiss, - onToggleChat: vi.fn(), - isPhoneMode: () => false, - onToggleDisplayMode: vi.fn(), - onRename, - onClear: vi.fn(), - onClose: vi.fn() - }) + const actions = buildActions({ target, onDismiss, onRename }) const rename = actions.find((action) => action.label === 'Rename') expect(rename).toMatchObject({ closeBeforePress: true }) @@ -35,4 +49,51 @@ describe('getMobileTerminalActionSheetActions', () => { expect(onRename).toHaveBeenCalledWith(target) expect(onDismiss).not.toHaveBeenCalled() }) + + // Why: handle-only close lets the pending-terminal effect resurrect the tab (#6927, #7345). + it('closes the handle through its session tab, not the handle-only path', () => { + const onClose = vi.fn() + const onCloseSessionTab = vi.fn() + const tab = terminalTab('tab-1::leaf-1', 'terminal-1') + const actions = buildActions({ + target: { handle: 'terminal-1' }, + tabs: [tab], + onClose, + onCloseSessionTab + }) + + actions.find((action) => action.label === 'Close')?.onPress() + expect(onCloseSessionTab).toHaveBeenCalledWith(tab) + expect(onClose).not.toHaveBeenCalled() + }) + + it('closes the split leaf that owns the handle, not its sibling', () => { + const onCloseSessionTab = vi.fn() + const first = terminalTab('tab-1::leaf-1', 'terminal-1') + const second = terminalTab('tab-1::leaf-2', 'terminal-2') + const actions = buildActions({ + target: { handle: 'terminal-2' }, + tabs: [first, second], + onCloseSessionTab + }) + + actions.find((action) => action.label === 'Close')?.onPress() + expect(onCloseSessionTab).toHaveBeenCalledWith(second) + }) + + it('falls back to the handle close when no session tab owns the handle', () => { + const onClose = vi.fn() + const onCloseSessionTab = vi.fn() + const target = { handle: 'terminal-9' } + const actions = buildActions({ + target, + tabs: [terminalTab('tab-1::leaf-1', 'terminal-1'), terminalTab('tab-2::leaf-1', null)], + onClose, + onCloseSessionTab + }) + + actions.find((action) => action.label === 'Close')?.onPress() + expect(onClose).toHaveBeenCalledWith(target) + expect(onCloseSessionTab).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts index fd5ef5a5583..9684bc3e7a5 100644 --- a/mobile/src/session/mobile-terminal-action-sheet-actions.ts +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -7,9 +7,12 @@ type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null } /** Builds the terminal long-press menu without adding another action block to the * already dense session route. Native chat stays first as the view switch. */ -export function getMobileTerminalActionSheetActions(args: { +export function getMobileTerminalActionSheetActions< + Target extends { handle: string }, + Tab extends TerminalTab +>(args: { target: Target | null - tabs: readonly TerminalTab[] + tabs: readonly Tab[] isTabChatView: (tabId: string) => boolean nativeChatTranscriptIsLocalReadable: boolean onDismiss: () => void @@ -18,7 +21,10 @@ export function getMobileTerminalActionSheetActions void onRename: (target: Target) => void onClear: (target: Target) => void + /** Fallback for a live handle with no matching session tab. */ onClose: (target: Target) => void + /** Preferred path runs host teardown and records the local tombstone. */ + onCloseSessionTab: (tab: Tab) => void /** Appended after Close; receives the pressed tab's id so the session route's * bulk-close builder can resolve the anchor itself. */ bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[] @@ -28,6 +34,7 @@ export function getMobileTerminalActionSheetActions tab.terminal === target.handle) return [ ...getMobileNativeChatToggleActions({ terminalHandle: target.handle, @@ -65,12 +72,13 @@ export function getMobileTerminalActionSheetActions { args.onDismiss() + if (sessionTab) { + args.onCloseSessionTab(sessionTab) + return + } args.onClose(target) } }, - ...(args.bulkCloseActions?.( - args.tabs.find((tab) => tab.terminal === target.handle)?.id, - args.onDismiss - ) ?? []) + ...(args.bulkCloseActions?.(sessionTab?.id, args.onDismiss) ?? []) ] } diff --git a/mobile/src/session/use-initial-session-terminal-autocreate.test.ts b/mobile/src/session/use-initial-session-terminal-autocreate.test.ts new file mode 100644 index 00000000000..46a7bb859fa --- /dev/null +++ b/mobile/src/session/use-initial-session-terminal-autocreate.test.ts @@ -0,0 +1,189 @@ +import { createElement, type RefObject } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + useInitialSessionTerminalAutoCreate, + useWorktreeSessionTabsLoaded +} from './use-initial-session-terminal-autocreate' + +type HarnessProps = { + newlyCreatedWorkspace: boolean + terminalsLoaded: boolean + visibleTabCount: number + worktreeId: string + connected?: boolean + hasClient?: boolean +} + +describe('useInitialSessionTerminalAutoCreate', () => { + let renderer: ReactTestRenderer | null = null + let stateRef: RefObject<{ + autoCreatedForWorktree: string | null + sawSessionTabs: boolean + }> + const consumeCreationRoute = vi.fn() + const createTerminal = vi.fn() + + function Harness(props: HarnessProps): null { + useInitialSessionTerminalAutoCreate({ + client: props.hasClient === false ? null : {}, + newlyCreatedWorkspace: props.newlyCreatedWorkspace, + connState: props.connected === false ? 'disconnected' : 'connected', + terminalsLoaded: props.terminalsLoaded, + visibleTabCount: props.visibleTabCount, + activeHandle: null, + createInFlight: false, + stateRef, + worktreeId: props.worktreeId, + consumeCreationRoute, + createTerminal + }) + return null + } + + async function render(props: HarnessProps): Promise { + await act(async () => { + if (renderer) { + renderer.update(createElement(Harness, props)) + } else { + renderer = create(createElement(Harness, props)) + } + }) + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + stateRef = { + current: { autoCreatedForWorktree: null, sawSessionTabs: false } + } + consumeCreationRoute.mockClear() + createTerminal.mockClear() + vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { + throw new Error(String(args[0])) + } + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + it('waits for the new worktree snapshot after route reuse', async () => { + await render({ + newlyCreatedWorkspace: false, + terminalsLoaded: true, + visibleTabCount: 0, + worktreeId: 'existing' + }) + await render({ + newlyCreatedWorkspace: true, + terminalsLoaded: false, + visibleTabCount: 0, + worktreeId: 'new' + }) + expect(consumeCreationRoute).not.toHaveBeenCalled() + expect(createTerminal).not.toHaveBeenCalled() + + await render({ + newlyCreatedWorkspace: true, + terminalsLoaded: true, + visibleTabCount: 0, + worktreeId: 'new' + }) + expect(consumeCreationRoute).not.toHaveBeenCalled() + expect(createTerminal).toHaveBeenCalledOnce() + + await render({ + newlyCreatedWorkspace: true, + terminalsLoaded: true, + visibleTabCount: 1, + worktreeId: 'new' + }) + expect(consumeCreationRoute).toHaveBeenCalledOnce() + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('consumes a populated creation route before a later remount', async () => { + await render({ + newlyCreatedWorkspace: true, + terminalsLoaded: true, + visibleTabCount: 1, + worktreeId: 'new' + }) + expect(consumeCreationRoute).toHaveBeenCalledOnce() + expect(createTerminal).not.toHaveBeenCalled() + + act(() => renderer?.unmount()) + renderer = null + stateRef = { + current: { autoCreatedForWorktree: null, sawSessionTabs: false } + } + await render({ + newlyCreatedWorkspace: false, + terminalsLoaded: true, + visibleTabCount: 0, + worktreeId: 'new' + }) + expect(createTerminal).not.toHaveBeenCalled() + }) + + it.each([ + { connected: false, hasClient: true }, + { connected: true, hasClient: false } + ])( + 'keeps the creation route armed until a client reconnects (connected=$connected, hasClient=$hasClient)', + async ({ connected, hasClient }) => { + await render({ + newlyCreatedWorkspace: true, + terminalsLoaded: true, + visibleTabCount: 0, + worktreeId: 'new', + connected, + hasClient + }) + expect(consumeCreationRoute).not.toHaveBeenCalled() + expect(createTerminal).not.toHaveBeenCalled() + + await render({ + newlyCreatedWorkspace: true, + terminalsLoaded: true, + visibleTabCount: 0, + worktreeId: 'new' + }) + expect(consumeCreationRoute).not.toHaveBeenCalled() + expect(createTerminal).toHaveBeenCalledOnce() + } + ) +}) + +describe('useWorktreeSessionTabsLoaded', () => { + let renderer: ReactTestRenderer | null = null + let current: readonly [boolean, (loaded: boolean) => void] | null = null + + function Harness({ worktreeId }: { worktreeId: string }): null { + current = useWorktreeSessionTabsLoaded(worktreeId) + return null + } + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + current = null + }) + + it('does not carry a loaded snapshot across a reused route', async () => { + await act(async () => { + renderer = create(createElement(Harness, { worktreeId: 'existing' })) + }) + act(() => current?.[1](true)) + expect(current?.[0]).toBe(true) + + await act(async () => { + renderer?.update(createElement(Harness, { worktreeId: 'new' })) + }) + expect(current?.[0]).toBe(false) + }) +}) diff --git a/mobile/src/session/use-initial-session-terminal-autocreate.ts b/mobile/src/session/use-initial-session-terminal-autocreate.ts new file mode 100644 index 00000000000..a5b3fef0550 --- /dev/null +++ b/mobile/src/session/use-initial-session-terminal-autocreate.ts @@ -0,0 +1,106 @@ +import { useEffect, useEffectEvent, useReducer, type RefObject } from 'react' +import { shouldAutoCreateInitialSessionTerminal } from './initial-session-terminal' + +type InitialSessionTerminalAutoCreateState = { + autoCreatedForWorktree: string | null + sawSessionTabs: boolean +} + +/** + * Fresh auto-create bookkeeping. The route must re-create this on every worktree + * change, or a revisit inherits the previous workspace's `sawSessionTabs`. + */ +export function createInitialSessionAutoCreateState(): InitialSessionTerminalAutoCreateState { + return { autoCreatedForWorktree: null, sawSessionTabs: false } +} + +type InitialSessionTerminalAutoCreateArgs = { + client: unknown + newlyCreatedWorkspace: boolean + connState: string + terminalsLoaded: boolean + visibleTabCount: number + activeHandle: string | null + createInFlight: boolean + stateRef: RefObject + worktreeId: string + consumeCreationRoute: () => void + createTerminal: () => void +} + +/** + * Creates the first terminal of a newly created mobile workspace that hydrates + * empty, at most once per route. See initial-session-terminal. + */ +export function useInitialSessionTerminalAutoCreate( + args: InitialSessionTerminalAutoCreateArgs +): void { + const { + client, + newlyCreatedWorkspace, + connState, + terminalsLoaded, + visibleTabCount, + activeHandle, + createInFlight, + stateRef, + worktreeId + } = args + // Why: the route re-creates both callbacks every render; useEffectEvent keeps them + // out of the deps without mutating a ref during render. + const consumeCreationRoute = useEffectEvent(args.consumeCreationRoute) + const createTerminal = useEffectEvent(args.createTerminal) + + useEffect(() => { + if ( + newlyCreatedWorkspace && + client && + connState === 'connected' && + terminalsLoaded && + (visibleTabCount > 0 || activeHandle !== null) + ) { + consumeCreationRoute() + } + if ( + !client || + !shouldAutoCreateInitialSessionTerminal({ + newlyCreatedWorkspace, + connected: connState === 'connected', + tabsLoaded: terminalsLoaded, + visibleTabCount, + hasActiveTerminalHandle: activeHandle !== null, + createInFlight, + sawSessionTabs: stateRef.current.sawSessionTabs, + autoCreatedForWorktree: stateRef.current.autoCreatedForWorktree === worktreeId + }) + ) { + return + } + stateRef.current.autoCreatedForWorktree = worktreeId + createTerminal() + }, [ + activeHandle, + client, + connState, + createInFlight, + newlyCreatedWorkspace, + stateRef, + terminalsLoaded, + visibleTabCount, + worktreeId + ]) +} + +/** + * Tracks "tabs hydrated" per worktree so a reused route reports `false` until the + * new workspace's own snapshot lands, rather than inheriting the old one's. + */ +export function useWorktreeSessionTabsLoaded( + worktreeId: string +): readonly [boolean, (loaded: boolean) => void] { + const [loadedForWorktree, setLoaded] = useReducer( + (_current: string | null, loaded: boolean) => (loaded ? worktreeId : null), + null + ) + return [loadedForWorktree === worktreeId, setLoaded] +} diff --git a/mobile/src/tasks/github-check-summary.test.ts b/mobile/src/tasks/github-check-summary.test.ts index 955059bbbe4..a173663d9a5 100644 --- a/mobile/src/tasks/github-check-summary.test.ts +++ b/mobile/src/tasks/github-check-summary.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { buildGitHubCheckSummary } from './github-check-summary' +import { buildGitLabCheckSummary } from './gitlab-check-summary' describe('buildGitHubCheckSummary', () => { it('returns none for empty check lists', () => { @@ -8,7 +9,8 @@ describe('buildGitHubCheckSummary', () => { total: 0, passed: 0, failed: 0, - pending: 0 + pending: 0, + neutral: 0 }) }) @@ -24,22 +26,35 @@ describe('buildGitHubCheckSummary', () => { total: 3, passed: 1, failed: 1, - pending: 1 + pending: 1, + neutral: 0 }) }) - it('marks all completed non-failing checks as successful', () => { + it('keeps neutral and unknown terminal conclusions out of passed', () => { expect( buildGitHubCheckSummary([ { status: 'completed', conclusion: 'success' }, { status: 'completed', conclusion: 'neutral' } ]) ).toEqual({ - state: 'success', + state: 'neutral', total: 2, - passed: 2, + passed: 1, failed: 0, - pending: 0 + pending: 0, + neutral: 1 + }) + }) + + it('rolls up GitLab jobs with unknown terminal statuses as neutral', () => { + expect(buildGitLabCheckSummary([{ status: 'success' }, { status: 'future_status' }])).toEqual({ + state: 'neutral', + total: 2, + passed: 1, + failed: 0, + pending: 0, + neutral: 1 }) }) }) diff --git a/mobile/src/tasks/github-check-summary.ts b/mobile/src/tasks/github-check-summary.ts index c2064f302cd..a0006efb95d 100644 --- a/mobile/src/tasks/github-check-summary.ts +++ b/mobile/src/tasks/github-check-summary.ts @@ -4,11 +4,12 @@ export type GitHubCheckLike = { } export type GitHubCheckSummary = { - state: 'success' | 'failure' | 'pending' | 'none' + state: 'success' | 'failure' | 'pending' | 'neutral' | 'none' total: number passed: number failed: number pending: number + neutral: number } function isFailedCheck(check: GitHubCheckLike): boolean { @@ -28,18 +29,32 @@ function isPendingCheck(check: GitHubCheckLike): boolean { export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary { let failed = 0 let pending = 0 + let neutral = 0 for (const check of checks) { - if (isFailedCheck(check)) { + if (isFailedCheck(check) || check.conclusion === 'action_required') { failed += 1 } else if (isPendingCheck(check)) { pending += 1 + } else if (check.conclusion === 'success') { + continue + } else { + neutral += 1 } } const total = checks.length - const passed = Math.max(0, total - failed - pending) - const state = total === 0 ? 'none' : failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success' + const passed = total - failed - pending - neutral + const state = + total === 0 + ? 'none' + : failed > 0 + ? 'failure' + : pending > 0 + ? 'pending' + : neutral > 0 + ? 'neutral' + : 'success' - return { state, total, passed, failed, pending } + return { state, total, passed, failed, pending, neutral } } diff --git a/mobile/src/tasks/gitlab-check-summary.ts b/mobile/src/tasks/gitlab-check-summary.ts new file mode 100644 index 00000000000..d984b6e4283 --- /dev/null +++ b/mobile/src/tasks/gitlab-check-summary.ts @@ -0,0 +1,37 @@ +import { buildGitHubCheckSummary, type GitHubCheckLike } from './github-check-summary' +import type { ProviderCheckSummary } from '../../../src/shared/types' + +type GitLabPipelineJobLike = { status: string } + +export function buildGitLabCheckSummary(jobs: GitLabPipelineJobLike[]): ProviderCheckSummary { + return buildGitHubCheckSummary( + jobs.map((job): GitHubCheckLike => { + const status = job.status.toLowerCase() + const nonterminal = [ + 'created', + 'pending', + 'scheduled', + 'waiting_for_callback', + 'waiting_for_resource', + 'preparing' + ].includes(status) + return { + status: status === 'running' ? 'in_progress' : nonterminal ? 'queued' : 'completed', + conclusion: + status === 'success' + ? 'success' + : status === 'failed' + ? 'failure' + : ['canceled', 'canceling'].includes(status) + ? 'cancelled' + : status === 'skipped' + ? 'skipped' + : ['manual', 'action_required'].includes(status) + ? 'action_required' + : status === 'running' || nonterminal + ? 'pending' + : null + } + }) + ) +} diff --git a/mobile/src/tasks/mobile-hosted-check-status.test.ts b/mobile/src/tasks/mobile-hosted-check-status.test.ts new file mode 100644 index 00000000000..f98b71dc575 --- /dev/null +++ b/mobile/src/tasks/mobile-hosted-check-status.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { HostedReviewDecision } from '../../../src/shared/hosted-review' +import { + getHostedChecksLabel, + getHostedReviewLabel, + getHostedReviewSignalTone +} from './mobile-hosted-check-status' + +describe('mobile hosted check status', () => { + it('renders a hydrated neutral GitLab summary as unresolved checks', () => { + const summary = { + state: 'neutral' as const, + total: 2, + passed: 1, + failed: 0, + pending: 0, + neutral: 1 + } + expect(getHostedChecksLabel({ checksSummary: summary })).toBe('Unresolved checks') + expect(getHostedReviewSignalTone({ checksSummary: summary }, 'checks')).toBe('neutral') + }) + + it('accepts provider-neutral GitHub status fields without a shared work-item type', () => { + expect( + getHostedReviewSignalTone( + { reviewDecision: 'approved', reviewRequests: [], mergeable: 'UNKNOWN' }, + 'review' + ) + ).toBe('success') + }) + + it('renders hydrated GitLab approval, merge, and check states', () => { + expect(getHostedReviewLabel({ reviewDecision: 'review_required', reviewerCount: 2 })).toBe( + 'Review required' + ) + expect( + getHostedReviewSignalTone( + { + reviewDecision: 'review_required', + reviewerCount: 2, + mergeable: 'MERGEABLE', + checksSummary: { + state: 'success', + total: 1, + passed: 1, + failed: 0, + pending: 0, + neutral: 0 + } + }, + 'review' + ) + ).toBe('warning') + expect(getHostedReviewSignalTone({ mergeable: 'MERGEABLE' }, 'merge')).toBe('success') + expect(getHostedReviewSignalTone({ reviewDecision: 'approved' }, 'review')).toBe('success') + expect(getHostedReviewSignalTone({ mergeable: 'CONFLICTING' }, 'merge')).toBe('danger') + }) + + it('renders a shared typed GitLab review decision', () => { + const reviewDecision: HostedReviewDecision = 'changes_requested' + + expect(getHostedReviewLabel({ reviewDecision })).toBe('Changes requested') + expect(getHostedReviewSignalTone({ reviewDecision }, 'review')).toBe('danger') + }) + + it('keeps missing GitLab enrichment neutral', () => { + expect(getHostedReviewLabel({})).toBe('No reviewers') + expect(getHostedReviewSignalTone({}, 'review')).toBe('neutral') + expect(getHostedReviewSignalTone({}, 'merge')).toBe('neutral') + }) +}) diff --git a/mobile/src/tasks/mobile-hosted-check-status.ts b/mobile/src/tasks/mobile-hosted-check-status.ts new file mode 100644 index 00000000000..341d71bc814 --- /dev/null +++ b/mobile/src/tasks/mobile-hosted-check-status.ts @@ -0,0 +1,106 @@ +import type { ProviderCheckSummary, PRMergeableState } from '../../../src/shared/types' + +export type MobileHostedReviewStatus = { + checksSummary?: ProviderCheckSummary + reviewDecision?: string | null + reviewRequests?: readonly unknown[] + reviewerCount?: number + mergeable?: PRMergeableState + mergeStateStatus?: string | null +} + +export function getHostedReviewLabel(item: MobileHostedReviewStatus): string { + if (item.reviewDecision === 'approved' || item.reviewDecision === 'APPROVED') { + return 'Approved' + } + if (item.reviewDecision === 'changes_requested' || item.reviewDecision === 'CHANGES_REQUESTED') { + return 'Changes requested' + } + if (item.reviewDecision === 'review_required' || item.reviewDecision === 'REVIEW_REQUIRED') { + return 'Review required' + } + const reviewerCount = item.reviewerCount ?? item.reviewRequests?.length + return reviewerCount + ? `${reviewerCount} reviewer${reviewerCount === 1 ? '' : 's'}` + : 'No reviewers' +} + +export function getHostedMergeLabel(item: MobileHostedReviewStatus): string { + if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') { + return 'Conflicts' + } + if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { + return 'Behind' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'Able to merge' + } + return 'Unknown' +} + +export function getHostedChecksLabel(item: { checksSummary?: ProviderCheckSummary }): string { + const summary = item.checksSummary + if (!summary) { + return 'Checks' + } + if (summary.total === 0) { + return 'No checks' + } + if (summary.failed > 0) { + return `${summary.failed} failing` + } + if (summary.pending > 0) { + return `${summary.pending} pending` + } + return summary.state === 'neutral' + ? 'Unresolved checks' + : `${summary.passed}/${summary.total} passed` +} + +export function getHostedReviewSignalTone( + item: MobileHostedReviewStatus, + signal: 'review' | 'checks' | 'merge' +): 'neutral' | 'success' | 'warning' | 'danger' { + if (signal === 'review') { + if (item.reviewDecision === 'approved' || item.reviewDecision === 'APPROVED') { + return 'success' + } + if ( + item.reviewDecision === 'changes_requested' || + item.reviewDecision === 'CHANGES_REQUESTED' + ) { + return 'danger' + } + if ( + item.reviewDecision === 'review_required' || + item.reviewDecision === 'REVIEW_REQUIRED' || + item.reviewerCount !== undefined || + item.reviewRequests?.length + ) { + return 'warning' + } + return 'neutral' + } + if (signal === 'checks') { + if (item.checksSummary?.state === 'success') { + return 'success' + } + if (item.checksSummary?.state === 'failure') { + return 'danger' + } + if (item.checksSummary?.state === 'pending') { + return 'warning' + } + return 'neutral' + } + if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') { + return 'danger' + } + if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { + return 'warning' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'success' + } + return 'neutral' +} diff --git a/mobile/src/terminal/terminal-caret-rendering-oracle.test.ts b/mobile/src/terminal/terminal-caret-rendering-oracle.test.ts new file mode 100644 index 00000000000..d2cf51df3b8 --- /dev/null +++ b/mobile/src/terminal/terminal-caret-rendering-oracle.test.ts @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom +import { Terminal, type ITerminalInitOnlyOptions, type ITerminalOptions } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html' + +type CursorCoreService = { + isCursorHidden: boolean + isCursorInitialized: boolean +} + +type ListenerOwner = Pick + +function cursorCoreService(terminal: Terminal): CursorCoreService { + const core = (terminal as unknown as { _core?: { coreService?: CursorCoreService } })._core + expect(core, 'xterm private _core compatibility').toBeDefined() + expect(core?.coreService, 'xterm private coreService compatibility').toBeDefined() + expect(typeof core?.coreService?.isCursorHidden).toBe('boolean') + expect(typeof core?.coreService?.isCursorInitialized).toBe('boolean') + return core!.coreService! +} + +function listenerOwner(target: EventTarget): ListenerOwner { + let owner = target as ListenerOwner | null + while (owner) { + if (Object.hasOwn(owner, 'addEventListener') && Object.hasOwn(owner, 'removeEventListener')) { + return owner + } + owner = Object.getPrototypeOf(owner) as ListenerOwner | null + } + throw new Error(`No event-listener owner for ${target.constructor.name}`) +} + +function eventListenerOwners(): ListenerOwner[] { + const targets: EventTarget[] = [ + document, + document.defaultView!, + document.documentElement, + document.body, + document.createElement('div'), + document.createElement('textarea'), + document.createElement('canvas') + ] + return [...new Set(targets.map(listenerOwner))] +} + +function trackEventListenerCleanup(): () => { + added: number + removed: number + unreleased: string[] +} { + const registrations: Array<{ + capture: boolean + listener: EventListenerOrEventListenerObject + removed: boolean + target: EventTarget + type: string + }> = [] + const capture = (options?: boolean | AddEventListenerOptions | EventListenerOptions): boolean => + typeof options === 'boolean' ? options : (options?.capture ?? false) + const activeRegistration = ( + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions | EventListenerOptions + ) => + registrations.find( + (entry) => + !entry.removed && + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture(options) + ) + + for (const owner of eventListenerOwners()) { + const addEventListener = owner.addEventListener + const removeEventListener = owner.removeEventListener + vi.spyOn(owner, 'addEventListener').mockImplementation(function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ) { + addEventListener.call(this, type, listener, options) + if (!activeRegistration(this, type, listener, options)) { + registrations.push({ + capture: capture(options), + listener, + removed: false, + target: this, + type + }) + } + }) + vi.spyOn(owner, 'removeEventListener').mockImplementation(function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ) { + removeEventListener.call(this, type, listener, options) + const registration = activeRegistration(this, type, listener, options) + if (registration) { + registration.removed = true + } + }) + } + + return () => ({ + added: registrations.length, + removed: registrations.filter((registration) => registration.removed).length, + unreleased: registrations + .filter((registration) => !registration.removed) + .map((registration) => registration.type) + }) +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +describe('xterm caret rendering oracle', () => { + beforeEach(() => { + vi.stubGlobal( + 'OffscreenCanvas', + class { + getContext(): Pick { + return { font: '', measureText: () => ({ width: 8 }) as TextMetrics } + } + } + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('renders and hides an unfocused main-buffer caret', async () => { + const listenerCleanup = trackEventListenerCleanup() + const options: ITerminalOptions & ITerminalInitOnlyOptions = MOBILE_TERMINAL_CARET_OPTIONS + const terminal = new Terminal(options) + const container = document.createElement('div') + document.body.append(container) + + try { + terminal.open(container) + await write(terminal, '\x1b[?25h\x1b[2K\x1b[1G> hello\x1b[?2004h\x1b[1;3H') + terminal.refresh(0, terminal.rows - 1) + await vi.waitFor(() => expect(container.textContent).toContain('hello')) + const coreService = cursorCoreService(terminal) + expect({ + initialized: coreService.isCursorInitialized, + rendered: container.querySelector('.xterm-cursor') !== null + }).toEqual({ initialized: true, rendered: true }) + + await write(terminal, '\x1b[?25l') + terminal.refresh(0, terminal.rows - 1) + await vi.waitFor(() => expect(container.querySelector('.xterm-cursor')).toBeNull()) + expect({ + hidden: coreService.isCursorHidden, + rendered: container.querySelector('.xterm-cursor') !== null + }).toEqual({ hidden: true, rendered: false }) + } finally { + // Why: teardown only — an assertion here would mask the body failure and strand the container. + terminal.dispose() + container.remove() + } + + expect(container.querySelector('.xterm')).toBeNull() + const cleanup = listenerCleanup() + expect(cleanup.added).toBeGreaterThan(0) + expect(cleanup.removed).toBe(cleanup.added) + expect(cleanup.unreleased).toEqual([]) + }) + + it('releases listeners across 25 terminal lifecycles', () => { + const listenerCleanup = trackEventListenerCleanup() + for (let cycle = 0; cycle < 25; cycle += 1) { + const terminal = new Terminal(MOBILE_TERMINAL_CARET_OPTIONS) + const container = document.createElement('div') + document.body.append(container) + terminal.open(container) + terminal.dispose() + expect(container.querySelector('.xterm')).toBeNull() + container.remove() + } + + const cleanup = listenerCleanup() + expect(cleanup.added).toBeGreaterThan(0) + expect(cleanup.removed).toBe(cleanup.added) + expect(cleanup.unreleased).toEqual([]) + }) +}) diff --git a/mobile/src/terminal/terminal-input-connection-gate.test.ts b/mobile/src/terminal/terminal-input-connection-gate.test.ts new file mode 100644 index 00000000000..08b08a2f43f --- /dev/null +++ b/mobile/src/terminal/terminal-input-connection-gate.test.ts @@ -0,0 +1,136 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { resolveMobileTerminalInputGate } from './terminal-input-connection-gate' +import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) + +function routeSlice(anchorStart: string, anchorEnd: string): string { + const start = sessionRouteSource.indexOf(anchorStart) + expect(start).toBeGreaterThanOrEqual(0) + // Why: a duplicated start anchor would silently slice the wrong region. + expect(sessionRouteSource.indexOf(anchorStart, start + 1)).toBe(-1) + const end = sessionRouteSource.indexOf(anchorEnd, start) + expect(end).toBeGreaterThan(start) + return sessionRouteSource.slice(start, end + anchorEnd.length) +} + +describe('terminal input connection gate', () => { + it('Given a live connection on a terminal tab Then composing and sending are both allowed', () => { + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: true, canSend: true }) + }) + + it('Given a cut connection Then composing stays available while sending is blocked', () => { + for (const connState of [ + 'connecting', + 'handshaking', + 'disconnected', + 'reconnecting', + 'auth-failed' + ] as const) { + expect( + resolveMobileTerminalInputGate({ + connState, + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: true, canSend: false }) + } + }) + + it('Given a non-terminal tab or no handle Then neither composing nor sending is allowed', () => { + for (const activeSessionTabType of ['markdown', 'file', 'browser']) { + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: 'terminal-a', + activeSessionTabType + }) + ).toEqual({ canCompose: false, canSend: false }) + } + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: null, + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: false, canSend: false }) + }) + + it('Given a lagging tab list yielding no tab Then the gate treats the type as unknown, not non-terminal', () => { + expect( + resolveMobileTerminalInputGate({ + connState: 'disconnected', + activeHandle: 'terminal-a', + activeSessionTabType: undefined + }) + ).toEqual({ canCompose: true, canSend: false }) + }) +}) + +describe('session route offline-compose wiring', () => { + it('derives both gates from the shared resolver', () => { + expect(sessionRouteSource).toContain('resolveMobileTerminalInputGate({') + }) + + it('keeps the buffered command box editable offline while the live capture stays send-gated', () => { + const bufferedInput = routeSlice( + 'ref={commandInputRef}', + 'onSubmitEditing={() => void handleSend()}' + ) + expect(bufferedInput).toContain('editable={canCompose}') + + const liveCapture = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"') + expect(liveCapture).toContain('editable={canSend}') + }) + + it('keeps the send button connection-gated so held text cannot fire into a dead link', () => { + const sendButton = routeSlice('styles.sendButton,', 'accessibilityLabel="Send command"') + expect(sendButton).toContain('disabled={!canSend}') + }) + + it('holds composed text when the return key submits offline', () => { + const handleSend = routeSlice('async function handleSend()', 'sendingRef.current = true') + expect(handleSend).toContain('!canSend') + }) + + it('keeps the live/buffered mode toggle reachable offline', () => { + const modeToggle = routeSlice( + 'liveInputEnabled && styles.accessoryKeyActive', + 'onPress={toggleLiveInput}' + ) + expect(modeToggle).toContain('disabled={!canCompose}') + }) + + it('tells the live-input commit hook about connection loss so stale mirror state resets', () => { + const hookCall = routeSlice('useTerminalLiveInputCommit({', 'setLiveInputCapture') + expect(hookCall).toContain("connected: connState === 'connected'") + }) + + it('keeps every keystroke-grade terminal send now-or-never so nothing replays after reconnect', () => { + // Live mirror, buffered send, and gesture arrows must all opt out of the + // connect wait — a parked send replays stale bytes into the PTY. Accessory + // keys get the same option inside terminal-live-accessory-raw-send.ts. + const optOuts = sessionRouteSource.match(/TERMINAL_INPUT_SEND_OPTIONS/g)?.length ?? 0 + expect(optOuts).toBe(4) + expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true }) + }) + + it('tags terminal sends with the device presence lock only when a token exists', () => { + expect( + buildTerminalSendParams({ terminal: 't1', text: 'ls', enter: true, deviceToken: 'tok' }) + ).toEqual({ terminal: 't1', text: 'ls', enter: true, client: { id: 'tok', type: 'mobile' } }) + expect( + buildTerminalSendParams({ terminal: 't1', text: 'ls', enter: false, deviceToken: null }) + ).toEqual({ terminal: 't1', text: 'ls', enter: false }) + }) +}) diff --git a/mobile/src/terminal/terminal-input-connection-gate.ts b/mobile/src/terminal/terminal-input-connection-gate.ts new file mode 100644 index 00000000000..c8d4da30138 --- /dev/null +++ b/mobile/src/terminal/terminal-input-connection-gate.ts @@ -0,0 +1,26 @@ +import type { ConnectionState } from '../transport/types' + +type MobileTerminalInputGateOptions = { + readonly connState: ConnectionState + readonly activeHandle: string | null + readonly activeSessionTabType: string | null | undefined +} + +type MobileTerminalInputGate = { + // Why: composing is local — it must survive an outage so typed text is held, not discarded (#6713). + readonly canCompose: boolean + readonly canSend: boolean +} + +export function resolveMobileTerminalInputGate({ + connState, + activeHandle, + activeSessionTabType +}: MobileTerminalInputGateOptions): MobileTerminalInputGate { + const canCompose = + activeHandle != null && + activeSessionTabType !== 'markdown' && + activeSessionTabType !== 'file' && + activeSessionTabType !== 'browser' + return { canCompose, canSend: canCompose && connState === 'connected' } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts new file mode 100644 index 00000000000..7a1110b407b --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send' +import type { RpcClient } from '../transport/rpc-client' + +function captureClient(result: Promise = Promise.resolve({ ok: true })) { + const sendRequest = vi.fn(() => result) + return { client: { sendRequest } as unknown as Pick, sendRequest } +} + +const BASE_ARGS = { + targetHandle: 'terminal-a', + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal', + connState: 'connected', + bytes: '', + deviceToken: 'tok' +} as const + +describe('terminal live accessory raw send', () => { + it('sends raw bytes now-or-never with the device presence tag', async () => { + const { client, sendRequest } = captureClient() + + await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) + + expect(sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { terminal: 'terminal-a', text: '', enter: false, client: { id: 'tok', type: 'mobile' } }, + // Why: an accessory key parked in the connect wait would fire into the PTY long after the tap. + { failWhenDisconnected: true } + ) + }) + + it('drops the bytes instead of sending while disconnected', async () => { + const { client, sendRequest } = captureClient() + + await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' }) + + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('drops the bytes when the terminal selection went stale mid-flush', async () => { + const { client, sendRequest } = captureClient() + + await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' }) + + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('swallows a rejected send so accessory taps never surface transport errors', async () => { + const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send'))) + + await expect( + sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) + ).resolves.toBeUndefined() + }) +}) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.ts new file mode 100644 index 00000000000..792491f6c63 --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.ts @@ -0,0 +1,43 @@ +import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target' +import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' + +type TerminalLiveAccessoryRawSendArgs = { + readonly client: Pick | null + readonly targetHandle: string + readonly activeHandle: string | null + readonly activeSessionTabType: string | null + readonly connState: ConnectionState + readonly bytes: string + readonly deviceToken: string | null +} + +export async function sendTerminalLiveAccessoryRawBytes( + args: TerminalLiveAccessoryRawSendArgs +): Promise { + // Why: async IME flushing can outlive the original terminal selection. + const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle: args.targetHandle, + activeHandle: args.activeHandle, + activeSessionTabType: args.activeSessionTabType + }) + if (!args.client || !rawSendTarget || args.connState !== 'connected') { + return + } + await args.client + .sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: rawSendTarget, + text: args.bytes, + enter: false, + deviceToken: args.deviceToken + }), + TERMINAL_INPUT_SEND_OPTIONS + ) + .then( + () => undefined, + () => undefined + ) +} diff --git a/mobile/src/terminal/terminal-send-request.ts b/mobile/src/terminal/terminal-send-request.ts new file mode 100644 index 00000000000..e99c6f03c35 --- /dev/null +++ b/mobile/src/terminal/terminal-send-request.ts @@ -0,0 +1,26 @@ +import type { SendRequestOptions } from '../transport/rpc-client' + +type TerminalSendParams = { + readonly terminal: string + readonly text: string + readonly enter: boolean + readonly client?: { readonly id: string; readonly type: 'mobile' } +} + +// Why: keystroke sends must never park in the connect wait — parked sends replay into the PTY after reconnect (#6713). +export const TERMINAL_INPUT_SEND_OPTIONS: SendRequestOptions = { failWhenDisconnected: true } + +export function buildTerminalSendParams(args: { + terminal: string + text: string + enter: boolean + // Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor. + deviceToken: string | null +}): TerminalSendParams { + return { + terminal: args.terminal, + text: args.text, + enter: args.enter, + ...(args.deviceToken ? { client: { id: args.deviceToken, type: 'mobile' as const } } : {}) + } +} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 11579955926..e8409cb3bee 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -38,6 +38,13 @@ const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { brightWhite: '#c0caf5' } +export const MOBILE_TERMINAL_CARET_OPTIONS = { + cursorBlink: false, + cursorStyle: 'bar', + showCursorImmediately: true, + cursorInactiveStyle: 'block' +} as const + // Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing. export const XTERM_HTML = ` @@ -726,11 +733,12 @@ ${TERMINAL_WEBGL_RECOVERY_JS} // Why: xterm suppresses parser-generated query replies when disableStdin // is true. Native accepts only validated reply grammars from onData. disableStdin: false, - cursorBlink: false, - cursorStyle: 'bar', - // Why: native TextInput owns mobile keyboard focus, so xterm stays inactive. - // Match its active bar while still honoring application cursor-hide sequences. - cursorInactiveStyle: 'bar', + cursorBlink: ${MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink}, + cursorStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle)}, + // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. + showCursorImmediately: ${MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately}, + // A full inactive cell remains visible under the terminal's phone-fit scale. + cursorInactiveStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle)}, convertEol: false, allowProposedApi: true }); diff --git a/mobile/src/terminal/terminal-webview-init-surface.test.ts b/mobile/src/terminal/terminal-webview-init-surface.test.ts index 9aa23e1fc53..fdef4f5b4df 100644 --- a/mobile/src/terminal/terminal-webview-init-surface.test.ts +++ b/mobile/src/terminal/terminal-webview-init-surface.test.ts @@ -18,6 +18,7 @@ type TerminalStub = ReturnType type TerminalOptions = { cursorInactiveStyle?: string cursorStyle?: string + showCursorImmediately?: boolean } type RegisteredWindowListener = { listener: EventListenerOrEventListenerObject @@ -146,7 +147,8 @@ describe('terminal WebView init surface replacement', () => { for (const options of terminalOptions) { expect(options).toMatchObject({ cursorStyle: 'bar', - cursorInactiveStyle: 'bar' + cursorInactiveStyle: 'block', + showCursorImmediately: true }) } }) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index b759e30d615..c211ff447ec 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -11,6 +11,8 @@ type TerminalLiveInputCommitHarness = { readonly handlers: ReturnType> readonly sent: readonly string[] readonly setActiveSessionTabType: (next: string | undefined) => void + readonly setConnected: (next: boolean) => void + readonly setSendResult: (next: boolean) => void readonly unmount: () => void } @@ -46,15 +48,16 @@ function createTerminalLiveInputCommitHarness({ current: new Set([activeHandle]) } const sent: string[] = [] + let currentSendResult = sendResult const sendLiveTerminalInputRef: RefObject = { current: async (_handle, bytes) => { sent.push(bytes) - return sendResult + return currentSendResult } } - // The hook keeps live-input state in refs, so a change handler alone never - // re-renders; only a prop change (this variable) re-runs the pending-clear effect. + // Refs never re-render; only these variables re-run the hook's clear effects. let currentActiveSessionTabType: string | undefined = 'terminal' + let currentConnected = true let handlers: ReturnType> | null = null let renderer: ReactTestRenderer | null = null @@ -64,6 +67,7 @@ function createTerminalLiveInputCommitHarness({ activeHandleRef, activeSessionTabType: currentActiveSessionTabType, activeSessionTabTypeRef, + connected: currentConnected, liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, @@ -98,6 +102,15 @@ function createTerminalLiveInputCommitHarness({ renderer?.update(createElement(Harness)) }) }, + setConnected: (next: boolean): void => { + currentConnected = next + act(() => { + renderer?.update(createElement(Harness)) + }) + }, + setSendResult: (next: boolean): void => { + currentSendResult = next + }, unmount: () => { act(() => renderer?.unmount()) } @@ -340,4 +353,40 @@ describe('terminal live input commit hook', () => { // Then: pending was dropped, so submit sends only the carriage return await vi.waitFor(() => expect(sent).toEqual(['\r'])) }) + + it('Given bytes lost in a silent stall When the disconnect is detected Then the first post-recovery send carries no stale fragment or phantom erases', async () => { + // Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect) + const { captures, handlers, sent, setConnected, setSendResult } = + createTerminalLiveInputCommitHarness({ sendResult: false }) + handlers.handleLiveInputChange('XYZZY') + await vi.waitFor(() => expect(sent).toEqual(['XYZZY'])) + + // When: the outage is finally detected, then the link recovers + setConnected(false) + setSendResult(true) + setConnected(true) + + // Then: the capture was wiped, and fresh typing sends verbatim bytes — not + // 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived + expect(captures.at(-1)).toBe('') + const sentBeforeRecovery = sent.length + handlers.handleLiveInputChange('echo CLEANLINE') + await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE'])) + }) + + it('Given a held syllable during an outage When the disconnect is detected Then the settle timer cannot commit it later', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({ + sendResult: false + }) + handlers.handleLiveInputChange('한') + + // When + setConnected(false) + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS) + + // Then: the outage cleared the held text before the timer could send it + expect(sent).toEqual([]) + }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 9c707dfbb31..d3b3e3fec09 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -22,6 +22,7 @@ type TerminalLiveInputCommitOptions = { readonly activeHandleRef: RefObject readonly activeSessionTabType: TTabType | null | undefined readonly activeSessionTabTypeRef: RefObject + readonly connected: boolean readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet readonly liveInputTerminalHandlesRef: RefObject> @@ -45,6 +46,7 @@ export function useTerminalLiveInputCommit({ activeHandleRef, activeSessionTabType, activeSessionTabTypeRef, + connected, liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, @@ -68,6 +70,13 @@ export function useTerminalLiveInputCommit({ setLiveInputCapture }) + useEffect(() => { + // Why: what reached the PTY is unknowable across an outage — stale mirror state corrupts the first post-reconnect send. + if (!connected) { + clearPendingLiveInputCommit() + } + }, [connected, clearPendingLiveInputCommit]) + useEffect(() => { const pendingHandle = pendingLiveInputHandleRef.current if (!pendingHandle) { diff --git a/mobile/src/transport/cellular-connecting-label-stall.test.ts b/mobile/src/transport/cellular-connecting-label-stall.test.ts new file mode 100644 index 00000000000..e7bc6731462 --- /dev/null +++ b/mobile/src/transport/cellular-connecting-label-stall.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' +import { classifyConnection, verdictDisplayLabel } from './connection-health' + +// Issue #10119 — "mobile client fails to connect over cellular and remains stuck +// at connecting". Each scenario below is a way a cellular link fails and what the +// user is shown while it does. On the parent commit ws.onopen reset +// reconnectAttempt before the E2EE handshake completed and classifyConnection +// reverted to "Connecting…" on every redial, so no escalation assertion here +// could pass: the label looped "Connecting…" forever. + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => plaintext, + decrypt: (raw: string) => raw, + decryptBytes: (bytes: Uint8Array) => bytes +})) + +type CarrierBehavior = + // Carrier silently drops the SYN to a LAN/CGNAT destination: the socket sits + // CONNECTING until the client's 12s connect timeout fires. + | { kind: 'blackhole' } + // Carrier answers with an RST. + | { kind: 'refused' } + // Endpoint completes the WS upgrade, then never speaks (relay accepted the + // socket but no desktop joined the session). + | { kind: 'open-then-silent' } + // Endpoint is healthy but its e2ee_ready lands after readyAfterMs, which a + // high-latency / lossy cellular link readily pushes past the 5s budget. + | { kind: 'slow-handshake'; readyAfterMs: number } + +let carrier: CarrierBehavior = { kind: 'blackhole' } +const sockets: CarrierWebSocket[] = [] + +class CarrierWebSocket { + static CONNECTING = 0 + // Why: sendEncrypted compares readyState against WebSocket.OPEN — the mock + // must define it or the e2ee_auth write silently fails. + static OPEN = 1 + static CLOSED = 3 + readonly CONNECTING = 0 + readyState = 0 + onopen: (() => void) | null = null + onclose: ((event?: unknown) => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: ((event?: unknown) => void) | null = null + + constructor(readonly endpoint: string) { + sockets.push(this) + if (carrier.kind === 'refused') { + setTimeout(() => this.fail(), 1) + return + } + if (carrier.kind === 'open-then-silent' || carrier.kind === 'slow-handshake') { + setTimeout(() => { + this.readyState = 1 + this.onopen?.() + }, 200) + } + } + + send(payload: string): void { + if (carrier.kind !== 'slow-handshake') { + return + } + if (payload.includes('e2ee_hello')) { + const delay = carrier.readyAfterMs + setTimeout(() => { + if (this.readyState === 1) { + this.onmessage?.({ data: JSON.stringify({ type: 'e2ee_ready' }) }) + } + }, delay) + return + } + if (payload.includes('e2ee_auth')) { + setTimeout(() => { + if (this.readyState === 1) { + this.onmessage?.({ data: JSON.stringify({ type: 'e2ee_authenticated' }) }) + } + }, 10) + } + } + + close(): void { + if (this.readyState === 3) { + return + } + this.fail() + } + + private fail(): void { + this.readyState = 3 + this.onerror?.({ message: 'network error' }) + this.onclose?.({ code: 1006, wasClean: false }) + } +} + +const originalWebSocket = globalThis.WebSocket + +type Sample = { atMs: number; state: string; attempts: number; label: string } + +// Walks simulated time recording what every screen would render, since +// classifyConnection is the single label source for home, host, tasks and session. +function observe(endpoint: string, durationMs: number): Sample[] { + const client = connect(endpoint, 'device-token', 'server-public-key') + const samples: Sample[] = [] + const stepMs = 250 + for (let atMs = 0; atMs <= durationMs; atMs += stepMs) { + const state = client.getState() + const attempts = client.getReconnectAttempt() + samples.push({ + atMs, + state, + attempts, + label: verdictDisplayLabel( + classifyConnection({ + state, + reconnectAttempts: attempts, + lastConnectedAt: client.getLastConnectedAt(), + endpoint, + nowMs: Date.now() + }) + ) + }) + vi.advanceTimersByTime(stepMs) + } + client.close() + return samples +} + +function isEscalated(label: string): boolean { + return label !== 'Connecting…' && label !== 'Reconnecting…' +} + +function labelsAfterFirstEscalation(samples: Sample[]): Sample[] { + const first = samples.findIndex((s) => isEscalated(s.label)) + expect(first).toBeGreaterThan(0) + return samples.slice(first) +} + +describe('issue #10119 — what a phone shows while it cannot reach the desktop', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'log').mockImplementation(() => {}) + sockets.length = 0 + // @ts-expect-error test double for the RN global + globalThis.WebSocket = CarrierWebSocket + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + globalThis.WebSocket = originalWebSocket + }) + + it('escalates and stays escalated when the handshake never fits the 5s budget', () => { + carrier = { kind: 'slow-handshake', readyAfterMs: 6_000 } + const samples = observe('ws://192.168.0.56:6769', 900_000) + + // 15 minutes of failure against a desktop that is up and answering. + expect(samples.some((s) => s.state === 'handshaking')).toBe(true) + expect(samples.some((s) => s.state === 'connected')).toBe(false) + + // The failure counter survives ws.onopen, so both connection-health gates + // (warning >= 3, unreachable >= 12) are reachable. + expect(Math.max(...samples.map((s) => s.attempts))).toBeGreaterThanOrEqual(12) + expect(samples.some((s) => s.label === "Can't connect")).toBe(true) + expect(samples.some((s) => s.label === "Can't reach desktop")).toBe(true) + + // Once escalated, no later redial reverts the label to "Connecting…". + const after = labelsAfterFirstEscalation(samples) + expect(after.every((s) => s.label !== 'Connecting…')).toBe(true) + + // Growing backoff: the pinned counter burned a socket every ~5.6s (~160 in + // 15 min); with the tiered delays plus the 90s trickle it stays modest. + expect(sockets.length).toBeGreaterThanOrEqual(5) + expect(sockets.length).toBeLessThan(30) + }) + + it('escalates when the endpoint accepts the socket and then says nothing', () => { + carrier = { kind: 'open-then-silent' } + const samples = observe('ws://relay.example:443', 900_000) + + expect(Math.max(...samples.map((s) => s.attempts))).toBeGreaterThanOrEqual(12) + expect(samples.some((s) => s.label === "Can't reach desktop")).toBe(true) + + const after = labelsAfterFirstEscalation(samples) + expect(after.every((s) => s.label !== 'Connecting…')).toBe(true) + }) + + it('latches "Can\'t connect" on a blackholed LAN endpoint instead of reverting each dial', () => { + carrier = { kind: 'blackhole' } + const samples = observe('ws://192.168.0.56:6769', 120_000) + + // Three 12s connect timeouts plus backoff: first escalation lands ≈ 37.5s. + const first = samples.find((s) => isEscalated(s.label)) + expect(first?.atMs ?? Infinity).toBeLessThanOrEqual(40_000) + + // Every later 12s dial window used to flip the label back to "Connecting…". + const after = labelsAfterFirstEscalation(samples) + expect(after.every((s) => s.label !== 'Connecting…')).toBe(true) + }) + + it('holds "Can\'t reach desktop" through every trickle dial once past the give-up cap', () => { + carrier = { kind: 'blackhole' } + const samples = observe('ws://192.168.0.56:6769', 900_000) + + const firstUnreachable = samples.findIndex((s) => s.label === "Can't reach desktop") + expect(firstUnreachable).toBeGreaterThan(0) + + // The loop has given up internally (attempts held at the cap); the label + // must say so through the 12s of every 90s trickle dial, not just between them. + const after = samples.slice(firstUnreachable) + expect(after.every((s) => s.attempts >= 12)).toBe(true) + expect(after.every((s) => s.label === "Can't reach desktop")).toBe(true) + }) + + it('resets the failure counter only once a handshake actually completes', () => { + carrier = { kind: 'slow-handshake', readyAfterMs: 6_000 } + const client = connect('ws://192.168.0.56:6769', 'device-token', 'server-public-key') + + vi.advanceTimersByTime(40_000) + expect(client.getState()).not.toBe('connected') + expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(3) + + // The link heals: the same desktop now answers inside the budget. + carrier = { kind: 'slow-handshake', readyAfterMs: 50 } + vi.advanceTimersByTime(20_000) + expect(client.getState()).toBe('connected') + expect(client.getReconnectAttempt()).toBe(0) + + client.close() + }) + + it('contrast: an endpoint that RSTs escalates the same way', () => { + carrier = { kind: 'refused' } + const samples = observe('ws://192.168.0.56:6769', 60_000) + + expect(samples.some((s) => s.label === "Can't connect")).toBe(true) + const after = labelsAfterFirstEscalation(samples) + expect(after.every((s) => s.label !== 'Connecting…')).toBe(true) + }) +}) diff --git a/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts b/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts new file mode 100644 index 00000000000..3cb87609c16 --- /dev/null +++ b/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebSocketServer, type WebSocket as ServerWebSocket } from 'ws' +import { connect } from './rpc-client' +import { classifyConnection, verdictDisplayLabel } from './connection-health' + +// Issue #10119 verification over a REAL socket, real timers, real WebSocket +// upgrade — guards the fake-timer suite in +// cellular-connecting-label-stall.test.ts against mock drift. +// +// The desktop is up and listening. The only thing wrong is that its E2EE +// handshake reply lands later than HANDSHAKE_TIMEOUT_MS (5s) — the condition a +// high-latency / lossy cellular link produces on a handshake that needs two +// round trips (e2ee_hello → e2ee_ready → e2ee_auth → e2ee_authenticated). +// +// The client must escalate past "Connecting…" and stay escalated. On the parent +// commit ws.onopen reset reconnectAttempt to 0 before the handshake succeeded, +// so getReconnectAttempt() never passed the connection-health gates and the +// label looped "Connecting…" forever. +// +// Opt-in like rpc-client-live-recovery.test.ts — needs ~22s wall-clock: +// ORCA_MOBILE_LIVE_REPRO=1 pnpm vitest run src/transport/cellular-handshake-stall-real-socket.test.ts + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => plaintext, + decrypt: (raw: string) => raw, + decryptBytes: (bytes: Uint8Array) => bytes +})) + +const RUN_LIVE = + process.env.ORCA_MOBILE_LIVE_REPRO === '1' || !!process.env.ORCA_MOBILE_LIVE_REPRO_FULL + +// Long enough for three handshake-timeout cycles (≈17s) plus CI scheduling slack. +const OBSERVE_MS = 22_000 +const SAMPLE_MS = 200 + +let server: WebSocketServer | null = null +const serverSockets: ServerWebSocket[] = [] + +// Completes the WebSocket upgrade, then answers e2ee_hello later than the +// client's 5s handshake budget allows. +async function startSlowHandshakeDesktop(replyAfterMs: number): Promise { + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + server = wss + wss.on('connection', (socket) => { + serverSockets.push(socket) + socket.on('message', (data) => { + const text = data.toString() + if (!text.includes('e2ee_hello')) { + return + } + setTimeout(() => { + if (socket.readyState === socket.OPEN) { + socket.send(JSON.stringify({ type: 'e2ee_ready', publicKeyB64: 'server-public-key' })) + } + }, replyAfterMs) + }) + }) + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() + if (typeof address === 'string' || address === null) { + throw new Error('expected a TCP address') + } + return address.port +} + +describe.runIf(RUN_LIVE)('issue #10119 — real socket, handshake slower than the 5s budget', () => { + beforeEach(() => { + serverSockets.length = 0 + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(async () => { + vi.restoreAllMocks() + for (const socket of serverSockets) { + socket.terminate() + } + const wss = server + server = null + if (wss) { + await new Promise((resolve) => wss.close(() => resolve())) + } + }) + + it('escalates and latches against a live desktop that answers 1s too late', async () => { + const port = await startSlowHandshakeDesktop(6_000) + const endpoint = `ws://127.0.0.1:${port}` + const client = connect(endpoint, 'device-token', 'server-public-key') + + const labels: string[] = [] + const states: string[] = [] + let maxAttempts = 0 + const started = Date.now() + while (Date.now() - started < OBSERVE_MS) { + const state = client.getState() + const attempts = client.getReconnectAttempt() + maxAttempts = Math.max(maxAttempts, attempts) + states.push(state) + labels.push( + verdictDisplayLabel( + classifyConnection({ + state, + reconnectAttempts: attempts, + lastConnectedAt: client.getLastConnectedAt(), + endpoint + }) + ) + ) + await new Promise((resolve) => setTimeout(resolve, SAMPLE_MS)) + } + client.close() + + // The dial loop really did run and really did keep failing. + expect(serverSockets.length).toBeGreaterThanOrEqual(2) + expect(states).toContain('handshaking') + expect(states).not.toContain('connected') + + // The failure counter survives ws.onopen, so the warning gate (>= 3) fires. + expect(maxAttempts).toBeGreaterThanOrEqual(3) + const firstEscalated = labels.findIndex((l) => l !== 'Connecting…' && l !== 'Reconnecting…') + expect(firstEscalated).toBeGreaterThan(0) + + // Once escalated, later redials never revert the label to "Connecting…". + expect(labels.slice(firstEscalated).every((l) => l !== 'Connecting…')).toBe(true) + }, 60_000) +}) + +// Why: vitest fails a file with zero tests; keep a sentinel for default runs. +describe.runIf(!RUN_LIVE)('real-socket handshake stall (skipped)', () => { + it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => { + expect(true).toBe(true) + }) +}) diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts index 4a0973dae74..5ac61c06a93 100644 --- a/mobile/src/transport/connection-health.test.ts +++ b/mobile/src/transport/connection-health.test.ts @@ -71,6 +71,45 @@ describe('classifyConnection Tailscale hint', () => { }) }) +// Issue #10119: every redial re-enters 'connecting', which used to revert an +// escalated verdict to "Connecting…" for the whole dial window — on a loop that +// had already failed for minutes, the user mostly saw the reassuring label. +describe('classifyConnection while dialing (issue #10119)', () => { + const base = { lastConnectedAt: null, nowMs: 1_000_000 } + + it('keeps the warning verdict through a redial instead of reverting to Connecting…', () => { + for (const state of ['connecting', 'handshaking'] as const) { + const verdict = classifyConnection({ ...base, state, reconnectAttempts: 3 }) + expect(verdict).toMatchObject({ kind: 'warning', label: "Can't connect" }) + } + }) + + it('keeps the unreachable verdict through a trickle dial', () => { + const verdict = classifyConnection({ ...base, state: 'connecting', reconnectAttempts: 12 }) + expect(verdict).toMatchObject({ kind: 'unreachable', reason: 'never-connected' }) + }) + + it('applies the stale heuristic while dialing too', () => { + const verdict = classifyConnection({ + state: 'handshaking', + reconnectAttempts: 12, + lastConnectedAt: 900_000, + nowMs: 1_000_000 + }) + expect(verdict).toMatchObject({ kind: 'unreachable', reason: 'stale' }) + }) + + it('still shows Connecting… before any failures', () => { + const verdict = classifyConnection({ ...base, state: 'connecting', reconnectAttempts: 0 }) + expect(verdict).toEqual({ kind: 'normal', label: 'Connecting…' }) + }) + + it('still shows Connecting… below the warning gate', () => { + const verdict = classifyConnection({ ...base, state: 'handshaking', reconnectAttempts: 2 }) + expect(verdict).toEqual({ kind: 'normal', label: 'Connecting…' }) + }) +}) + describe('verdictDisplayLabel', () => { it('appends the hint to warning and unreachable labels', () => { expect( diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index c244b6c9afd..6774b111ff5 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -61,19 +61,18 @@ export function classifyConnection(args: { return { kind: 'auth-failed', label: 'Pairing invalid — re-pair with your desktop' } } - // Connected / connecting / handshaking are normal. if (state === 'connected') { return { kind: 'normal', label: 'Connected' } } - if (state === 'connecting' || state === 'handshaking') { - return { kind: 'normal', label: 'Connecting…' } - } if (state === 'disconnected') { return { kind: 'normal', label: 'Disconnected' } } - // state === 'reconnecting' from here. + // connecting / handshaking / reconnecting from here. The gates apply to all + // three: every redial re-enters 'connecting', and letting that revert an + // escalated verdict to "Connecting…" hid the failure loop behind a reassuring + // label for most of each cycle (issue #10119). if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) { if (lastConnectedAt == null) { return { @@ -97,7 +96,7 @@ export function classifyConnection(args: { return { kind: 'warning', label: "Can't connect", hint } } - return { kind: 'normal', label: 'Reconnecting…' } + return { kind: 'normal', label: state === 'reconnecting' ? 'Reconnecting…' : 'Connecting…' } } // Why: single place that turns a verdict into display text so every screen diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 918e6e92f93..cee492dfd09 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -14,6 +14,7 @@ const secureStoreMock = vi.hoisted(() => ({ })) const scheduleCleanupMock = vi.hoisted(() => vi.fn()) +const platformMock = vi.hoisted(() => ({ OS: 'ios' })) vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock @@ -25,7 +26,7 @@ vi.mock('expo-secure-store', () => ({ })) vi.mock('react-native', () => ({ - Platform: { OS: 'ios' } + Platform: platformMock })) vi.mock('./host-credential-cleanup', () => ({ @@ -70,6 +71,7 @@ describe('host-store list mutations', () => { beforeEach(() => { vi.clearAllMocks() resetHostStoreForTests() + platformMock.OS = 'ios' resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) @@ -179,6 +181,25 @@ describe('host-store list mutations', () => { expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() }) + it('keeps the normal iOS save on the existing default keychain service', async () => { + await saveHost({ + id: 'host-new', + name: 'New Host', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'new-token', + lastConnected: 0 + }) + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-new', + 'new-token', + { + keychainAccessible: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' + } + ) + }) + it('commits the removal when credential cleanup scheduling rejects', async () => { scheduleCleanupMock.mockRejectedValue(new Error('intent storage unavailable')) @@ -328,3 +349,104 @@ describe('host-store list mutations', () => { expect(storedHostsRaw).toBe('{') }) }) + +describe('host-store pairing save after an Android encryption rejection', () => { + const NEW_HOST = { + id: 'host-1782629088232', + name: 'Host 1', + endpoint: 'ws://192.168.0.56:6769', + publicKeyB64: 'desktop-key', + lastConnected: 0, + deviceToken: 'device-token' + } + // Why: the verbatim Android rejection from #6600 — expo maps a null-message GeneralSecurityException to this. + const ENCRYPT_REJECTION = new Error( + "Could not encrypt the value for key 'orca.host-token.host-1782629088232' under keychain 'key_v1'. Caused by: unknown" + ) + const GENERATION_KEY = 'orca:pairing-keychain-generation' + let storedHostsRaw: string + let storedGenerationRaw: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetHostStoreForTests() + platformMock.OS = 'android' + resetMobileRelayHostOverlayStoreForTests() + scheduleCleanupMock.mockReset() + scheduleCleanupMock.mockResolvedValue(undefined) + storedHostsRaw = '[]' + storedGenerationRaw = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === HOSTS_STORAGE_KEY) { + return storedHostsRaw + } + // Why: the generation record is durable on device; a forgetful mock would fake a broken read path. + return key === GENERATION_KEY ? storedGenerationRaw : null + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === HOSTS_STORAGE_KEY) { + storedHostsRaw = raw + } else if (key === GENERATION_KEY) { + storedGenerationRaw = raw + } + }) + secureStoreMock.deleteItemAsync.mockResolvedValue(undefined) + secureStoreMock.getItemAsync.mockResolvedValue(null) + }) + + it('saves the host when the reported Android failure is alias-local (#6600)', async () => { + const written = new Map() + // Why: simulate the unverified alias-local case; no affected physical device was available. + secureStoreMock.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw ENCRYPT_REJECTION + } + written.set(options.keychainService, value) + } + ) + + await expect(saveHost(NEW_HOST)).resolves.toBeUndefined() + + expect(written.get('orca.pairing.v1')).toBe('device-token') + expect(JSON.parse(storedHostsRaw)).toEqual([ + { + id: NEW_HOST.id, + name: NEW_HOST.name, + endpoint: NEW_HOST.endpoint, + publicKeyB64: NEW_HOST.publicKeyB64, + lastConnected: NEW_HOST.lastConnected + } + ]) + }) + + it('still surfaces the failure when no keystore alias can accept the token', async () => { + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(saveHost(NEW_HOST)).rejects.toBe(ENCRYPT_REJECTION) + }) + + it('serves the rotated token to loadHosts so the saved host survives a relaunch', async () => { + const written = new Map() + secureStoreMock.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw ENCRYPT_REJECTION + } + written.set(options.keychainService, value) + } + ) + await saveHost(NEW_HOST) + // Why: a fresh process has no token cache, so the host list has to come back off the rotated alias. + resetHostStoreForTests() + secureStoreMock.getItemAsync.mockImplementation( + async (_key: string, options?: { keychainService?: string }) => + written.get(options?.keychainService) ?? null + ) + + const hosts = await loadHosts() + + expect(hosts).toHaveLength(1) + expect(hosts[0]!.deviceToken).toBe('device-token') + }) +}) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 61ed6742d12..9d16235481c 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { HostProfileSchema, @@ -8,6 +7,12 @@ import { type StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' import { retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup @@ -27,12 +32,6 @@ const STORAGE_KEY = 'orca:hosts' const TOKEN_KEY_PREFIX = 'orca.host-token.' const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' -// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off iCloud Keychain and out of backup restores onto another device. -// Reads/writes stay silent (no biometric prompt) because we don't request access control flags. -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function tokenKey(hostId: string): string { return `${TOKEN_KEY_PREFIX}${hostId}` } @@ -46,7 +45,7 @@ async function readDeviceToken(hostId: string): Promise { if (Platform.OS === 'web') { return AsyncStorage.getItem(webTokenKey(hostId)) } - return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + return readPairingKeychainItem(tokenKey(hostId)) } async function writeDeviceToken(hostId: string, token: string): Promise { @@ -54,7 +53,7 @@ async function writeDeviceToken(hostId: string, token: string): Promise { await AsyncStorage.setItem(webTokenKey(hostId), token) return } - await SecureStore.setItemAsync(tokenKey(hostId), token, KEYCHAIN_OPTIONS) + await writePairingKeychainItem(tokenKey(hostId), token) } async function deleteDeviceToken(hostId: string): Promise { @@ -62,7 +61,7 @@ async function deleteDeviceToken(hostId: string): Promise { await AsyncStorage.removeItem(webTokenKey(hostId)) return } - await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(tokenKey(hostId)) } async function deleteHostCredentials(hostId: string): Promise { @@ -342,4 +341,5 @@ export function resetHostStoreForTests(): void { hostListMutation = Promise.resolve() tokenCache.clear() inflightLoad = null + resetPairingKeychainForTests() } diff --git a/mobile/src/transport/mobile-relay-credential-bundle.test.ts b/mobile/src/transport/mobile-relay-credential-bundle.test.ts index 4df9d44430b..185d5410980 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.test.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) const secureStore = vi.hoisted(() => ({ getItemAsync: vi.fn(), setItemAsync: vi.fn(), @@ -7,6 +11,7 @@ const secureStore = vi.hoisted(() => ({ })) const platform = vi.hoisted(() => ({ OS: 'ios' })) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', ...secureStore @@ -20,6 +25,7 @@ import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { resetPairingKeychainForTests } from './pairing-keychain' const journal = { metadata: { @@ -62,8 +68,11 @@ describe('mobile relay credential bundle', () => { beforeEach(() => { vi.clearAllMocks() + resetPairingKeychainForTests() platform.OS = 'ios' stored = null + asyncStorage.getItem.mockResolvedValue(null) + asyncStorage.setItem.mockResolvedValue(undefined) secureStore.getItemAsync.mockImplementation(async () => stored) secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { stored = value diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index 87f6f3826f4..a08fa052459 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -1,8 +1,12 @@ -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) const ResumeCredentialSchema = z @@ -38,10 +42,6 @@ export const MobileRelayCredentialBundleSchema = z export type MobileRelayCredentialBundle = z.infer -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function credentialKey(hostId: string): string { return `orca.mobile-relay.credentials.${hostId}` } @@ -74,7 +74,7 @@ export async function readMobileRelayCredentialBundle( hostId: string ): Promise { requireNativeSecretStore() - const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + const raw = await readPairingKeychainItem(credentialKey(hostId)) if (raw === null) { return null } @@ -91,18 +91,14 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) - await SecureStore.setItemAsync( - credentialKey(validated.hostId), - JSON.stringify(validated), - KEYCHAIN_OPTIONS - ) + await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated)) } export async function deleteMobileRelayCredentialBundle(hostId: string): Promise { if (Platform.OS === 'web') { return } - await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(credentialKey(hostId)) } function requireNativeSecretStore(): void { diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 19775270b45..2493c9e78af 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -1,7 +1,11 @@ -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) @@ -17,10 +21,6 @@ export const MobileRelayDirectUpgradeJournalSchema = z export type MobileRelayDirectUpgradeJournal = z.infer -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function journalKey(hostId: string): string { return `orca.mobile-relay.direct-upgrade.${hostId}` } @@ -43,7 +43,7 @@ export async function readMobileRelayDirectUpgradeJournal( hostId: string ): Promise { requireNativeSecretStore() - const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + const raw = await readPairingKeychainItem(journalKey(hostId)) if (!raw) { return null } @@ -60,18 +60,14 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) - await SecureStore.setItemAsync( - journalKey(parsed.hostId), - JSON.stringify(parsed), - KEYCHAIN_OPTIONS - ) + await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed)) } export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise { if (Platform.OS === 'web') { return } - await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(journalKey(hostId)) } function encodeBase64Url(value: Uint8Array): string { diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts index 5790eb456aa..cf249a90506 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -31,6 +31,8 @@ import { import type { PairingOffer } from './types' const now = Date.UTC(2026, 6, 13) +const GENERATION_KEY = 'orca:pairing-keychain-generation' +const JOURNAL_PRESENCE_KEY = 'orca:pairing-keychain-presence:orca.mobile-relay.pairing-journal.v1' const offer = { v: 2, endpoint: 'ws://192.168.1.10:6768', @@ -51,6 +53,8 @@ const offer = { describe('mobile relay pairing journal store', () => { let metadataRaw: string | null let secretRaw: string | null + let generationRaw: string | null + let presenceRaw: string | null beforeEach(() => { vi.clearAllMocks() @@ -58,12 +62,32 @@ describe('mobile relay pairing journal store', () => { platform.OS = 'ios' metadataRaw = null secretRaw = null - asyncStorage.getItem.mockImplementation(async () => metadataRaw) - asyncStorage.setItem.mockImplementation(async (_key: string, value: string) => { - metadataRaw = value + generationRaw = null + presenceRaw = null + asyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + return generationRaw + } + if (key === JOURNAL_PRESENCE_KEY) { + return presenceRaw + } + return metadataRaw }) - asyncStorage.removeItem.mockImplementation(async () => { - metadataRaw = null + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + if (key === GENERATION_KEY) { + generationRaw = value + } else if (key === JOURNAL_PRESENCE_KEY) { + presenceRaw = value + } else { + metadataRaw = value + } + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + if (key === JOURNAL_PRESENCE_KEY) { + presenceRaw = null + } else { + metadataRaw = null + } }) secureStore.getItemAsync.mockImplementation(async () => secretRaw) secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { @@ -94,6 +118,36 @@ describe('mobile relay pairing journal store', () => { await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) }) + it('retries the journal under a distinct alias before relay pairing connects (#6600)', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + now, + randomBytes: (length) => new Uint8Array(length).fill(length) + }) + secureStore.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw new Error( + "Could not encrypt the value for key 'orca.mobile-relay.pairing-journal.v1' under keychain 'key_v1'. Caused by: unknown" + ) + } + secretRaw = value + } + ) + platform.OS = 'android' + + await expect(saveMobileRelayPairingJournal(journal)).resolves.toBeUndefined() + + expect(generationRaw).toBe('1') + expect(secureStore.setItemAsync).toHaveBeenLastCalledWith( + 'orca.mobile-relay.pairing-journal.v1', + expect.any(String), + expect.objectContaining({ keychainService: 'orca.pairing.v1' }) + ) + }) + it('records a provisional winner only for the active journal identity', async () => { const journal = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, @@ -223,7 +277,7 @@ describe('mobile relay pairing journal store', () => { hostName: 'Red Panda', randomBytes: (length) => new Uint8Array(length).fill(12) }) - secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable')) + secureStore.setItemAsync.mockRejectedValue(new Error('keychain unavailable')) await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/) await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts index b7044830da5..d02ebc72284 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { MobileRelayPairingJournalMetadataSchema, @@ -7,12 +6,15 @@ import { type MobileRelayPairingJournal, type MobileRelayPairingJournalMetadata } from './mobile-relay-pairing-journal' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1' const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1' -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} let journalMutation: Promise = Promise.resolve() export async function saveMobileRelayPairingJournal( @@ -39,7 +41,7 @@ export async function saveMobileRelayPairingJournal( // Why: metadata-first makes a crash before the keychain write recover as // an incomplete journal, never as an untracked bearer secret. await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata)) - await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS) + await writePairingKeychainItem(JOURNAL_SECRET_KEY, JSON.stringify(secrets)) }) journalMutation = mutation.catch(() => {}) return mutation @@ -50,7 +52,7 @@ export async function loadMobileRelayPairingJournal(): Promise { const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) if (rawMetadata === null) { - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {}) return null } const metadata = parseMetadata(rawMetadata) @@ -58,7 +60,7 @@ export async function loadMobileRelayPairingJournal(): Promise { // Why: metadata is the discoverable cleanup pointer; remove it before the // native secret so a second crash can only leave a self-cleaning orphan. await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {}) } export async function updateMobileRelayPairingJournal( @@ -114,7 +116,7 @@ export async function clearMobileRelayPairingJournal(journalId: string): Promise throw new Error('stale mobile relay pairing journal') } await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY) }) journalMutation = mutation.catch(() => {}) return mutation @@ -147,4 +149,5 @@ function requireNativeSecretStore(): void { /** Test-only: drain the module mutation chain between cases. */ export function resetMobileRelayPairingJournalStoreForTests(): void { journalMutation = Promise.resolve() + resetPairingKeychainForTests() } diff --git a/mobile/src/transport/pairing-keychain.test.ts b/mobile/src/transport/pairing-keychain.test.ts new file mode 100644 index 00000000000..fda46d0a328 --- /dev/null +++ b/mobile/src/transport/pairing-keychain.test.ts @@ -0,0 +1,403 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +const secureStoreMock = vi.hoisted(() => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn() +})) +const platformMock = vi.hoisted(() => ({ OS: 'android' })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock })) + +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStoreMock +})) +vi.mock('react-native', () => ({ Platform: platformMock })) + +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' + +const GENERATION_KEY = 'orca:pairing-keychain-generation' +const TOKEN_KEY = 'orca.host-token.host-1782629088232' +const TOKEN_PRESENCE_KEY = `orca:pairing-keychain-presence:${TOKEN_KEY}` + +// Why: the exact Android failure from #6600 — expo maps a null-message GeneralSecurityException to this. +const ENCRYPT_REJECTION = new Error( + `Could not encrypt the value for key '${TOKEN_KEY}' under keychain 'key_v1'. Caused by: unknown` +) + +type Options = { keychainService?: string } | undefined + +function serviceOf(options: Options): string | undefined { + return options?.keychainService +} + +describe('pairing keychain', () => { + let generationRecord: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetPairingKeychainForTests() + platformMock.OS = 'android' + generationRecord = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => + key === GENERATION_KEY ? generationRecord : null + ) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + } + }) + secureStoreMock.setItemAsync.mockResolvedValue(undefined) + secureStoreMock.deleteItemAsync.mockResolvedValue(undefined) + secureStoreMock.getItemAsync.mockResolvedValue(null) + }) + + it('writes under the default keychain service so existing installs keep their tokens', async () => { + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + const [key, value, options] = secureStoreMock.setItemAsync.mock.calls[0]! + expect(key).toBe(TOKEN_KEY) + expect(value).toBe('token') + // Why: passing any keychainService would change the keystore alias and orphan every already-stored token. + expect(serviceOf(options as Options)).toBeUndefined() + expect(generationRecord).toBeNull() + }) + + it('recovers when the reported Android encryption failure is alias-local', async () => { + // Why: simulate the unverified alias-local case; no affected physical device was available. + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + const rotated = secureStoreMock.setItemAsync.mock.calls.at(-1)! + expect(serviceOf(rotated[2] as Options)).toBe('orca.pairing.v1') + expect(rotated[1]).toBe('token') + expect(generationRecord).toBe('1') + }) + + it('surfaces the original error when a rotated write also fails', async () => { + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + expect(generationRecord).toBe('1:pending') + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('retries an unconfirmed alias without consuming another generation', async () => { + generationRecord = '1:pending' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBe('1:pending') + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe( + 'orca.pairing.v1' + ) + }) + + it('confirms an unconfirmed alias after its first successful write', async () => { + generationRecord = '1:pending' + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(generationRecord).toBe('1') + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe( + 'orca.pairing.v1' + ) + }) + + it('does not rotate on an iOS keychain failure', async () => { + platformMock.OS = 'ios' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBeNull() + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + }) + + it('never stores a token under a generation it could not durably record', async () => { + // Why: reads only walk back from the recorded generation, so a token written under an + // unrecorded one is silently unreachable after a relaunch and the host vanishes. + asyncStorageMock.setItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + throw new Error('storage full') + } + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + const rotatedWrites = secureStoreMock.setItemAsync.mock.calls.filter( + (call) => serviceOf(call[2] as Options) !== undefined + ) + expect(rotatedWrites).toHaveLength(0) + expect(generationRecord).toBeNull() + }) + + it('records the rotation before storing the token so a relaunch can still find it', async () => { + const order: string[] = [] + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + order.push(`record:${raw}`) + } + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + const service = serviceOf(options) + if (service === undefined) { + throw ENCRYPT_REJECTION + } + order.push(`store:${service}`) + } + ) + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(order).toEqual(['record:1:pending', 'store:orca.pairing.v1', 'record:1']) + }) + + it('keeps a successful rotated write reachable when confirmation storage fails', async () => { + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key !== GENERATION_KEY) { + return + } + if (raw === '1') { + throw new Error('storage unavailable') + } + generationRecord = raw + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBe('1:pending') + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v1' ? 'token' : null + ) + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('token') + }) + + it('reads through the rotated service once a rotation has been committed', async () => { + generationRecord = '1' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v1' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + }) + + it('falls back to a retired service so rotation does not orphan a still-readable token', async () => { + generationRecord = '2' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token') + // Why: probes must walk v2 -> v1 -> default rather than stopping at the current generation. + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(3) + }) + + it('does not return a stale older value when the current alias throws', async () => { + generationRecord = '1' + const currentError = new Error('Could not decrypt the value') + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => { + if (serviceOf(options) === 'orca.pairing.v1') { + throw currentError + } + return 'legacy-token' + }) + + await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toBe(currentError) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1) + }) + + it('does not return a stale older value when a recorded current item decrypts as null', async () => { + generationRecord = '1' + let presenceRecord: string | null = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + return generationRecord + } + return key === TOKEN_PRESENCE_KEY ? presenceRecord : null + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + } + if (key === TOKEN_PRESENCE_KEY) { + presenceRecord = raw + } + }) + + await writePairingKeychainItem(TOKEN_KEY, 'rotated-token') + expect(presenceRecord).toBe('1') + expect(asyncStorageMock.setItem.mock.invocationCallOrder[0]).toBeLessThan( + secureStoreMock.setItemAsync.mock.invocationCallOrder[0]! + ) + + resetPairingKeychainForTests() + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toThrow(/recorded generation/) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1) + }) + + it('reads an older value while a newly recorded alias has no item yet', async () => { + generationRecord = '1:pending' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token') + }) + + it('probes every bounded service when the generation record is temporarily unreadable', async () => { + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + throw new Error('storage unavailable') + } + return null + }) + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(7) + }) + + it('does not rotate from a guessed generation when the record is unreadable', async () => { + const storageError = new Error('storage unavailable') + asyncStorageMock.getItem.mockRejectedValueOnce(storageError) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(storageError) + + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + + it('deletes the token under every generation so rotation cannot strand a live credential', async () => { + generationRecord = '2' + + await deletePairingKeychainItem(TOKEN_KEY) + + const services = secureStoreMock.deleteItemAsync.mock.calls.map((call) => + serviceOf(call[1] as Options) + ) + expect(services).toEqual(['orca.pairing.v2', 'orca.pairing.v1', undefined]) + expect(asyncStorageMock.removeItem).toHaveBeenCalledWith(TOKEN_PRESENCE_KEY) + }) + + it('reports a partial delete failure after attempting every generation', async () => { + generationRecord = '2' + const deleteError = new Error('delete failed') + secureStoreMock.deleteItemAsync.mockImplementation(async (_k: string, options: Options) => { + if (serviceOf(options) === 'orca.pairing.v2') { + throw deleteError + } + }) + + await expect(deletePairingKeychainItem(TOKEN_KEY)).rejects.toBe(deleteError) + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(3) + expect(asyncStorageMock.removeItem).not.toHaveBeenCalled() + }) + + it('serializes writes that share the global generation', async () => { + let releaseFirst!: () => void + const firstWrite = new Promise((resolve) => { + releaseFirst = resolve + }) + secureStoreMock.setItemAsync.mockImplementationOnce(async () => firstWrite) + + const first = writePairingKeychainItem(TOKEN_KEY, 'first') + const second = writePairingKeychainItem(`${TOKEN_KEY}.second`, 'second') + await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1)) + + releaseFirst() + await Promise.all([first, second]) + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(2) + }) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'probes rotated services when generation record %j is malformed', + async (raw) => { + generationRecord = raw + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + } + ) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'refuses to write from malformed generation record %j', + async (raw) => { + generationRecord = raw + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toThrow( + /generation record is invalid/ + ) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + } + ) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'deletes every bounded service when generation record %j is malformed', + async (raw) => { + generationRecord = raw + + await deletePairingKeychainItem(TOKEN_KEY) + + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(9) + } + ) + + it('stops rotating at the generation cap rather than probing unbounded services', async () => { + generationRecord = '8' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(generationRecord).toBe('8') + }) +}) diff --git a/mobile/src/transport/pairing-keychain.ts b/mobile/src/transport/pairing-keychain.ts new file mode 100644 index 00000000000..bf3b1810807 --- /dev/null +++ b/mobile/src/transport/pairing-keychain.ts @@ -0,0 +1,257 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' + +// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps pairing credentials off iCloud Keychain and backup restores. +const BASE_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +const GENERATION_STORAGE_KEY = 'orca:pairing-keychain-generation' +const PRESENCE_STORAGE_PREFIX = 'orca:pairing-keychain-presence:' +const SERVICE_PREFIX = 'orca.pairing.v' +// Why: every generation adds one read probe per miss; bound pathological recovery cost. +const MAX_GENERATION = 8 + +/** + * Why: expo-secure-store shares one Android keystore alias across every + * unauthenticated item under a keychain service. The #6600 rejection proves + * encryption reached expo's null-message GeneralSecurityException branch; a + * fresh service selects a distinct alias and can recover an alias-local fault. + */ +function serviceForGeneration(generation: number): string | undefined { + // Why: generation 0 must keep expo's default service so existing credentials stay readable. + return generation <= 0 ? undefined : `${SERVICE_PREFIX}${generation}` +} + +function optionsForGeneration(generation: number): SecureStore.SecureStoreOptions { + const keychainService = serviceForGeneration(generation) + return keychainService ? { ...BASE_OPTIONS, keychainService } : BASE_OPTIONS +} + +type LoadedGeneration = + | { generation: number; pending: boolean; reliable: true } + | { generation: 0; pending: false; reliable: false; error: unknown } + +type GenerationState = { generation: number; pending: boolean } +type PresenceChange = { storageKey: string; previousRaw: string | null } + +let cachedGeneration: GenerationState | null = null +let keychainMutation: Promise = Promise.resolve() + +function parseGeneration(raw: string | null): LoadedGeneration { + if (raw === null) { + return { generation: 0, pending: false, reliable: true } + } + const pending = raw.endsWith(':pending') + const generationRaw = pending ? raw.slice(0, -':pending'.length) : raw + const parsed = Number(generationRaw) + if ( + !Number.isInteger(parsed) || + parsed < 0 || + parsed > MAX_GENERATION || + (pending && parsed === 0) || + String(parsed) !== generationRaw + ) { + return { + generation: 0, + pending: false, + reliable: false, + error: new Error('pairing keychain generation record is invalid') + } + } + return { generation: parsed, pending, reliable: true } +} + +function parsePresenceGeneration(raw: string | null): number | null { + if (raw === null) { + return null + } + const parsed = Number(raw) + if ( + !Number.isInteger(parsed) || + parsed < 0 || + parsed > MAX_GENERATION || + String(parsed) !== raw + ) { + throw new Error('pairing keychain presence record is invalid') + } + return parsed +} + +function presenceStorageKey(key: string): string { + return `${PRESENCE_STORAGE_PREFIX}${key}` +} + +async function loadPresenceGeneration(key: string): Promise { + if (Platform.OS !== 'android') { + return null + } + return parsePresenceGeneration(await AsyncStorage.getItem(presenceStorageKey(key))) +} + +async function loadGeneration(): Promise { + if (cachedGeneration !== null) { + return { ...cachedGeneration, reliable: true } + } + try { + const loaded = parseGeneration(await AsyncStorage.getItem(GENERATION_STORAGE_KEY)) + if (loaded.reliable) { + cachedGeneration = { generation: loaded.generation, pending: loaded.pending } + } + return loaded + } catch (error) { + // Why: don't cache or rotate from a guess; a later read may recover the durable pointer. + return { generation: 0, pending: false, reliable: false, error } + } +} + +// Why: reads only walk back from the recorded generation, so persist before writing under it. +async function commitGeneration(generation: number, pending: boolean): Promise { + const raw = pending ? `${generation}:pending` : String(generation) + await AsyncStorage.setItem(GENERATION_STORAGE_KEY, raw) + cachedGeneration = { generation, pending } +} + +function enqueueKeychainMutation(operation: () => Promise): Promise { + const mutation = keychainMutation.then(operation) + keychainMutation = mutation.catch(() => {}) + return mutation +} + +async function preparePresenceWrite( + key: string, + generation: number +): Promise { + if (Platform.OS !== 'android') { + return null + } + // Why: Expo Android returns null for both absent and undecryptable entries. + const storageKey = presenceStorageKey(key) + const previousRaw = await AsyncStorage.getItem(storageKey) + parsePresenceGeneration(previousRaw) + await AsyncStorage.setItem(storageKey, String(generation)) + return { storageKey, previousRaw } +} + +async function restorePresence(change: PresenceChange | null): Promise { + if (!change) { + return + } + if (change.previousRaw === null) { + await AsyncStorage.removeItem(change.storageKey) + return + } + await AsyncStorage.setItem(change.storageKey, change.previousRaw) +} + +async function setItemAtGeneration(key: string, value: string, generation: number): Promise { + const presenceChange = await preparePresenceWrite(key, generation) + try { + await SecureStore.setItemAsync(key, value, optionsForGeneration(generation)) + } catch (error) { + // Why: a failed rollback leaves reads fail-closed at the attempted generation. + await restorePresence(presenceChange).catch(() => {}) + throw error + } +} + +export async function readPairingKeychainItem(key: string): Promise { + await keychainMutation + const presenceGeneration = await loadPresenceGeneration(key) + if (presenceGeneration !== null) { + const value = await SecureStore.getItemAsync(key, optionsForGeneration(presenceGeneration)) + if (value === null) { + throw new Error('pairing keychain item is unavailable at its recorded generation') + } + return value + } + const loaded = await loadGeneration() + const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION + for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) { + const value = await SecureStore.getItemAsync(key, optionsForGeneration(candidate)) + if (value !== null) { + return value + } + } + return null +} + +async function writePairingKeychainItemImpl(key: string, value: string): Promise { + const loaded = await loadGeneration() + if (!loaded.reliable) { + throw loaded.error + } + const generation = loaded.generation + let firstError: unknown + try { + await setItemAtGeneration(key, value, generation) + if (loaded.pending) { + await commitGeneration(generation, false) + } + return + } catch (error) { + firstError = error + } + + // Why: retry an unconfirmed alias instead of exhausting generations on a device-wide failure. + if (loaded.pending || !isAndroidEncryptionFailure(firstError)) { + throw firstError + } + const rotated = generation + 1 + if (rotated > MAX_GENERATION) { + throw firstError + } + try { + await commitGeneration(rotated, true) + } catch { + throw firstError + } + try { + await setItemAtGeneration(key, value, rotated) + await commitGeneration(rotated, false) + } catch { + throw firstError + } +} + +function isAndroidEncryptionFailure(error: unknown): boolean { + if (Platform.OS !== 'android' || !error || typeof error !== 'object') { + return false + } + const message = 'message' in error ? error.message : null + return typeof message === 'string' && message.includes('Could not encrypt the value for key') +} + +export function writePairingKeychainItem(key: string, value: string): Promise { + return enqueueKeychainMutation(() => writePairingKeychainItemImpl(key, value)) +} + +async function deletePairingKeychainItemImpl(key: string): Promise { + const loaded = await loadGeneration() + const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION + let firstError: unknown + for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) { + try { + await SecureStore.deleteItemAsync(key, optionsForGeneration(candidate)) + } catch (error) { + firstError ??= error + } + } + if (firstError !== undefined) { + throw firstError + } + if (Platform.OS === 'android') { + await AsyncStorage.removeItem(presenceStorageKey(key)) + } +} + +export function deletePairingKeychainItem(key: string): Promise { + return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key)) +} + +/** Test-only: drop cached generation and mutation state between cases. */ +export function resetPairingKeychainForTests(): void { + cachedGeneration = null + keychainMutation = Promise.resolve() +} diff --git a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts new file mode 100644 index 00000000000..395f13688db --- /dev/null +++ b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + sent: string[] = [] + close = vi.fn(() => { + if (this.readyState === MockWebSocket.CLOSED) { + return + } + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + }) + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + this.receive(JSON.stringify({ type: 'e2ee_ready' })) + this.receive('encrypted:{"type":"e2ee_authenticated"}') + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function track(request: Promise): { read: () => string } { + let outcome = 'pending' + request.then( + () => { + outcome = 'resolved' + }, + (error: Error) => { + outcome = error.message + } + ) + return { read: () => outcome } +} + +function terminalSendFrames(socket: MockWebSocket): string[] { + return socket.sent.filter((frame) => frame.includes('terminal.send')) +} + +describe('mobile rpc-client connect-wait replay', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('Given a cut connection When a send opts into failWhenDisconnected Then it rejects now and nothing replays after reconnect', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + const request = client.sendRequest( + 'terminal.send', + { terminal: 't1', text: 'YZZY' }, + { failWhenDisconnected: true } + ) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(0) + expect(outcome.read()).toBe('Not connected: terminal.send') + + // Reconnect; the rejected keystroke must not ride the new socket. + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + expect(terminalSendFrames(mockSockets[1]!)).toEqual([]) + } finally { + client.close() + await request.catch(() => undefined) + } + }) + + it('Given a cut connection When a caller does not opt in Then the send parks and is delivered on the next socket', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + // Pins the default behavior that motivates the opt-out: parked requests + // replay after reconnect, which is what corrupted post-recovery input. + const request = client.sendRequest('terminal.send', { terminal: 't1', text: 'YZZY' }) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(0) + expect(outcome.read()).toBe('pending') + + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + expect(terminalSendFrames(mockSockets[1]!)).toHaveLength(1) + } finally { + client.close() + await request.catch(() => undefined) + } + }) + + it('rejects requests waiting for reconnect after the retry cap', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + const waitingRequestError = client.sendRequest('status.get').then( + () => null, + (error: Error) => error + ) + await vi.advanceTimersByTimeAsync(520_000) + + expect(client.getState()).toBe('reconnecting') + expect(client.getReconnectAttempt()).toBe(12) + await expect(waitingRequestError).resolves.toMatchObject({ + message: 'Connection retry limit reached' + }) + await expect(client.sendRequest('status.get')).rejects.toThrow('Connection retry limit reached') + + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client-unauthorized-close.test.ts b/mobile/src/transport/rpc-client-unauthorized-close.test.ts index 5fb4ab18e40..464d0b95808 100644 --- a/mobile/src/transport/rpc-client-unauthorized-close.test.ts +++ b/mobile/src/transport/rpc-client-unauthorized-close.test.ts @@ -89,9 +89,10 @@ describe('unauthorized close-code mapping (silent 4001)', () => { // A desktop with a regenerated keypair can't send a decryptable e2ee_error — // the phone only ever sees the 4001 close. Three of those must latch. + // Why 1_000: failed handshakes grow the backoff since issue #10119. for (let i = 0; i < 3; i++) { if (i > 0) { - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(1_000) } const socket = lastSocket() socket.open() @@ -130,9 +131,10 @@ describe('unauthorized close-code mapping (silent 4001)', () => { it('shares one budget between decrypted e2ee_error rejections and 4001 closes', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') + // Why 1_000: failed handshakes grow the backoff since issue #10119. for (let i = 0; i < 3; i++) { if (i > 0) { - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(1_000) } const socket = lastSocket() socket.open() diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 5f79f1be698..e61e7272a65 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -712,6 +712,47 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) + it('reconnects when the half-open socket omits its close callback', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + openAndAuthenticate(socket) + socket.emitCloseOnClose = false + + client.notifyForeground() + await vi.advanceTimersByTimeAsync(8_000) + + expect(socket.close).toHaveBeenCalledTimes(1) + expect(client.getState()).toBe('reconnecting') + socket.onclose?.() + expect(client.getState()).toBe('reconnecting') + + await vi.advanceTimersByTimeAsync(500) + expect(mockSockets).toHaveLength(2) + openAndAuthenticate(mockSockets[1]!) + expect(client.getState()).toBe('connected') + expect(client.getReconnectAttempt()).toBe(0) + + await vi.advanceTimersByTimeAsync(1_000) + expect(mockSockets).toHaveLength(2) + + client.close() + }) + + it('coalesces repeated foreground probes while one probe is pending', async () => { + const client = connectAuthenticated().client + const socket = mockSockets[0]! + client.notifyForeground() + client.notifyForeground() + client.notifyForeground() + expect(sentRequests(socket, 'status.get')).toHaveLength(1) + await vi.advanceTimersByTimeAsync(8_000) + expect(socket.close).toHaveBeenCalledTimes(1) + expect(client.getState()).toBe('reconnecting') + + await vi.advanceTimersByTimeAsync(500) + expect(mockSockets).toHaveLength(2) + client.close() + }) it('keeps a healthy connection when the foreground probe is answered', async () => { const { client, socket } = connectAuthenticated() @@ -894,9 +935,11 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') // Three consecutive handshake rejections (AUTH_RETRY_BUDGET = 3). + // Why 1_000: failed handshakes grow the backoff since issue #10119 — + // cycle 2 waits RECONNECT_DELAYS[1]. for (let i = 0; i < 3; i++) { if (i > 0) { - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(1_000) } const socket = mockSockets[mockSockets.length - 1]! socket.open() @@ -913,16 +956,17 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') // Two rejections, then a clean connect resets the budget... + // Why 1_000: failed handshakes grow the backoff since issue #10119. for (let i = 0; i < 2; i++) { if (i > 0) { - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(1_000) } const socket = mockSockets[mockSockets.length - 1]! socket.open() socket.receive(JSON.stringify({ type: 'e2ee_ready' })) socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}') } - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(1_000) authenticate(mockSockets[mockSockets.length - 1]!) expect(client.getState()).toBe('connected') @@ -940,29 +984,4 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) }) - - it('rejects requests waiting for reconnect after the retry cap', async () => { - const client = connect('ws://desktop.invalid', 'token', 'server-key') - const socket = mockSockets[0]! - - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') - socket.close() - - const waitingRequestError = client.sendRequest('status.get').then( - () => null, - (error: Error) => error - ) - await vi.advanceTimersByTimeAsync(520_000) - - expect(client.getState()).toBe('reconnecting') - expect(client.getReconnectAttempt()).toBe(12) - await expect(waitingRequestError).resolves.toMatchObject({ - message: 'Connection retry limit reached' - }) - await expect(client.sendRequest('status.get')).rejects.toThrow('Connection retry limit reached') - - client.close() - }) }) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index f0302141131..8331000169e 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -54,6 +54,9 @@ export type SendRequestOptions = { * against the post-connect clock, and squeezing them to the floor after a slow * reconnect would fail sends that used to land. */ budgetSpansConnect?: boolean + /** Reject immediately when not connected — a send parked in the connect wait + * replays stale terminal bytes into the PTY after reconnect. */ + failWhenDisconnected?: boolean } type SubscribeOptions = { @@ -89,7 +92,7 @@ export type RpcClient = { viewport: { cols: number; rows: number } ) => void getState: () => ConnectionState - // 0 means never failed (reset on successful open); the UI escalates "Reconnecting…" to "Can't connect" past a threshold. + // 0 means never failed (reset once the handshake authenticates); the UI escalates "Reconnecting…" to "Can't connect" past a threshold. getReconnectAttempt: () => number // Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip". getLastConnectedAt: () => number | null @@ -138,8 +141,7 @@ export function connect( typeof optionsOrLegacy === 'function' ? { onStateChange: optionsOrLegacy } : (optionsOrLegacy ?? {}) - const onStateChange = options.onStateChange - const onLog = options.onLog + const { onStateChange, onLog } = options let logCounter = 0 function emitLog(level: ConnectionLogLevel, message: string, detail?: string) { if (!onLog) { @@ -161,6 +163,7 @@ export function connect( let connectTimer: ReturnType | null = null let handshakeTimer: ReturnType | null = null let activityProbeTimer: ReturnType | null = null + let activityProbeInFlight = false let intentionallyClosed = false // Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair. let authRejectionCount = 0 @@ -220,6 +223,8 @@ export function connect( }) if (next === 'connected') { lastConnectedAt = Date.now() + // Why: only a completed E2EE handshake proves the path is healthy (issue #10119). + reconnectAttempt = 0 // Why: a clean handshake proves the token is valid — reset the auth retry budget. authRejectionCount = 0 for (const waiter of connectWaiters.splice(0)) { @@ -348,7 +353,9 @@ export function connect( } console.log('[net] ws.onopen', { attempt: reconnectAttempt }) clearConnectTimer() - reconnectAttempt = 0 + // Why: no reconnectAttempt reset here — an open socket isn't a healthy session + // until e2ee_authenticated. Resetting pre-handshake pinned the counter at 0↔1, + // so a handshake-stall loop never escalated past "Connecting…" (issue #10119). setState('handshaking') emitLog('success', 'WebSocket open', 'Starting E2EE handshake') @@ -766,15 +773,17 @@ export function connect( // Why: stream frames can flow while control RPC is wedged; only a control response satisfies this probe. function runActivityProbe() { - if (state !== 'connected' || !ws) { + if (state !== 'connected' || !ws || activityProbeInFlight) { return } + activityProbeInFlight = true const probeWs = ws const id = nextId() const probeControlResponseSequence = controlResponseSequence let timedOut = false const timeout = setTimeout(() => { timedOut = true + activityProbeInFlight = false pending.delete(id) if (controlResponseSequence > probeControlResponseSequence) { return @@ -787,23 +796,26 @@ export function connect( if (timedOut) { return } + activityProbeInFlight = false clearTimeout(timeout) }, reject: () => { if (timedOut) { return } + activityProbeInFlight = false clearTimeout(timeout) } }) if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) { + activityProbeInFlight = false clearTimeout(timeout) pending.delete(id) } } - function forceSocketReconnect(socket: WebSocket): void { - if (socket !== ws) { + function forceSocketReconnect(socket: WebSocket | null): void { + if (!socket || socket !== ws) { return } if (socket.readyState === WebSocket.OPEN) { @@ -1005,6 +1017,9 @@ export function connect( const budget = openRpcRequestBudget(options) const waitStart = budget.startedAt const wasConnected = state === 'connected' + if (options?.failWhenDisconnected && !wasConnected) { + throw new Error(`Not connected: ${method}`) + } await waitForConnected(options?.timeoutMs) if (!wasConnected) { console.log('[net] sendRequest waited for connect', { @@ -1026,9 +1041,7 @@ export function connect( }) // Why: the frame was written 30s ago — the host may have processed it. reject(markRpcDeliveryUnknown(new Error(`Request timed out: ${method}`))) - if (requestWs) { - forceSocketReconnect(requestWs) - } + forceSocketReconnect(requestWs) }, timeoutMs) pending.set(id, { diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 8edeb06131b..50ecae7d024 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -2579,9 +2579,12 @@ private enum KeyMap { } private final class AgentRuntime: NSObject, NSApplicationDelegate { + private static let unclaimedSessionDeadline: TimeInterval = 30 + private let socketPath: String private let token: String? private var listener: SocketListener? + private var unclaimedSessionTimeout: DispatchWorkItem? init(socketPath: String, token: String?) { self.socketPath = socketPath @@ -2590,9 +2593,29 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { do { - let listener = try SocketListener(socketPath: socketPath, token: token) + let timeout = DispatchWorkItem { + fputs("computer-use agent received no authenticated session before its deadline\n", stderr) + NSApp.terminate(nil) + } + unclaimedSessionTimeout = timeout + let listener = try SocketListener( + socketPath: socketPath, + token: token, + onSessionClaimed: { + timeout.cancel() + }, + onSessionClosed: { + DispatchQueue.main.async { + NSApp.terminate(nil) + } + } + ) self.listener = listener listener.start() + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.unclaimedSessionDeadline, + execute: timeout + ) } catch { fputs("failed to start computer-use socket: \(error)\n", stderr) NSApp.terminate(nil) @@ -2600,6 +2623,8 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + unclaimedSessionTimeout?.cancel() + unclaimedSessionTimeout = nil listener?.stop() } } @@ -3505,14 +3530,26 @@ private final class ButtonTarget: NSObject { private final class SocketListener: @unchecked Sendable { private let socketPath: String private let token: String? + private let onSessionClaimed: () -> Void + private let onSessionClosed: () -> Void private let provider = Provider() private let providerLock = NSLock() + private let sessionLock = NSLock() + private var sessionOwnership = AgentSessionOwnership() + private var lastConnectionID: UInt64 = 0 private var socketFd: Int32 = -1 private var isStopped = false - init(socketPath: String, token: String?) throws { + init( + socketPath: String, + token: String?, + onSessionClaimed: @escaping () -> Void, + onSessionClosed: @escaping () -> Void + ) throws { self.socketPath = socketPath self.token = token + self.onSessionClaimed = onSessionClaimed + self.onSessionClosed = onSessionClosed try bindSocket() } @@ -3587,14 +3624,35 @@ private final class SocketListener: @unchecked Sendable { } continue } + guard let connectionID = allocateConnectionID() else { + fputs("computer-use socket exhausted connection identities\n", stderr) + close(fd) + continue + } Thread.detachNewThread { [weak self] in - self?.handleConnection(fd) + self?.handleConnection(fd, connectionID: connectionID) } } } - private func handleConnection(_ fd: Int32) { - defer { close(fd) } + private func allocateConnectionID() -> AgentSessionConnectionID? { + sessionLock.lock() + defer { sessionLock.unlock() } + guard lastConnectionID < UInt64.max else { return nil } + lastConnectionID += 1 + return AgentSessionConnectionID(rawValue: lastConnectionID) + } + + private func handleConnection(_ fd: Int32, connectionID: AgentSessionConnectionID) { + var registeredSession = false + var hangupMonitor: AuthenticatedConnectionHangupMonitor? + defer { + hangupMonitor?.cancel() + if registeredSession { + disconnectSession(connectionID) + } + close(fd) + } let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true let decoder = JSONDecoder() while let line = readLine(from: fd) { @@ -3603,6 +3661,40 @@ private final class SocketListener: @unchecked Sendable { else { continue } + if !registeredSession && isAuthenticatedAgentSession( + expectedToken: token, + requestToken: request.token, + authorizedPeer: authorizedPeer + ) { + let monitor: AuthenticatedConnectionHangupMonitor + do { + monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: fd, + onHangup: { [weak self] in + self?.disconnectSession(connectionID) + } + ) + } catch { + fputs("computer-use owner monitor failed: \(error)\n", stderr) + return + } + sessionLock.lock() + let registration = sessionOwnership.registerConnection( + connectionID, + authenticated: true + ) + sessionLock.unlock() + guard registration != .rejected else { + monitor.cancel() + return + } + registeredSession = true + hangupMonitor = monitor + monitor.start() + if registration == .claimed { + onSessionClaimed() + } + } let response = handleRequest( provider: provider, lock: providerLock, @@ -3613,6 +3705,15 @@ private final class SocketListener: @unchecked Sendable { writeJSON(response, to: fd) } } + + private func disconnectSession(_ connectionID: AgentSessionConnectionID) { + sessionLock.lock() + let shouldTerminate = sessionOwnership.disconnect(connectionID) + sessionLock.unlock() + if shouldTerminate { + onSessionClosed() + } + } } private func existingPathMode(_ path: String) -> mode_t? { diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentSessionOwnership.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentSessionOwnership.swift new file mode 100644 index 00000000000..d39543e9895 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentSessionOwnership.swift @@ -0,0 +1,50 @@ +public struct AgentSessionConnectionID: Hashable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } +} + +public enum AgentSessionRegistration: Sendable { + case rejected + case claimed + case retained +} + +public struct AgentSessionOwnership: Sendable { + private var authenticatedConnections: Set = [] + private var wasClaimed = false + private var sessionClosed = false + + public init() {} + + public mutating func registerConnection( + _ connection: AgentSessionConnectionID, + authenticated: Bool + ) -> AgentSessionRegistration { + guard authenticated, !sessionClosed else { return .rejected } + let inserted = authenticatedConnections.insert(connection).inserted + guard inserted else { return .rejected } + guard !wasClaimed else { return .retained } + wasClaimed = true + return .claimed + } + + public mutating func disconnect(_ connection: AgentSessionConnectionID) -> Bool { + guard authenticatedConnections.remove(connection) != nil else { return false } + guard wasClaimed, authenticatedConnections.isEmpty else { return false } + sessionClosed = true + return true + } +} + +public func isAuthenticatedAgentSession( + expectedToken: String?, + requestToken: String?, + authorizedPeer: Bool +) -> Bool { + guard authorizedPeer else { return false } + guard let expectedToken else { return true } + return requestToken == expectedToken +} diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AuthenticatedConnectionHangupMonitor.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AuthenticatedConnectionHangupMonitor.swift new file mode 100644 index 00000000000..55e4ec91721 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AuthenticatedConnectionHangupMonitor.swift @@ -0,0 +1,182 @@ +import Darwin +import Dispatch +import Foundation + +public final class AuthenticatedConnectionHangupMonitor: @unchecked Sendable { + typealias RegisterEvents = @Sendable ( + Int32, + UnsafePointer?, + Int32 + ) -> POSIXErrorCode? + typealias CloseDescriptor = @Sendable (Int32) -> Void + + private static let cancelEventIdentifier: UInt = 1 + + private let closeDescriptor: CloseDescriptor + private let eventQueue: DispatchQueue + private let eventQueueDescriptor: Int32 + private let onEvent: @Sendable () -> Void + private let stateLock = NSLock() + private let onHangup: @Sendable () -> Void + private var isCancelled = false + private var didReportHangup = false + private var isFinished = false + private var isStarted = false + + public convenience init( + fileDescriptor: Int32, + queue: DispatchQueue = DispatchQueue( + label: "com.stablyai.orca.computer-use-owner-hangup" + ), + onEvent: @escaping @Sendable () -> Void = {}, + onHangup: @escaping @Sendable () -> Void + ) throws { + try self.init( + fileDescriptor: fileDescriptor, + queue: queue, + registerEvents: { descriptor, events, count in + guard kevent(descriptor, events, count, nil, 0, nil) == 0 else { + return POSIXErrorCode(rawValue: errno) ?? .EIO + } + return nil + }, + closeDescriptor: { descriptor in + close(descriptor) + }, + onEvent: onEvent, + onHangup: onHangup + ) + } + + init( + fileDescriptor: Int32, + queue: DispatchQueue = DispatchQueue( + label: "com.stablyai.orca.computer-use-owner-hangup" + ), + registerEvents: RegisterEvents, + closeDescriptor: @escaping CloseDescriptor, + onEvent: @escaping @Sendable () -> Void = {}, + onHangup: @escaping @Sendable () -> Void + ) throws { + self.closeDescriptor = closeDescriptor + eventQueue = queue + self.onEvent = onEvent + self.onHangup = onHangup + let descriptor = kqueue() + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard fileDescriptor >= 0 else { + closeDescriptor(descriptor) + throw POSIXError(.EBADF) + } + eventQueueDescriptor = descriptor + var registrations = [ + kevent( + ident: UInt(fileDescriptor), + filter: Int16(EVFILT_READ), + flags: UInt16(EV_ADD | EV_CLEAR), + fflags: UInt32(NOTE_LOWAT), + data: Int.max, + udata: nil + ), + kevent( + ident: Self.cancelEventIdentifier, + filter: Int16(EVFILT_USER), + flags: UInt16(EV_ADD | EV_CLEAR), + fflags: 0, + data: 0, + udata: nil + ) + ] + let registrationError = registrations.withUnsafeMutableBufferPointer { buffer in + registerEvents(descriptor, buffer.baseAddress, Int32(buffer.count)) + } + if let registrationError { + isFinished = true + closeDescriptor(descriptor) + throw POSIXError(registrationError) + } + } + + deinit { + cancel() + } + + public func start() { + stateLock.lock() + guard !isStarted, !isCancelled, !isFinished else { + stateLock.unlock() + return + } + isStarted = true + stateLock.unlock() + eventQueue.async { [self] in + waitForHangup() + } + } + + public func cancel() { + stateLock.lock() + guard !isFinished else { + stateLock.unlock() + return + } + isCancelled = true + guard isStarted else { + isFinished = true + closeDescriptor(eventQueueDescriptor) + stateLock.unlock() + return + } + var event = kevent( + ident: Self.cancelEventIdentifier, + filter: Int16(EVFILT_USER), + flags: 0, + fflags: UInt32(NOTE_TRIGGER), + data: 0, + udata: nil + ) + _ = kevent(eventQueueDescriptor, &event, 1, nil, 0, nil) + stateLock.unlock() + } + + private func waitForHangup() { + defer { finish() } + while true { + var event = kevent() + let result = kevent(eventQueueDescriptor, nil, 0, &event, 1, nil) + if result < 0 && errno == EINTR { + continue + } + guard result > 0 else { return } + onEvent() + if event.filter == Int16(EVFILT_USER) { + return + } + if event.flags & UInt16(EV_EOF | EV_ERROR) != 0 { + reportHangup() + return + } + } + } + + private func finish() { + stateLock.lock() + isFinished = true + closeDescriptor(eventQueueDescriptor) + stateLock.unlock() + } + + private func reportHangup() { + stateLock.lock() + guard !isCancelled, !didReportHangup else { + stateLock.unlock() + return + } + didReportHangup = true + stateLock.unlock() + + onHangup() + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentSessionOwnershipTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentSessionOwnershipTests.swift new file mode 100644 index 00000000000..8094501c919 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentSessionOwnershipTests.swift @@ -0,0 +1,104 @@ +import OrcaComputerUseMacOSCore +import XCTest + +final class AgentSessionOwnershipTests: XCTestCase { + func testUnclaimedDisconnectDoesNotTerminateAgent() { + var ownership = AgentSessionOwnership() + + XCTAssertFalse(ownership.disconnect(connection(12))) + } + + func testUnauthenticatedConnectionCannotClaimOrRetainAgent() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: false), .rejected) + XCTAssertFalse(ownership.disconnect(connection(12))) + } + + func testLastAuthenticatedDisconnectTerminatesAgent() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed) + XCTAssertTrue(ownership.disconnect(connection(12))) + } + + func testAgentWaitsForEveryAuthenticatedConnectionToClose() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed) + XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .retained) + XCTAssertFalse(ownership.disconnect(connection(12))) + XCTAssertTrue(ownership.disconnect(connection(13))) + } + + func testDuplicateRegistrationDoesNotRetainAgent() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed) + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .rejected) + XCTAssertTrue(ownership.disconnect(connection(12))) + } + + func testClosedSessionRejectsNewConnections() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed) + XCTAssertTrue(ownership.disconnect(connection(12))) + XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .rejected) + XCTAssertFalse(ownership.disconnect(connection(13))) + } + + func testStaleDisconnectCannotRemoveReusedFileDescriptorOwner() { + var ownership = AgentSessionOwnership() + let staleConnection = connection(12) + let otherConnection = connection(13) + let reusedDescriptorConnection = connection(14) + + XCTAssertEqual(ownership.registerConnection(staleConnection, authenticated: true), .claimed) + XCTAssertEqual(ownership.registerConnection(otherConnection, authenticated: true), .retained) + XCTAssertFalse(ownership.disconnect(staleConnection)) + XCTAssertEqual( + ownership.registerConnection(reusedDescriptorConnection, authenticated: true), + .retained + ) + + XCTAssertFalse(ownership.disconnect(staleConnection)) + XCTAssertFalse(ownership.disconnect(otherConnection)) + XCTAssertTrue(ownership.disconnect(reusedDescriptorConnection)) + } + + func testTokenlessSessionStillRequiresAuthorizedPeer() { + XCTAssertFalse(isAuthenticatedAgentSession( + expectedToken: nil, + requestToken: nil, + authorizedPeer: false + )) + XCTAssertTrue(isAuthenticatedAgentSession( + expectedToken: nil, + requestToken: nil, + authorizedPeer: true + )) + } + + func testTokenSessionRequiresAuthorizedPeerAndMatchingToken() { + XCTAssertFalse(isAuthenticatedAgentSession( + expectedToken: "expected", + requestToken: "expected", + authorizedPeer: false + )) + XCTAssertFalse(isAuthenticatedAgentSession( + expectedToken: "expected", + requestToken: "wrong", + authorizedPeer: true + )) + XCTAssertTrue(isAuthenticatedAgentSession( + expectedToken: "expected", + requestToken: "expected", + authorizedPeer: true + )) + } +} + +private func connection(_ rawValue: UInt64) -> AgentSessionConnectionID { + AgentSessionConnectionID(rawValue: rawValue) +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AuthenticatedConnectionHangupMonitorTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AuthenticatedConnectionHangupMonitorTests.swift new file mode 100644 index 00000000000..1d335e98bb6 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AuthenticatedConnectionHangupMonitorTests.swift @@ -0,0 +1,195 @@ +import Darwin +import Dispatch +@testable import OrcaComputerUseMacOSCore +import XCTest + +final class AuthenticatedConnectionHangupMonitorTests: XCTestCase { + func testRejectsInvalidDescriptorDuringSetup() { + XCTAssertThrowsError( + try AuthenticatedConnectionHangupMonitor( + fileDescriptor: -1, + onHangup: {} + ) + ) + } + + func testRegistrationFailureClosesEventQueueOnce() { + let closes = DescriptorCloseRecorder() + + XCTAssertThrowsError( + try AuthenticatedConnectionHangupMonitor( + fileDescriptor: 42, + registerEvents: { _, _, _ in .EBADF }, + closeDescriptor: { descriptor in + closes.recordAndClose(descriptor) + }, + onHangup: {} + ) + ) + XCTAssertEqual(closes.count, 1) + } + + func testReportsPeerCloseWhileRequestHandlingIsBlocked() throws { + let descriptors = try makeSocketPair() + let hangup = expectation(description: "peer hangup") + let processingStarted = expectation(description: "processing started") + let releaseProcessing = DispatchSemaphore(value: 0) + let processingFinished = DispatchSemaphore(value: 0) + let monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onHangup: { + hangup.fulfill() + } + ) + monitor.start() + + DispatchQueue.global().async { + processingStarted.fulfill() + releaseProcessing.wait() + processingFinished.signal() + } + wait(for: [processingStarted], timeout: 1) + close(descriptors.peer) + + wait(for: [hangup], timeout: 1) + XCTAssertEqual(processingFinished.wait(timeout: .now() + 0.05), .timedOut) + + releaseProcessing.signal() + XCTAssertEqual(processingFinished.wait(timeout: .now() + 1), .success) + monitor.cancel() + close(descriptors.local) + } + + func testReadableDataDoesNotLookLikeHangup() throws { + let descriptors = try makeSocketPair() + let hangup = expectation(description: "peer hangup") + let callbacks = CallbackRecorder() + let events = CallbackRecorder() + let monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onEvent: { + events.record() + }, + onHangup: { + callbacks.record() + hangup.fulfill() + } + ) + monitor.start() + var byte: UInt8 = 7 + + XCTAssertEqual(write(descriptors.peer, &byte, 1), 1) + usleep(50_000) + XCTAssertEqual(callbacks.count, 0) + XCTAssertEqual(events.count, 0) + close(descriptors.peer) + wait(for: [hangup], timeout: 1) + XCTAssertEqual(events.count, 1) + + monitor.cancel() + close(descriptors.local) + } + + func testCancelPreventsLaterHangupCallback() throws { + let descriptors = try makeSocketPair() + let hangup = expectation(description: "peer hangup") + hangup.isInverted = true + let monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onHangup: { + hangup.fulfill() + } + ) + monitor.start() + + monitor.cancel() + close(descriptors.peer) + + wait(for: [hangup], timeout: 0.1) + close(descriptors.local) + } + + func testCancelReleasesMonitorWhileSocketStaysOpen() throws { + let descriptors = try makeSocketPair() + var monitor: AuthenticatedConnectionHangupMonitor? = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onHangup: {} + ) + weak var retainedMonitor = monitor + + monitor?.cancel() + monitor = nil + + let deadline = Date().addingTimeInterval(1) + while retainedMonitor != nil, Date() < deadline { + usleep(10_000) + } + XCTAssertNil(retainedMonitor) + close(descriptors.peer) + close(descriptors.local) + } + + func testStartReportsHangupQueuedAfterSuccessfulSetup() throws { + let descriptors = try makeSocketPair() + let hangup = expectation(description: "queued peer hangup") + let monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onHangup: { + hangup.fulfill() + } + ) + + close(descriptors.peer) + monitor.start() + + wait(for: [hangup], timeout: 1) + monitor.cancel() + close(descriptors.local) + } +} + +private final class CallbackRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedCount = 0 + + var count: Int { + lock.lock() + defer { lock.unlock() } + return recordedCount + } + + func record() { + lock.lock() + recordedCount += 1 + lock.unlock() + } +} + +private final class DescriptorCloseRecorder: @unchecked Sendable { + private let lock = NSLock() + private var closeCount = 0 + + var count: Int { + lock.lock() + defer { lock.unlock() } + return closeCount + } + + func recordAndClose(_ descriptor: Int32) { + lock.lock() + closeCount += 1 + let shouldClose = closeCount == 1 + lock.unlock() + if shouldClose { + close(descriptor) + } + } +} + +private func makeSocketPair() throws -> (local: Int32, peer: Int32) { + var descriptors: [Int32] = [0, 0] + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else { + throw POSIXError(.EIO) + } + return (descriptors[0], descriptors[1]) +} diff --git a/package.json b/package.json index fe23f109180..1a05084eabc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.162-rc.0", + "version": "1.4.163-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -58,19 +58,21 @@ "smoke:computer": "node config/scripts/computer-use-smoke.mjs", "verify:computer-native": "node config/scripts/verify-computer-native.mjs", "verify:cli-bin": "node config/scripts/verify-cli-bin.mjs", + "verify:built-skills-cli": "node config/scripts/verify-skills-cli-runtime.cjs out", "verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs", "sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix", "verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs", "verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check", "audit:localization": "node config/scripts/audit-localization-coverage.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs", + "test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs", "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", - "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer", + "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", "build": "pnpm run build:desktop && pnpm run build:native", - "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer", + "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "rebuild:node": "pnpm rebuild node-pty", @@ -103,8 +105,10 @@ "test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts", "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs", + "bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs", "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", + "bench:hang-watchdog-memory": "pnpm run ensure:electron-runtime && node config/scripts/hang-watchdog-memory-benchmark.mjs", "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", "bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs", "bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs", diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index f6f72111565..eeedfbe0428 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -232,6 +232,11 @@ describe('supportsBrowserPageFlag', () => { it('does not expose browser page targeting on local agent discovery', () => { expect(supportsBrowserPageFlag(['agent-context'])).toBe(false) }) + + it('does not expose browser page targeting on host-local account commands', () => { + expect(supportsBrowserPageFlag(['account', 'add'])).toBe(false) + expect(supportsBrowserPageFlag(['account', 'list'])).toBe(false) + }) }) describe('validateCommandAndFlags', () => { diff --git a/src/cli/args.ts b/src/cli/args.ts index a5150c980e5..b7a3be791e3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -152,6 +152,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { } if ( [ + 'account', 'automations', 'project', 'repo', @@ -198,6 +199,7 @@ export function isCommandGroup(commandPath: string[]): boolean { return ( (commandPath.length === 1 && [ + 'account', 'automations', 'project', 'repo', diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index 6b2c7410c49..1cafa87cb95 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -17,6 +17,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ keys: ['claude-teams', 'open', 'serve', 'status'], load: async () => (await import('./handlers/core.js')).CORE_HANDLERS }, + { + name: 'account', + keys: ['account add', 'account list'], + load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS + }, { name: 'automations', keys: [ diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts new file mode 100644 index 00000000000..d39a5f33293 --- /dev/null +++ b/src/cli/handlers/account.test.ts @@ -0,0 +1,565 @@ +import { EventEmitter } from 'node:events' +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { delimiter } from 'node:path' +import type * as NodeFs from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + deleteKeychainMock, + getVersionManagerBinPathsMock, + readKeychainMock, + resolveCliCommandMock, + rmSyncMock, + spawnMock, + writeKeychainMock +} = vi.hoisted(() => ({ + deleteKeychainMock: vi.fn(), + getVersionManagerBinPathsMock: vi.fn(), + readKeychainMock: vi.fn(), + resolveCliCommandMock: vi.fn(), + rmSyncMock: vi.fn(), + spawnMock: vi.fn(), + writeKeychainMock: vi.fn() +})) + +// Why: keep real temp-dir cleanup by default so leak assertions stay honest, +// while allowing deterministic Windows EBUSY coverage. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + rmSyncMock.mockImplementation(actual.rmSync) + return { ...actual, rmSync: rmSyncMock } +}) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), + execFileSync: vi.fn(), + spawn: spawnMock +})) +vi.mock('../../main/claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: deleteKeychainMock, + readActiveClaudeKeychainCredentialsStrict: readKeychainMock, + writeActiveClaudeKeychainCredentials: writeKeychainMock +})) +vi.mock('../../shared/node-cli-command-resolution', () => ({ + getVersionManagerBinPaths: getVersionManagerBinPathsMock, + resolveCliCommand: resolveCliCommandMock +})) + +import { ACCOUNT_HANDLERS } from './account' +import type { HandlerContext } from '../dispatch' +import type { RuntimeClient } from '../runtime-client' +import { getCmdExePath } from '../../shared/windows-batch-spawn' +import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' + +function successfulChild(): EventEmitter { + const child = new EventEmitter() + queueMicrotask(() => child.emit('exit', 0)) + return child +} + +// Why: identify the handler under test by set difference, not by position — +// `.at(-1)` picks up any listener a later registration appends (vitest installs +// its own once-wrapped SIGINT teardown), which made assertions flake. +function newSignalListener( + signal: NodeJS.Signals, + before: readonly unknown[] +): (signal: NodeJS.Signals) => void { + const added = process.listeners(signal).filter((listener) => !before.includes(listener)) + if (added.length !== 1) { + throw new Error(`Expected 1 new ${signal} listener, found ${added.length}`) + } + return added[0] as (signal: NodeJS.Signals) => void +} + +function accountState(email: string) { + return { + accounts: [{ id: 'account-1', email }], + activeAccountId: 'account-1', + activeAccountIdsByRuntime: { host: 'account-1', wsl: {} } + } +} + +describe('account CLI handlers', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + const originalElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE + const originalPathAlias = process.env.Path + const callMock = vi.fn() + const client = { call: callMock } as unknown as RuntimeClient + let logSpy: ReturnType + + function context(agent: string, json = false): HandlerContext { + return { + client, + cwd: process.cwd(), + flags: new Map([['agent', agent]]), + json, + rawArgs: [] + } + } + + beforeEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + spawnMock.mockReset().mockImplementation(() => successfulChild()) + resolveCliCommandMock.mockReset().mockImplementation((command: string) => command) + getVersionManagerBinPathsMock.mockReset().mockReturnValue([]) + readKeychainMock.mockReset().mockResolvedValue(null) + deleteKeychainMock.mockReset().mockResolvedValue(undefined) + writeKeychainMock.mockReset().mockResolvedValue(undefined) + callMock.mockReset().mockImplementation((method: string) => + Promise.resolve({ + id: 'test', + ok: true, + result: + method === 'status.get' + ? { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] } + : accountState(method.includes('Claude') ? 'claude@example.com' : 'codex@example.com'), + _meta: { runtimeId: 'test-runtime' } + }) + ) + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + process.env.ELECTRON_RUN_AS_NODE = '1' + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + logSpy.mockRestore() + if (originalElectronRunAsNode === undefined) { + delete process.env.ELECTRON_RUN_AS_NODE + } else { + process.env.ELECTRON_RUN_AS_NODE = originalElectronRunAsNode + } + if (originalPathAlias === undefined) { + delete process.env.Path + } else { + process.env.Path = originalPathAlias + } + }) + + it('uses Codex device auth and keeps JSON stdout clean', async () => { + await ACCOUNT_HANDLERS['account add'](context('codex', true)) + + expect(spawnMock).toHaveBeenCalledWith( + 'codex', + ['login', '--device-auth'], + expect.objectContaining({ + stdio: ['inherit', process.stderr, 'inherit'], + env: expect.objectContaining({ CODEX_HOME: expect.any(String) }) + }) + ) + const spawnOptions = spawnMock.mock.calls[0]?.[2] + expect(spawnOptions.env.ELECTRON_RUN_AS_NODE).toBeUndefined() + expect(existsSync(spawnOptions.env.CODEX_HOME)).toBe(false) + expect(callMock).toHaveBeenCalledWith('accounts.addCodexFromHome', { + sourceHome: spawnOptions.env.CODEX_HOME + }) + }) + + it('routes Windows package-manager shims through the safe cmd launcher', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + resolveCliCommandMock.mockReturnValue('C:\\tools\\codex.cmd') + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + ['/d', '/c', 'C:\\tools\\codex.cmd', 'login', '--device-auth'], + expect.objectContaining({ stdio: ['inherit', 'inherit', 'inherit'] }) + ) + }) + + it('adds version-manager Node paths to the login child environment', async () => { + const nodeBin = '/home/test/.nvm/versions/node/v22.0.0/bin' + getVersionManagerBinPathsMock.mockReturnValue([nodeBin]) + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + const path = spawnMock.mock.calls[0]?.[2].env.PATH as string + expect(path.split(delimiter)[0]).toBe(nodeBin) + }) + + it('updates the effective Windows PATH regardless of native environment casing', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.Path = 'C:\\stale' + const nodeBin = 'C:\\Users\\test\\.volta\\bin' + const effectivePathBefore = process.env.PATH ?? process.env.Path ?? '' + getVersionManagerBinPathsMock.mockReturnValue([nodeBin]) + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + const env = spawnMock.mock.calls[0]?.[2].env as NodeJS.ProcessEnv + const pathValues = Object.entries(env) + .filter(([key]) => key.toLowerCase() === 'path') + .map(([, value]) => value) + expect(pathValues).toContain(`${nodeBin}${delimiter}${effectivePathBefore}`) + }) + + it('removes scoped Claude credentials and restores the legacy Keychain item', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + + await ACCOUNT_HANDLERS['account add'](context('claude')) + + const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR + expect(deleteKeychainMock).toHaveBeenCalledWith(configDir) + expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { + configDir, + previousLegacyCredentialsSha256: createHash('sha256') + .update('legacy-credentials') + .digest('hex') + }) + expect(existsSync(configDir)).toBe(false) + }) + + it('waits for physical child close before removing interrupted login credentials', async () => { + // Why: deleting first lets the still-live login recreate credentials afterward. + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const kill = vi.fn() + const child = Object.assign(new EventEmitter(), { kill }) + let codexHome = '' + spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { + codexHome = options.env.CODEX_HOME + return child + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + const listenersBefore = process.listeners('SIGINT') + + const pending = ACCOUNT_HANDLERS['account add'](context('codex')).catch(() => {}) + await vi.waitFor(() => expect(codexHome).not.toBe('')) + expect(existsSync(codexHome)).toBe(true) + + newSignalListener('SIGINT', listenersBefore)('SIGINT') + + await vi.waitFor(() => expect(kill).toHaveBeenCalledWith('SIGINT')) + expect(exitSpy).not.toHaveBeenCalled() + expect(existsSync(codexHome)).toBe(true) + + child.emit('exit', 1) + child.emit('close', 1) + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(130)) + expect(existsSync(codexHome)).toBe(false) + expect(callMock).not.toHaveBeenCalledWith('accounts.addCodexFromHome', expect.anything()) + + await pending + exitSpy.mockRestore() + }) + + it('cleans up when an SSH hangup ends the login', async () => { + // Why: this flow targets headless/SSH hosts, where a dropped connection + // delivers SIGHUP — Node's default terminates without running cleanup. + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) + let codexHome = '' + spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { + codexHome = options.env.CODEX_HOME + return child + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + const listenersBefore = process.listeners('SIGHUP') + + const pending = ACCOUNT_HANDLERS['account add'](context('codex')).catch(() => {}) + await vi.waitFor(() => expect(codexHome).not.toBe('')) + + newSignalListener('SIGHUP', listenersBefore)('SIGHUP') + + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGHUP')) + expect(exitSpy).not.toHaveBeenCalled() + expect(existsSync(codexHome)).toBe(true) + + child.emit('exit', 1) + child.emit('close', 1) + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(129)) + expect(existsSync(codexHome)).toBe(false) + + await pending + exitSpy.mockRestore() + }) + + it('waits for in-flight cleanup when a second signal arrives', async () => { + // Why: a boolean latch lets the second signal's process.exit fire while the + // first cleanup is still inside a Keychain call, stranding the credentials. + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + let releaseKeychainDelete: (() => void) | undefined + deleteKeychainMock.mockImplementationOnce( + () => + new Promise((resolvePromise) => { + releaseKeychainDelete = () => resolvePromise() + }) + ) + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) + let configDir = '' + spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { + configDir = options.env.CLAUDE_CONFIG_DIR + return child + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + const sigintBefore = process.listeners('SIGINT') + const sigtermBefore = process.listeners('SIGTERM') + + const pending = ACCOUNT_HANDLERS['account add'](context('claude')).catch(() => {}) + await vi.waitFor(() => expect(configDir).not.toBe('')) + + const onSigint = newSignalListener('SIGINT', sigintBefore) + const onSigterm = newSignalListener('SIGTERM', sigtermBefore) + // Why: `rawListeners` exposes the `once` wrapper, so this fails if the handler + // is registered with `once` — where a second Ctrl-C falls through to Node's + // default and kills the process mid-cleanup. + expect(process.rawListeners('SIGINT')).toContain(onSigint) + + onSigint('SIGINT') + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGINT')) + child.emit('exit', 1) + child.emit('close', 1) + await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalledWith(configDir)) + onSigterm('SIGTERM') + await new Promise((resolvePromise) => { + setImmediate(resolvePromise) + }) + + expect(exitSpy).not.toHaveBeenCalled() + expect(writeKeychainMock).not.toHaveBeenCalled() + expect(existsSync(configDir)).toBe(true) + + releaseKeychainDelete?.() + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled()) + expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + expect(existsSync(configDir)).toBe(false) + expect(child.kill).toHaveBeenCalledOnce() + + await pending + exitSpy.mockRestore() + }) + + it('warns that the account may already be registered when interrupted mid-RPC', async () => { + // Why: the runtime finishes the add independently of this process, so an + // interrupt after sign-in cannot honestly be reported as "not added". + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) + spawnMock.mockImplementation(() => { + queueMicrotask(() => child.emit('exit', 0)) + return child + }) + // Why: only the registration RPC hangs — the preflight must still resolve. + callMock.mockImplementation((method: string) => + method === 'status.get' + ? Promise.resolve({ + id: 'test', + ok: true, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'test-runtime' } + }) + : method === 'accounts.list' + ? Promise.resolve({ + id: 'test', + ok: true, + result: { claude: accountState('c@e.com'), codex: accountState('x@e.com') }, + _meta: { runtimeId: 'test-runtime' } + }) + : new Promise(() => {}) + ) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + const listenersBefore = process.listeners('SIGINT') + + void ACCOUNT_HANDLERS['account add'](context('codex')).catch(() => {}) + await vi.waitFor(() => + expect(callMock).toHaveBeenCalledWith('accounts.addCodexFromHome', expect.anything()) + ) + + newSignalListener('SIGINT', listenersBefore)('SIGINT') + + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(130)) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('may still have been registered')) + warnSpy.mockRestore() + exitSpy.mockRestore() + }) + + it('stays armed for signals until post-success cleanup finishes', async () => { + // Why: detaching the handlers before cleanup leaves the multi-second Keychain + // calls covered only by Node's default handling, which kills mid-cleanup. + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + let releaseKeychainDelete: (() => void) | undefined + deleteKeychainMock.mockImplementationOnce( + () => + new Promise((resolvePromise) => { + releaseKeychainDelete = () => resolvePromise() + }) + ) + + const listenersBefore = process.listeners('SIGINT') + + const pending = ACCOUNT_HANDLERS['account add'](context('claude')) + await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalled()) + + // Why: cleanup is still in flight here, so this add's guard must still be installed. + const handler = newSignalListener('SIGINT', listenersBefore) + + releaseKeychainDelete?.() + await pending + expect(process.listeners('SIGINT')).not.toContain(handler) + }) + + it('fails before the login when the runtime is unreachable', async () => { + // Why: discovering a dead runtime after sign-in wastes a full OAuth round trip. + callMock.mockRejectedValue(new Error('runtime not running')) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( + 'runtime not running' + ) + expect(callMock).toHaveBeenCalledWith('status.get') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('fails before login when the running runtime predates account imports', async () => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'test-runtime' } + }) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( + 'runtime is too old' + ) + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith('status.get') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it.each(['environment', 'pairing-code'])( + 'rejects --%s instead of silently ignoring it', + async (flag) => { + // Why: account commands are pinned to the local runtime, so honoring these + // silently would register the account on the wrong host. + await expect( + ACCOUNT_HANDLERS['account add']({ + ...context('codex'), + flags: new Map([ + ['agent', 'codex'], + [flag, 'homelab'] + ]) + }) + ).rejects.toThrow(`\`--${flag}\` does not retarget`) + expect(spawnMock).not.toHaveBeenCalled() + } + ) + + it.each(['environment', 'pairing-code'])( + 'rejects --%s on `account list` instead of listing the local host', + async (flag) => { + // Why: listing is read-only, but answering with the LOCAL machine's accounts + // when the user named a remote host is the specific wrong answer they'd act on. + await expect( + ACCOUNT_HANDLERS['account list']({ + ...context('claude'), + flags: new Map([[flag, 'homelab']]) + }) + ).rejects.toThrow(`\`--${flag}\` does not retarget`) + expect(callMock).not.toHaveBeenCalled() + } + ) + + it('keeps the original add error when cleanup also fails', async () => { + // Why: cleanup runs in a `finally`, so an unguarded rejection there replaces + // the error that actually explains why the add failed. + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + rmSyncMock.mockImplementationOnce(() => { + throw new Error('EBUSY: resource busy or locked') + }) + callMock.mockImplementation((method: string) => + method === 'status.get' + ? Promise.resolve({ + id: 'test', + ok: true, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'test-runtime' } + }) + : method === 'accounts.list' + ? Promise.resolve({ + id: 'test', + ok: true, + result: { claude: accountState('c@e.com'), codex: accountState('x@e.com') }, + _meta: { runtimeId: 'test-runtime' } + }) + : Promise.reject(new Error('registration rejected by runtime')) + ) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( + 'registration rejected by runtime' + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to clean up the temporary login directory'), + expect.any(Error) + ) + warnSpy.mockRestore() + }) + + it('fails a successful add when the temporary credentials cannot be removed', async () => { + rmSyncMock.mockImplementationOnce(() => { + throw new Error('EBUSY: resource busy or locked') + }) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex', true))).rejects.toThrow('EBUSY') + expect(logSpy).not.toHaveBeenCalled() + }) + + it('fails a successful Claude add when Keychain cleanup fails', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + deleteKeychainMock.mockRejectedValueOnce(new Error('Keychain denied cleanup')) + + await expect(ACCOUNT_HANDLERS['account add'](context('claude'))).rejects.toThrow( + 'Failed to clean up Claude login artifacts' + ) + expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + }) + + it('rejects `--agent` with no value instead of defaulting to Claude', async () => { + // Why: the parser turns a valueless flag into boolean true, so a silent + // default would run a full OAuth login for the wrong provider. + await expect( + ACCOUNT_HANDLERS['account add']({ ...context('claude'), flags: new Map([['agent', true]]) }) + ).rejects.toThrow('Missing a value for --agent') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('marks an account selected for WSL as active in human output', async () => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { + claude: { + accounts: [{ id: 'claude-wsl', email: 'claude@example.com' }], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'claude-wsl' } } + }, + codex: { accounts: [], activeAccountId: null } + }, + _meta: { runtimeId: 'test-runtime' } + }) + + await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() }) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('claude@example.com (active)')) + }) + + it('lists accounts without forcing a provider usage refresh', async () => { + // Why: the forced lane bypasses the poll throttle and costs one serial + // round-trip per managed account, and this output shows no usage numbers. + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { + claude: { accounts: [], activeAccountId: null }, + codex: { accounts: [], activeAccountId: null } + }, + _meta: { runtimeId: 'test-runtime' } + }) + + await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() }) + + expect(callMock).toHaveBeenCalledWith('accounts.list', { refreshUsage: false }) + }) +}) diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts new file mode 100644 index 00000000000..0ad19aa240d --- /dev/null +++ b/src/cli/handlers/account.ts @@ -0,0 +1,301 @@ +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import type { CommandHandler, HandlerContext } from '../dispatch' +import { printResult } from '../format' +import { RuntimeClientError } from '../runtime-client' +import { stripElectronRunAsNode } from '../runtime/launch' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, + writeActiveClaudeKeychainCredentials +} from '../../main/claude-accounts/keychain' +import { + getVersionManagerBinPaths, + resolveCliCommand +} from '../../shared/node-cli-command-resolution' +import { getSpawnArgsForWindows } from '../../shared/windows-batch-spawn' +import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import { + type InteractiveLoginSession, + withInteractiveLoginCleanup +} from './interactive-login-interruption' + +// Why: add returns just that provider's state; list returns the full snapshot. +type AccountsListSnapshot = { + claude: ClaudeRateLimitAccountsState + codex: CodexRateLimitAccountsState +} + +// Why: Claude and Codex managed-account summaries both carry id+email+active id, +// so one formatter renders either provider's block. +type AccountsBlock = { + accounts: readonly { id: string; email: string }[] + activeAccountId: string | null + activeAccountIdsByRuntime?: { + host: string | null + wsl: Record + } +} + +/** Renders a provider's managed-account list as a human-readable block, marking the active account. */ +function formatAccountsBlock(label: string, block: AccountsBlock): string { + if (block.accounts.length === 0) { + return `No managed ${label} accounts.` + } + const activeAccountIds = new Set([ + block.activeAccountId, + block.activeAccountIdsByRuntime?.host, + ...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {}) + ]) + const lines = block.accounts.map( + (account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}` + ) + return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}` +} + +function addAgentNodePaths(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const pathKey = + process.platform === 'win32' && env.Path !== undefined && env.PATH === undefined + ? 'Path' + : 'PATH' + const currentEntries = (env[pathKey] ?? '').split(delimiter).filter(Boolean) + const existing = new Set(currentEntries) + const missing = getVersionManagerBinPaths().filter((entry) => !existing.has(entry)) + if (missing.length > 0) { + env[pathKey] = [...missing, ...currentEntries].join(delimiter) + } + return env +} + +/** + * Runs the real agent login attached to the user's terminal so the OAuth + * URL/device-code prompt is visible and the code can be pasted back — the desktop + * GUI flow drives this via a browser Orca can't reach on a headless host. + */ +async function runAgentLoginInTerminal( + command: string, + args: string[], + extraEnv: Record, + json: boolean, + session: InteractiveLoginSession +): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const resolvedCommand = resolveCliCommand(command) + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedCommand, args) + const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv }) + const child = spawn(spawnCmd, spawnArgs, { + // Why: JSON mode reserves stdout for the response envelope while keeping + // the interactive login attached to the user's terminal via stderr. + stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'], + env + }) + session.child = child + child.once('error', (error) => + rejectPromise( + new RuntimeClientError( + 'internal', + `Could not launch \`${command}\`. Is it installed and on PATH? (${ + error instanceof Error ? error.message : String(error) + })` + ) + ) + ) + child.once('exit', (code) => { + session.child = null + if (code === 0) { + resolvePromise() + return + } + rejectPromise( + new RuntimeClientError( + 'internal', + `\`${command} ${args.join(' ')}\` exited with code ${code ?? 'null'}.` + ) + ) + }) + }) +} + +async function cleanupClaudeLoginArtifacts( + configDir: string, + legacyCredentials: string | null, + restoreLegacyCredentials: boolean +): Promise { + const errors: unknown[] = [] + if (process.platform === 'darwin') { + try { + await deleteActiveClaudeKeychainCredentialsStrict(configDir) + } catch (error) { + errors.push(error) + } + if (restoreLegacyCredentials) { + try { + await (legacyCredentials + ? writeActiveClaudeKeychainCredentials(legacyCredentials) + : deleteActiveClaudeKeychainCredentialsStrict()) + } catch (error) { + errors.push(error) + } + } + } + try { + rmSync(configDir, { recursive: true, force: true }) + } catch (error) { + errors.push(error) + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Failed to clean up Claude login artifacts.') + } +} + +/** Logs into a Claude account in a temp config dir, then registers it with the local runtime. */ +async function addClaudeAccount({ client, json }: HandlerContext): Promise { + const configDir = mkdtempSync(join(tmpdir(), 'orca-account-add-claude-')) + const session: InteractiveLoginSession = { + child: null, + registering: false, + terminationPromise: null + } + let legacyCredentials: string | null = null + let restoreLegacyCredentials = false + const result = await withInteractiveLoginCleanup( + session, + async () => { + await cleanupClaudeLoginArtifacts(configDir, legacyCredentials, restoreLegacyCredentials) + }, + async () => { + if (process.platform === 'darwin') { + legacyCredentials = await readActiveClaudeKeychainCredentialsStrict() + restoreLegacyCredentials = true + } + await runAgentLoginInTerminal( + 'claude', + ['auth', 'login', '--claudeai'], + { + CLAUDE_CONFIG_DIR: configDir + }, + json, + session + ) + session.registering = true + return client.call('accounts.addClaudeFromConfigDir', { + configDir, + ...(process.platform === 'darwin' + ? { + previousLegacyCredentialsSha256: legacyCredentials + ? createHash('sha256').update(legacyCredentials).digest('hex') + : null + } + : {}) + }) + } + ) + printResult(result, json, (state) => formatAccountsBlock('Claude', state)) +} + +/** Logs into a Codex account in a temp CODEX_HOME, then registers it with the local runtime. */ +async function addCodexAccount({ client, json }: HandlerContext): Promise { + const codexHome = mkdtempSync(join(tmpdir(), 'orca-account-add-codex-')) + const session: InteractiveLoginSession = { + child: null, + registering: false, + terminationPromise: null + } + const result = await withInteractiveLoginCleanup( + session, + async () => { + rmSync(codexHome, { recursive: true, force: true }) + }, + async () => { + // Why: plain OAuth binds a loopback callback the user's browser cannot reach + // on a headless/SSH host; device auth is explicitly designed for this flow. + await runAgentLoginInTerminal( + 'codex', + ['login', '--device-auth'], + { CODEX_HOME: codexHome }, + json, + session + ) + session.registering = true + return client.call('accounts.addCodexFromHome', { + sourceHome: codexHome + }) + } + ) + printResult(result, json, (state) => formatAccountsBlock('Codex', state)) +} + +/** + * Rejects the runtime-selector flags instead of ignoring them. shouldIgnoreRemoteSelection + * pins account commands to the local runtime, so honoring `--environment homelab` + * silently would target the laptop rather than the host the user named — the exact + * mistake this feature exists to avoid. A `--help` note does not reach someone who + * already typed the flag. + */ +function rejectRemoteSelectionFlags(ctx: HandlerContext, command: string): void { + for (const flag of ['environment', 'pairing-code']) { + if (ctx.flags.has(flag)) { + throw new RuntimeClientError( + 'invalid_argument', + `\`--${flag}\` does not retarget \`${command}\`. Run it on the host whose accounts you want to manage.` + ) + } + } +} + +async function assertAccountImportSupported({ client }: HandlerContext): Promise { + const status = await client.call('status.get') + if (!status.result.capabilities?.includes(ACCOUNT_IMPORT_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The running Orca runtime is too old to add accounts from the CLI. Update or restart Orca and try again.' + ) + } +} + +/** CLI handlers for `orca account add [--agent claude|codex]` and `orca account list`. */ +export const ACCOUNT_HANDLERS: Record = { + 'account add': async (ctx) => { + const agentFlag = ctx.flags.get('agent') + // Why: a valueless `--agent` parses as boolean true; defaulting it to claude + // would silently run a full OAuth login for the provider the user did not ask for. + if (agentFlag !== undefined && typeof agentFlag !== 'string') { + throw new RuntimeClientError( + 'invalid_argument', + 'Missing a value for --agent. Use `--agent claude` or `--agent codex`.' + ) + } + const agent = agentFlag ?? 'claude' + if (agent !== 'claude' && agent !== 'codex') { + throw new RuntimeClientError( + 'invalid_argument', + `Unsupported --agent "${agent}". Use "claude" or "codex".` + ) + } + rejectRemoteSelectionFlags(ctx, 'orca account add') + // Why: fail on runtime version skew before burning a full OAuth round trip. + await assertAccountImportSupported(ctx) + await ctx.client.call('accounts.list', { refreshUsage: false }) + await (agent === 'claude' ? addClaudeAccount(ctx) : addCodexAccount(ctx)) + }, + 'account list': async (ctx) => { + rejectRemoteSelectionFlags(ctx, 'orca account list') + const { client, json } = ctx + // Why: this command renders no usage numbers, so skip the forced provider + // refresh — it is one serial network round-trip per managed account. + const result = await client.call('accounts.list', { + refreshUsage: false + }) + printResult( + result, + json, + (snapshot) => + `${formatAccountsBlock('Claude', snapshot.claude)}\n\n${formatAccountsBlock('Codex', snapshot.codex)}` + ) + } +} diff --git a/src/cli/handlers/interactive-login-interruption.test.ts b/src/cli/handlers/interactive-login-interruption.test.ts new file mode 100644 index 00000000000..8bd3ef98466 --- /dev/null +++ b/src/cli/handlers/interactive-login-interruption.test.ts @@ -0,0 +1,121 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + LOGIN_PROCESS_CLOSE_FALLBACK_MS, + LOGIN_PROCESS_POSIX_GRACE_MS, + terminateInteractiveLoginProcess, + terminateWindowsLoginProcessTree, + WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS +} from './interactive-login-interruption' + +function loginChild(pid = 4321): ChildProcess & EventEmitter { + return Object.assign(new EventEmitter(), { + kill: vi.fn(), + pid + }) as unknown as ChildProcess & EventEmitter +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('terminateInteractiveLoginProcess', () => { + it('runs bounded taskkill /T /F without a shell', async () => { + const execFileImpl = vi.fn( + ( + _command: string, + _args: readonly string[], + _options: { timeout?: number; windowsHide?: boolean }, + callback: (error: Error | null) => void + ) => callback(null) + ) + + await terminateWindowsLoginProcessTree(4321, execFileImpl as never) + + expect(execFileImpl).toHaveBeenCalledWith( + 'taskkill', + ['/pid', '4321', '/T', '/F'], + { timeout: WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS, windowsHide: true }, + expect.any(Function) + ) + }) + + it('waits for both Windows tree termination and physical wrapper close', async () => { + const child = loginChild() + let finishTreeKill: (() => void) | undefined + const killWindowsTree = vi.fn( + () => + new Promise((resolve) => { + finishTreeKill = resolve + }) + ) + let terminated = false + + const pending = terminateInteractiveLoginProcess(child, 'SIGTERM', { + platform: 'win32', + killWindowsTree + }).then(() => { + terminated = true + }) + + expect(killWindowsTree).toHaveBeenCalledWith(4321) + child.emit('close', 1) + await Promise.resolve() + expect(terminated).toBe(false) + + finishTreeKill?.() + await pending + expect(child.kill).not.toHaveBeenCalled() + }) + + it('bounds Windows close waits and falls back to the direct wrapper', async () => { + vi.useFakeTimers() + const child = loginChild() + + const pending = terminateInteractiveLoginProcess(child, 'SIGINT', { + platform: 'win32', + killWindowsTree: vi.fn().mockResolvedValue(undefined) + }) + await vi.advanceTimersByTimeAsync(LOGIN_PROCESS_CLOSE_FALLBACK_MS) + expect(child.kill).toHaveBeenCalledOnce() + expect(child.kill).toHaveBeenCalledWith(undefined) + + await vi.advanceTimersByTimeAsync(LOGIN_PROCESS_CLOSE_FALLBACK_MS) + await pending + }) + + it('forwards the interrupt signal to a direct POSIX child and awaits close', async () => { + const child = loginChild() + let terminated = false + + const pending = terminateInteractiveLoginProcess(child, 'SIGHUP', { + platform: 'linux' + }).then(() => { + terminated = true + }) + + expect(child.kill).toHaveBeenCalledWith('SIGHUP') + await Promise.resolve() + expect(terminated).toBe(false) + + child.emit('close', 1) + await pending + expect(child.kill).toHaveBeenCalledOnce() + }) + + it('force-kills an unresponsive POSIX child before the final bounded wait', async () => { + vi.useFakeTimers() + const child = loginChild() + + const pending = terminateInteractiveLoginProcess(child, 'SIGINT', { + platform: 'darwin' + }) + await vi.advanceTimersByTimeAsync(LOGIN_PROCESS_POSIX_GRACE_MS) + expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGINT') + expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + + await vi.advanceTimersByTimeAsync(LOGIN_PROCESS_CLOSE_FALLBACK_MS) + await pending + }) +}) diff --git a/src/cli/handlers/interactive-login-interruption.ts b/src/cli/handlers/interactive-login-interruption.ts new file mode 100644 index 00000000000..4ef1a06c2db --- /dev/null +++ b/src/cli/handlers/interactive-login-interruption.ts @@ -0,0 +1,184 @@ +import { execFile, type ChildProcess } from 'node:child_process' + +export const LOGIN_PROCESS_POSIX_GRACE_MS = 5_000 +export const LOGIN_PROCESS_CLOSE_FALLBACK_MS = 1_000 +export const WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS = 5_000 + +type WindowsTreeKiller = (rootPid: number) => Promise + +type TerminationDependencies = { + platform?: NodeJS.Platform + killWindowsTree?: WindowsTreeKiller + posixGraceMs?: number + closeFallbackMs?: number +} + +export type InteractiveLoginSession = { + child: ChildProcess | null + registering: boolean + terminationPromise: Promise | null +} + +const INTERRUPT_EXIT_CODES: Record = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 } +const INTERRUPT_SIGNALS = Object.keys(INTERRUPT_EXIT_CODES) as NodeJS.Signals[] + +function waitForClose(closePromise: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined + return Promise.race([ + closePromise.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs) + }) + ]).finally(() => { + if (timer) { + clearTimeout(timer) + } + }) +} + +function killDirectChild(child: ChildProcess, signal?: NodeJS.Signals): void { + try { + child.kill(signal) + } catch { + // The child may close between the timeout and fallback signal. + } +} + +export function terminateWindowsLoginProcessTree( + rootPid: number, + execFileImpl: typeof execFile = execFile +): Promise { + if (!Number.isInteger(rootPid) || rootPid <= 0) { + return Promise.resolve() + } + return new Promise((resolve) => { + execFileImpl( + 'taskkill', + ['/pid', String(rootPid), '/T', '/F'], + { timeout: WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS, windowsHide: true }, + () => resolve() + ) + }) +} + +/** + * Stops an interrupted interactive login before its credential directory is removed. + * The final close wait is bounded because a broken child handle must not hang Ctrl-C. + */ +export async function terminateInteractiveLoginProcess( + child: ChildProcess, + signal: NodeJS.Signals, + deps: TerminationDependencies = {} +): Promise { + let closed = false + let resolveClose: (() => void) | undefined + const onClose = (): void => { + closed = true + resolveClose?.() + } + const closePromise = new Promise((resolve) => { + resolveClose = resolve + }) + child.once('close', onClose) + + const platform = deps.platform ?? process.platform + const closeFallbackMs = deps.closeFallbackMs ?? LOGIN_PROCESS_CLOSE_FALLBACK_MS + try { + if (platform === 'win32') { + if (child.pid) { + const killTree = deps.killWindowsTree ?? terminateWindowsLoginProcessTree + await killTree(child.pid).catch(() => {}) + if (closed || (await waitForClose(closePromise, closeFallbackMs))) { + return + } + } + killDirectChild(child) + if (closed) { + return + } + await waitForClose(closePromise, closeFallbackMs) + return + } + + killDirectChild(child, signal) + const posixGraceMs = deps.posixGraceMs ?? LOGIN_PROCESS_POSIX_GRACE_MS + if (closed || (await waitForClose(closePromise, posixGraceMs))) { + return + } + killDirectChild(child, 'SIGKILL') + await waitForClose(closePromise, closeFallbackMs) + } finally { + child.off('close', onClose) + } +} + +/** + * Runs an account add with signal-safe cleanup and primary-error precedence. + */ +export async function withInteractiveLoginCleanup( + session: InteractiveLoginSession, + cleanup: () => Promise, + add: () => Promise +): Promise { + let cleanupPromise: Promise | null = null + let cleanupFailureReported = false + const cleanupOnce = (): Promise => + (cleanupPromise ??= (async () => { + await session.terminationPromise + await cleanup() + })()) + const reportCleanupFailure = (error: unknown): void => { + if (cleanupFailureReported) { + return + } + cleanupFailureReported = true + console.warn('[account] Failed to clean up the temporary login directory:', error) + } + const onSignal = (signal: NodeJS.Signals): void => { + if (session.child) { + session.terminationPromise ??= terminateInteractiveLoginProcess(session.child, signal) + } + if (session.registering) { + console.warn( + '[account] Interrupted after sign-in completed; the account may still have been registered. Run `orca account list` to check.' + ) + } + void cleanupOnce() + .catch(reportCleanupFailure) + .finally(() => process.exit(INTERRUPT_EXIT_CODES[signal] ?? 1)) + } + // Why: repeated signals must keep awaiting the same cleanup instead of restoring Node defaults. + for (const signal of INTERRUPT_SIGNALS) { + process.on(signal, onSignal) + } + let addFailed = false + let addError: unknown + let addResult: { value: T } | null = null + try { + addResult = { value: await add() } + } catch (error) { + addFailed = true + addError = error + } + let cleanupError: unknown + try { + await cleanupOnce() + } catch (error) { + cleanupError = error + } finally { + for (const signal of INTERRUPT_SIGNALS) { + process.off(signal, onSignal) + } + } + if (addFailed) { + if (cleanupError !== undefined) { + // Why: retain the error that explains the failed account add. + reportCleanupFailure(cleanupError) + } + throw addError + } + if (cleanupError !== undefined) { + throw cleanupError + } + return addResult!.value +} diff --git a/src/cli/handlers/skills.ts b/src/cli/handlers/skills.ts index 26e9ea5a734..7c911ea1751 100644 --- a/src/cli/handlers/skills.ts +++ b/src/cli/handlers/skills.ts @@ -3,14 +3,17 @@ import type { CommandHandler } from '../dispatch' import { RuntimeClientError } from '../runtime-client' import { delimiter, dirname } from 'node:path' import { getRepeatedStringFlag } from '../flags' -import { resolveCliCommand } from '../../main/codex-cli/command' -import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection' import { getTuiAgentDetectionProbeCommands, KNOWN_TUI_AGENT_DETECTION_COMMANDS, resolveDetectedTuiAgentIds -} from '../../main/ipc/tui-agent-detection-commands' -import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils' +} from '../../shared/tui-agent-detection-commands' +import { + getSpawnArgsForWindows, + UnsafeWindowsBatchArgumentsError +} from '../../shared/windows-batch-spawn' import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys' import { buildAgentFeatureSkillInstallArgs, diff --git a/src/cli/help.ts b/src/cli/help.ts index c9d98603c63..d85a318cb39 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -18,6 +18,10 @@ Diagnostics: Agent Discovery: agent-context Print the machine-readable command schema for agents +Accounts: + account add Add a managed Claude or Codex account on this Orca host + account list List managed Claude and Codex accounts on this Orca host + Skills: skills list List version-matched skill guides bundled with this Orca CLI skills get Print a version-matched skill guide as Markdown @@ -214,6 +218,8 @@ Common Commands: orca status [--json] orca diagnostics memory [--json] orca agent-context [--json] + orca account add [--agent claude|codex] [--json] + orca account list [--json] orca environment add --name --pairing-code [--json] orca environment list [--json] orca environment show --environment [--json] @@ -486,6 +492,11 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { if (command === 'orchestration task-create' && flag === 'display-name') { return '--display-name UI label shown for dispatched worker rows' } + // Why: the shared --agent help describes launching a TUI agent in a terminal, + // which is the wrong meaning here — this selects the account provider. + if (command === 'account add' && flag === 'agent') { + return '--agent Account provider: claude or codex (default claude)' + } if (flag === 'key' && command === 'computer hotkey') { return '--key Modifier chord with one key, e.g. CmdOrCtrl+A' } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 10b1205fe39..6eb064bee28 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -306,6 +306,20 @@ describe('orca root help', () => { logSpy.mockRestore() }) + it('advertises host-local account management', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main([], '/tmp/repo') + + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'account add Add a managed Claude or Codex account on this Orca host' + ) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'account list List managed Claude and Codex accounts on this Orca host' + ) + logSpy.mockRestore() + }) + it('advertises computer-use capabilities discovery', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/cli/index.ts b/src/cli/index.ts index 1ffe55a4702..cf4610cc850 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,7 @@ const COMMAND_PATHS = COMMAND_SPECS.flatMap((spec) => specPaths(spec)) function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { return ( + commandPath[0] === 'account' || commandPath[0] === 'environment' || commandPath[0] === 'serve' || commandPath[0] === 'agent' || diff --git a/src/cli/main-module-bundle-parity.test.ts b/src/cli/main-module-bundle-parity.test.ts new file mode 100644 index 00000000000..a109fcb9fdb --- /dev/null +++ b/src/cli/main-module-bundle-parity.test.ts @@ -0,0 +1,58 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(__dirname, '..', '..') +const CLI_ROOT = join(REPO_ROOT, 'src', 'cli') + +function listCliSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return listCliSourceFiles(path) + } + return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') + ? [path] + : [] + }) +} + +// Why: `import type` is erased by tsc, so it needs no emitted module at runtime. +const VALUE_IMPORT_FROM_MAIN = /(? { + const source = readFileSync(file, 'utf-8') + return [...source.matchAll(VALUE_IMPORT_FROM_MAIN)].map((match) => ({ + file: file.slice(REPO_ROOT.length + 1), + module: match[1] + })) + }) +} + +function findElectronViteMainEntries(): Set { + const config = readFileSync(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf-8') + return new Set( + // Why: entries wrap across lines once the path is long, so allow whitespace. + [...config.matchAll(/resolve\(\s*'src\/main\/([^']+)\.ts'\s*\)/g)].map((match) => match[1]) + ) +} + +describe('CLI imports of main-process modules', () => { + // Why: electron-vite cleans out/main and emits only its declared entries, so a + // `src/main/*` module the CLI imports but the config omits is deleted by the + // build that runs after `build:cli` — keep source-level feedback ahead of the + // final-artifact runtime verifier. + it('has an electron-vite entry for every main module the CLI imports', () => { + const entries = findElectronViteMainEntries() + const missing = findMainImports().filter(({ module }) => !entries.has(module)) + + expect(missing).toEqual([]) + }) + + it('finds the imports it is meant to guard', () => { + // Why: a broken matcher would make the guard above vacuously pass. + expect(findMainImports().length).toBeGreaterThanOrEqual(2) + expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 1cdee5aad4b..11caa30d704 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { delimiter } from 'node:path' -import type * as CodexCliCommandModule from '../main/codex-cli/command' +import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution' const { detectCommandsMock, @@ -19,12 +19,12 @@ const { // Why: agent detection probes the real machine, so pin it or every install // assertion depends on what the test runner happens to have installed. -vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({ +vi.mock('../shared/local-agent-install-dir-detection', () => ({ detectCommandsInInstallDirs: detectCommandsMock })) // Why: override only the npx lookup so the real Windows .cmd rail still runs. -vi.mock('../main/codex-cli/command', async (importOriginal) => ({ +vi.mock('../shared/node-cli-command-resolution', async (importOriginal) => ({ ...(await importOriginal()), resolveCliCommand: resolveCliCommandMock })) diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts new file mode 100644 index 00000000000..7e285cf3b97 --- /dev/null +++ b/src/cli/specs/account.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { ACCOUNT_COMMAND_SPECS } from './account' +import { effectiveAllowedFlags } from '../args' +import { formatCommandHelp } from '../help' + +function spec(path: string): (typeof ACCOUNT_COMMAND_SPECS)[number] { + const found = ACCOUNT_COMMAND_SPECS.find((entry) => entry.path.join(' ') === path) + if (!found) { + throw new Error(`Missing account spec: ${path}`) + } + return found +} + +describe('account command specs', () => { + it('does not accept or advertise browser page targeting', () => { + for (const entry of ACCOUNT_COMMAND_SPECS) { + expect(effectiveAllowedFlags(entry)).not.toContain('page') + expect(formatCommandHelp(entry)).not.toContain('--page') + } + }) + + // Why: named for what it asserts — the rendered Options block, not the `usage` + // string, which this test never reads. + it('renders --json and --help in its Options block', () => { + for (const entry of ACCOUNT_COMMAND_SPECS) { + const help = formatCommandHelp(entry) + expect(help).toContain('--json') + expect(help).toContain('--help') + } + }) + + it('describes --agent as the account provider, not a terminal agent', () => { + const help = formatCommandHelp(spec('account add')) + + expect(help).toContain('Account provider: claude or codex (default claude)') + expect(help).not.toContain('TUI agent') + }) + + it('aligns the --agent description with the global flag descriptions', () => { + const descriptionColumn = (help: string, flag: string): number => { + const line = help.split('\n').find((entry) => entry.startsWith(` --${flag}`)) + const match = line?.match(/^(\s*--\S+(?: <[^>]+>)?)(\s+)\S/) + if (!match) { + throw new Error(`No description found for --${flag}`) + } + return match[1].length + match[2].length + } + const help = formatCommandHelp(spec('account add')) + + expect(descriptionColumn(help, 'agent')).toBe(descriptionColumn(help, 'json')) + }) +}) diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts new file mode 100644 index 00000000000..69dd0896e8d --- /dev/null +++ b/src/cli/specs/account.ts @@ -0,0 +1,32 @@ +import { GLOBAL_FLAGS, type CommandSpec } from '../args' + +// Why: the desktop "Add account" button is disabled when the UI drives a remote +// runtime (a headless server). These commands run the interactive agent login +// (`claude login` / `codex login`) in the caller's own terminal on the host and +// register the captured account with the local runtime, giving headless hosts a +// way to manage Claude and Codex accounts. +export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['account', 'add'], + summary: 'Add a managed Claude or Codex account by signing in on this Orca host', + usage: 'orca account add [--agent claude|codex] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent'], + notes: [ + 'Runs the agent login (`claude login` / `codex login`) in this terminal, then registers the account with the local Orca runtime.', + 'Codex uses device authorization so the browser can complete sign-in from a different machine.', + 'Sign in with the account you want to add (e.g. use a private/incognito browser window for a second account).', + '--agent defaults to claude. Requires the Orca runtime to be running on this machine.' + ], + examples: ['orca account add', 'orca account add --agent codex'] + }, + { + path: ['account', 'list'], + summary: 'List managed Claude and Codex accounts on this Orca host', + usage: 'orca account list [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Lists the accounts on this machine. `--environment` / `--pairing-code` are rejected rather than ignored; run it on the host whose accounts you want to see.' + ], + examples: ['orca account list'] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 73df0dd4011..e779aa3b748 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -1,4 +1,5 @@ import type { CommandSpec } from '../args' +import { ACCOUNT_COMMAND_SPECS } from './account' import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced' import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { AUTOMATION_COMMAND_SPECS } from './automations' @@ -18,6 +19,7 @@ import { SKILL_COMMAND_SPECS } from './skills' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, + ...ACCOUNT_COMMAND_SPECS, ...PROJECT_COMMAND_SPECS, ...FILE_COMMAND_SPECS, ...AUTOMATION_COMMAND_SPECS, diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index e9801c3c489..85941007e12 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -15,14 +15,14 @@ function hasCode(error: unknown, code: string): boolean { return error instanceof Error && 'code' in error && error.code === code } -function parseLinuxStartTicks(statLine: string): string | null { - const commandEnd = statLine.lastIndexOf(') ') +export function parseLinuxStartTicks(statLine: string): string | null { + const commandEnd = statLine.lastIndexOf(')') if (commandEnd < 0) { return null } // Field 22 is index 19 after removing pid and the parenthesized command. const startTicks = statLine - .slice(commandEnd + 2) + .slice(commandEnd + 1) .trim() .split(/\s+/)[19] return startTicks ?? null @@ -115,7 +115,7 @@ async function readHostIdentity(): Promise { return runtimeHostIdentity } -async function readBootIdentity(): Promise { +export async function readBootIdentity(): Promise { if (process.platform === 'linux') { try { const bootId = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim() diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts index b69ec9998e6..946ed38a7ef 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-ui.test.ts @@ -570,6 +570,36 @@ describe('setupGuestShortcutForwarding', () => { expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:browserHistoryNavigate', 'forward') }) + it('forwards browser Find with its registered page and workspace owner', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + resolveWorkspaceId: () => 'workspace-1' + }) + + const preventDefault = triggerBeforeInput({ code: 'KeyF', key: 'f' }) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(rendererSendMock).toHaveBeenCalledWith('ui:findInBrowserPage', { + browserPageId: browserTabId, + browserWorkspaceId: 'workspace-1' + }) + }) + + it('does not broadcast browser Find without a registered workspace owner', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + const preventDefault = triggerBeforeInput({ code: 'KeyF', key: 'f' }) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(rendererSendMock).not.toHaveBeenCalled() + }) + it('forwards quick-command menu shortcuts from focused guest pages', () => { setupGuestShortcutForwarding({ browserTabId, diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 3e438c988cd..b9c52b39aa7 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -20,6 +20,7 @@ import { ModifierDoubleTapDetector, toModifierDoubleTapEvent } from '../../shared/modifier-double-tap-detector' +import type { BrowserFindSource } from '../../shared/browser-find-source' type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null type ShouldForwardDictationShortcut = () => boolean @@ -264,6 +265,7 @@ export function setupGuestShortcutForwarding(args: { getKeybindings?: () => KeybindingOverrides | undefined // Why: a floating-panel guest owns a distinct workspace; its close/index chords must route to the panel, not the main tab strip. resolveWorktreeId?: (browserTabId: string) => string | null + resolveWorkspaceId?: (browserTabId: string) => string | null }): () => void { const { browserTabId, @@ -272,7 +274,8 @@ export function setupGuestShortcutForwarding(args: { shouldForwardDictationShortcut, isMobileEmulatorEnabled, getKeybindings, - resolveWorktreeId + resolveWorktreeId, + resolveWorkspaceId } = args let ctrlTabSwitching = false const doubleTapDetector = new ModifierDoubleTapDetector() @@ -397,8 +400,15 @@ export function setupGuestShortcutForwarding(args: { // Why: forward soft reload so the renderer's reload() hits the parked-webview eviction the guest's built-in shortcut skips. renderer.send('ui:reloadBrowserPage') } else if (keybindingMatchesAction('browser.find', input, process.platform, keybindings)) { - // Why: guest-native find UI is invisible behind Orca's chrome; forward so the renderer opens its own find-in-page bar. - renderer.send('ui:findInBrowserPage') + const browserWorkspaceId = resolveWorkspaceId?.(browserTabId) + if (browserWorkspaceId) { + const source: BrowserFindSource = { + browserPageId: browserTabId, + browserWorkspaceId + } + // Why: active browser splits share one renderer; preserve the registered guest owner so only its Find bar opens. + renderer.send('ui:findInBrowserPage', source) + } } else if (keybindingMatchesAction('browser.back', input, process.platform, keybindings)) { // Why: macOS Logitech side-button remaps arrive as history keystrokes, not mouse events; forward so the renderer can goBack(). renderer.send('ui:browserHistoryNavigate', 'back') diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index 43652e6b9db..f1e5b650d13 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -195,6 +195,14 @@ describe('browserManager grab operations', () => { expect(selection.opId).toBe('op-1') }) + it('tears down an armed overlay when disabling before selection starts', async () => { + const result = await browserManager.setGrabMode('tab-1', false, guest) + + expect(result).toBe(true) + expect(guestExecuteJavaScriptMock).toHaveBeenCalledTimes(1) + expect(guestExecuteJavaScriptMock.mock.calls[0][0]).toContain('grab.cleanup()') + }) + it('returns false if injection fails', async () => { guestExecuteJavaScriptMock.mockRejectedValue(new Error('Injection failed')) const result = await browserManager.setGrabMode('tab-1', true, guest) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 6b51dc9a0dd..7dafca617f2 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1610,8 +1610,17 @@ export class BrowserManager { guest: Electron.WebContents ): Promise { if (!enabled) { + const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) this.cancelGrabOp(browserTabId, 'user') - return true + if (hadActiveGrabOp) { + return true + } + try { + await guest.executeJavaScript(buildGuestOverlayScript('teardown')) + return true + } catch { + return false + } } // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. try { @@ -1715,7 +1724,8 @@ export class BrowserManager { shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, getKeybindings: () => this.settingsResolver?.().keybindings, - resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null + resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, + resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null }) ) } diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts index dfbcd2322fe..6572bae7fca 100644 --- a/src/main/claude-accounts/service.test.ts +++ b/src/main/claude-accounts/service.test.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- test suite covers Claude capture and rollback edge cases */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' import { EventEmitter } from 'node:events' import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -15,9 +16,11 @@ import { writeManagedClaudeKeychainCredentials } from './keychain' +const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-test') + vi.mock('electron', () => ({ app: { - getPath: () => '/tmp/orca-claude-service-test' + getPath: () => CLAUDE_SERVICE_TEST_ROOT } })) @@ -171,7 +174,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores previous managed auth when reauth materialization fails', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -241,7 +244,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores settings without rematerializing when managed-auth rollback write fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -315,7 +318,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores oauth metadata when new credential write and credential rollback fail', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -393,7 +396,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores old metadata when rollback restores credentials but oauth restore fails', async () => { setPlatform('linux') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') const oauthPath = join(managedAuthPath, 'oauth-account.json') @@ -465,7 +468,7 @@ describe('ClaudeAccountService credential capture', () => { it('refreshes rate limits without recaching a removed active account', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -523,7 +526,7 @@ describe('ClaudeAccountService credential capture', () => { it('evicts inactive rate-limit cache after successful reauth', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -593,7 +596,7 @@ describe('ClaudeAccountService credential capture', () => { it('adds an account without switching the active Claude auth while PTYs are live', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') mkdirSync(hostAuthPath, { recursive: true }) @@ -675,9 +678,93 @@ describe('ClaudeAccountService credential capture', () => { ) }) + it('reports the original add failure and still removes managed auth when rollback rematerialization fails', async () => { + // Why: this is the desktop add path. Previously the rollback's rematerialize + // was unguarded, so when it threw it replaced the real add error and skipped + // safeRemoveManagedAuth, leaking the throwaway auth dir. + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => { + throw new Error('rematerialize failed') + }) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: null, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + ;(service as unknown as { writeManagedAuth(): Promise }).writeManagedAuth = vi.fn( + async () => { + throw new Error('managed auth write failed') + } + ) + + await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'managed auth write failed' + ) + + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + expect(settings.claudeManagedAccounts).toHaveLength(1) + // Why: the throwaway account directory must be gone even though rollback threw. + expect(readdirSync(join(tempDir, 'claude-accounts'))).toEqual(['host-account']) + warn.mockRestore() + }) + it('rejects adding a Claude account whose identity already exists', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') mkdirSync(existingAuthPath, { recursive: true }) @@ -756,7 +843,7 @@ describe('ClaudeAccountService credential capture', () => { it('adds a Claude account with the same email under a different organization', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') mkdirSync(existingAuthPath, { recursive: true }) @@ -830,7 +917,7 @@ describe('ClaudeAccountService credential capture', () => { it('switches the active Claude account while PTYs are live', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') @@ -912,7 +999,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores the previous selection when a Claude account switch fails', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') @@ -988,7 +1075,7 @@ describe('ClaudeAccountService credential capture', () => { it('selects a WSL account without changing the Windows active account', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') @@ -1075,7 +1162,7 @@ describe('ClaudeAccountService credential capture', () => { it('rejects selecting a WSL account for the Windows target', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') mkdirSync(wslAuthPath, { recursive: true }) @@ -1126,7 +1213,7 @@ describe('ClaudeAccountService credential capture', () => { it('removes a WSL account without clearing the Windows active account', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') @@ -1730,3 +1817,218 @@ describe('ClaudeAccountService credential capture', () => { } }) }) + +describe('ClaudeAccountService.addAccountFromConfigDir', () => { + const managedRoot = CLAUDE_SERVICE_TEST_ROOT + let sourceDir: string | null = null + + beforeEach(() => { + setPlatform('linux') + rmSync(managedRoot, { recursive: true, force: true }) + sourceDir = null + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() + vi.mocked(writeManagedClaudeKeychainCredentials).mockReset().mockResolvedValue() + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + rmSync(managedRoot, { recursive: true, force: true }) + if (sourceDir) { + rmSync(sourceDir, { recursive: true, force: true }) + } + }) + + function makeDeps() { + let settings = { + claudeManagedAccounts: [] as ClaudeManagedAccount[], + activeClaudeManagedAccountId: null as string | null, + activeClaudeManagedAccountIdsByRuntime: { host: null as string | null, wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const rateLimits = { evictInactiveClaudeCache: vi.fn() } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + return { store, rateLimits, runtimeAuth, getSettings: () => settings } + } + + it('registers a managed account by capturing an authenticated config dir', async () => { + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-')) + writeFileSync( + join(sourceDir, '.credentials.json'), + '{"claudeAiOauth":{"accessToken":"tok"}}\n', + 'utf-8' + ) + writeFileSync( + join(sourceDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), + 'utf-8' + ) + + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + // Why: avoid spawning a real `claude auth status` subprocess in the test. + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"new@example.com"}' + ) + + const result = await service.addAccountFromConfigDir(sourceDir) + + const accounts = deps.getSettings().claudeManagedAccounts + expect(accounts).toHaveLength(1) + expect(accounts[0].email).toBe('new@example.com') + expect(result.accounts[0]?.email).toBe('new@example.com') + expect(readFileSync(join(accounts[0].managedAuthPath, '.credentials.json'), 'utf-8')).toBe( + '{"claudeAiOauth":{"accessToken":"tok"}}\n' + ) + expect(deps.runtimeAuth.clearLastWrittenCredentialsJson).toHaveBeenCalledWith(accounts[0].id) + }) + + it('captures only the config-scoped macOS Keychain credential', async () => { + setPlatform('darwin') + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-')) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => + configDir ? '{"claudeAiOauth":{"accessToken":"scoped"}}' : 'legacy-credentials' + ) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"new@example.com"}' + ) + + await service.addAccountFromConfigDir(sourceDir) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(sourceDir) + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + expect.any(String), + '{"claudeAiOauth":{"accessToken":"scoped"}}' + ) + }) + + it('does not mistake an unchanged legacy Keychain credential for the temp login', async () => { + setPlatform('darwin') + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-empty-')) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => + configDir ? null : 'legacy-credentials' + ) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"existing@example.com"}' + ) + + await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( + 'no OAuth credentials were captured' + ) + expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) + }) + + it('captures a legacy Keychain credential that changed after login began', async () => { + setPlatform('darwin') + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-legacy-')) + const previousCredentials = '{"claudeAiOauth":{"accessToken":"previous"}}' + const newCredentials = '{"claudeAiOauth":{"accessToken":"new","email":"new@example.com"}}' + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => + configDir ? null : newCredentials + ) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"new@example.com"}' + ) + + await service.addAccountFromConfigDir(sourceDir, { + previousLegacyCredentialsSha256: createHash('sha256') + .update(previousCredentials) + .digest('hex') + }) + + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + expect.any(String), + newCredentials + ) + expect(deps.getSettings().claudeManagedAccounts[0]?.email).toBe('new@example.com') + }) + + it('still registers when the daemon cannot spawn `claude auth status`', async () => { + // Why: `allowFailure` covers a non-zero exit but not a spawn error, so a daemon + // started with a minimal PATH would hard-fail an add the user already signed in for. + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-nostatus-')) + writeFileSync( + join(sourceDir, '.credentials.json'), + '{"claudeAiOauth":{"accessToken":"tok"}}\n', + 'utf-8' + ) + writeFileSync( + join(sourceDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), + 'utf-8' + ) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => { + throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) + } + ) + + const result = await service.addAccountFromConfigDir(sourceDir) + + expect(result.accounts[0]?.email).toBe('new@example.com') + expect(deps.getSettings().claudeManagedAccounts).toHaveLength(1) + warn.mockRestore() + }) + + it('rejects and rolls back when the config dir has no credentials', async () => { + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-empty-')) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + + await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( + /No Claude credentials found/ + ) + expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) + expect(deps.runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + }) +}) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 42601c6ab56..3e3a8ec5f47 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: Claude managed accounts need one audited owner for login, credential capture, Keychain storage, selection, and rate-limit refresh. */ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -76,6 +76,10 @@ export type ClaudeAccountAddTarget = { wslDistro?: string | null } +export type ClaudeAccountImportOptions = ClaudeAccountAddTarget & { + previousLegacyCredentialsSha256?: string | null +} + type ManagedClaudeAuthLocation = { managedAuthPath: string managedAuthRuntime: 'host' | 'wsl' @@ -83,6 +87,8 @@ type ManagedClaudeAuthLocation = { wslLinuxAuthPath: string | null } +class DuplicateClaudeAccountError extends Error {} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } @@ -106,6 +112,19 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.doAddAccount(target)) } + /** + * Adds a managed Claude account from an already-authenticated `CLAUDE_CONFIG_DIR` + * instead of driving the interactive browser login here. Enables the + * `orca account add` CLI to run `claude login` in the user's own terminal on a + * headless host, then register the captured credentials without a desktop GUI. + */ + async addAccountFromConfigDir( + configDir: string, + options?: ClaudeAccountImportOptions + ): Promise { + return this.serializeMutation(() => this.doAddAccountFromConfigDir(configDir, options)) + } + async reauthenticateAccount(accountId: string): Promise { return this.serializeMutation(() => this.doReauthenticateAccount(accountId)) } @@ -140,67 +159,173 @@ export class ClaudeAccountService { ): Promise { const accountId = randomUUID() const managedAuth = this.createManagedAuthDir(accountId, target) - const { managedAuthPath } = managedAuth const previousSettings = this.store.getSettings() - let duplicateIdentityFound = false - try { const captured = await this.runClaudeLoginAndCapture(managedAuth) - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') - } - // Why: duplicate rows confuse account selection and rate-limit tracking; - // the per-row Re-authenticate action already refreshes credentials. - if ( - findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { - email: captured.identity.email, - organizationUuid: captured.identity.organizationUuid, - managedAuthRuntime: managedAuth.managedAuthRuntime, - wslDistro: managedAuth.wslDistro - }) - ) { - duplicateIdentityFound = true - throw new Error('This Claude account is already added.') - } - await this.writeManagedAuth(accountId, managedAuthPath, captured) - - const now = Date.now() - const account: ClaudeManagedAccount = { - id: accountId, - email: captured.identity.email, - managedAuthPath, - managedAuthRuntime: managedAuth.managedAuthRuntime, - wslDistro: managedAuth.wslDistro, - wslLinuxAuthPath: managedAuth.wslLinuxAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - createdAt: now, - updatedAt: now, - lastAuthenticatedAt: now - } - - const selection = normalizeClaudeRuntimeSelection(previousSettings) - this.store.updateSettings({ - claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], - activeClaudeManagedAccountId: selection.host, - activeClaudeManagedAccountIdsByRuntime: selection - }) - this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.rateLimits.evictInactiveClaudeCache(accountId) - return this.getSnapshot() + return await this.persistCapturedClaudeAccount( + accountId, + managedAuth, + previousSettings, + captured + ) } catch (error) { - // Duplicate detection precedes every credential/settings write, so only - // its throwaway auth directory needs cleanup. - if (!duplicateIdentityFound) { - this.restoreClaudeSettings(previousSettings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } - await this.safeRemoveManagedAuth(accountId, managedAuthPath) + await this.cleanupFailedAdd(accountId, managedAuth.managedAuthPath, previousSettings, error) throw error } } + private async doAddAccountFromConfigDir( + configDir: string, + options?: ClaudeAccountImportOptions + ): Promise { + const accountId = randomUUID() + const managedAuth = this.createManagedAuthDir(accountId, options) + const previousSettings = this.store.getSettings() + try { + const captured = await this.captureFromExistingConfigDir( + configDir, + options?.previousLegacyCredentialsSha256 + ) + return await this.persistCapturedClaudeAccount( + accountId, + managedAuth, + previousSettings, + captured + ) + } catch (error) { + await this.cleanupFailedAdd(accountId, managedAuth.managedAuthPath, previousSettings, error) + throw error + } + } + + // Why: capture credentials from a CLAUDE_CONFIG_DIR the caller already + // authenticated (e.g. a temp dir the CLI ran `claude login` into), mirroring + // runClaudeLoginAndCapture's capture step but without spawning the interactive + // login. On Linux/Windows the credentials live in a plaintext `.credentials.json`. + private async captureFromExistingConfigDir( + configDir: string, + previousLegacyCredentialsSha256?: string | null + ): Promise { + const trimmed = configDir.trim() + if (!trimmed) { + throw new Error('A Claude config directory path is required.') + } + const resolvedDir = resolve(trimmed) + // Why: macOS keeps Claude credentials in the Keychain rather than a file, so + // only require `.credentials.json` off-darwin; captureAuthFromConfigDir reads + // the scoped Keychain item on macOS. + if (process.platform !== 'darwin' && !existsSync(join(resolvedDir, '.credentials.json'))) { + throw new Error( + `No Claude credentials found in ${resolvedDir}. Run \`claude login\` into this directory first.` + ) + } + // Why: `allowFailure` covers a non-zero exit but not a spawn error, and unlike + // the GUI flow nothing has run `claude` in this process yet — a daemon started + // with a minimal PATH (launchd/systemd) would hard-fail an add the user already + // signed in for. Identity still resolves from the config dir's oauthAccount. + let status = '' + try { + status = await this.runClaudeCommand( + ['auth', 'status', '--json'], + { windowsPath: resolvedDir, linuxPath: null, wslDistro: null }, + STATUS_TIMEOUT_MS, + { allowFailure: true } + ) + } catch (error) { + console.warn('[claude-accounts] Could not read `claude auth status`:', error) + } + // Why: this post-login RPC did not observe the legacy Keychain value before + // login unless the CLI supplied its one-way pre-login credential baseline. + const currentLegacyKeychain = await readActiveClaudeKeychainCredentialsStrict() + return this.captureAuthFromConfigDir( + resolvedDir, + status, + currentLegacyKeychain, + previousLegacyCredentialsSha256 + ) + } + + private async persistCapturedClaudeAccount( + accountId: string, + managedAuth: ManagedClaudeAuthLocation, + previousSettings: ReturnType, + captured: CapturedClaudeAuth + ): Promise { + if (!captured.identity.email) { + throw new Error('Claude login completed, but Orca could not resolve the account email.') + } + // Why: duplicate rows confuse selection and rate-limit tracking; re-authentication + // is the supported way to refresh an account that is already managed. + if ( + findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { + email: captured.identity.email, + organizationUuid: captured.identity.organizationUuid, + managedAuthRuntime: managedAuth.managedAuthRuntime, + wslDistro: managedAuth.wslDistro + }) + ) { + throw new DuplicateClaudeAccountError('This Claude account is already added.') + } + await this.writeManagedAuth(accountId, managedAuth.managedAuthPath, captured) + + const now = Date.now() + const account: ClaudeManagedAccount = { + id: accountId, + email: captured.identity.email, + managedAuthPath: managedAuth.managedAuthPath, + managedAuthRuntime: managedAuth.managedAuthRuntime, + wslDistro: managedAuth.wslDistro, + wslLinuxAuthPath: managedAuth.wslLinuxAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: captured.identity.organizationUuid, + organizationName: captured.identity.organizationName, + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now + } + + const selection = normalizeClaudeRuntimeSelection(previousSettings) + this.store.updateSettings({ + claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], + activeClaudeManagedAccountId: selection.host, + activeClaudeManagedAccountIdsByRuntime: selection + }) + this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) + this.rateLimits.evictInactiveClaudeCache(accountId) + return this.getSnapshot() + } + + private async rollbackAddAccount( + accountId: string, + managedAuthPath: string, + previousSettings: ReturnType + ): Promise { + this.restoreClaudeSettings(previousSettings) + // Why: rollback is best-effort — a failed rematerialization must not skip the + // managed-auth cleanup below, and the caller rethrows the original add error. + try { + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + } catch (rollbackError) { + console.warn('[claude-accounts] Rollback rematerialization failed:', rollbackError) + } + await this.safeRemoveManagedAuth(accountId, managedAuthPath) + } + + private async cleanupFailedAdd( + accountId: string, + managedAuthPath: string, + previousSettings: ReturnType, + error: unknown + ): Promise { + if (error instanceof DuplicateClaudeAccountError) { + // Why: duplicate detection precedes writes; rollback I/O could only mask + // the useful duplicate-account error. + await this.safeRemoveManagedAuth(accountId, managedAuthPath) + return + } + await this.rollbackAddAccount(accountId, managedAuthPath, previousSettings) + } + private async doReauthenticateAccount(accountId: string): Promise { const account = this.requireAccount(accountId) const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath, accountId) @@ -587,9 +712,14 @@ export class ClaudeAccountService { private async captureAuthFromConfigDir( configDir: string, statusOutput: string, - previousLegacyKeychain: string | null + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null ): Promise { - const credentialsJson = await this.readCapturedCredentials(configDir, previousLegacyKeychain) + const credentialsJson = await this.readCapturedCredentials( + configDir, + previousLegacyKeychain, + previousLegacyCredentialsSha256 + ) if (!credentialsJson) { throw new Error('Claude login completed, but no OAuth credentials were captured.') } @@ -600,7 +730,8 @@ export class ClaudeAccountService { private async readCapturedCredentials( configDir: string, - previousLegacyKeychain: string | null + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null ): Promise { if (process.platform === 'darwin') { const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir) @@ -608,7 +739,13 @@ export class ClaudeAccountService { return scopedCredentialsJson } const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict() - if (legacyCredentialsJson && legacyCredentialsJson !== previousLegacyKeychain) { + const legacyChanged = + previousLegacyCredentialsSha256 === undefined + ? legacyCredentialsJson !== previousLegacyKeychain + : legacyCredentialsJson !== null && + createHash('sha256').update(legacyCredentialsJson).digest('hex') !== + previousLegacyCredentialsSha256 + if (legacyCredentialsJson && legacyChanged) { return legacyCredentialsJson } } diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index c2ae74b8911..175a42e6dbd 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -804,6 +804,11 @@ describe('CodexAccountService config sync', () => { expect(spawnMock).toHaveBeenCalledTimes(1) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) + // Why: the desktop add path must pass the new account's selection target, as + // reauthenticate and select already do. Called with no argument, a WSL add + // syncs the host home that did not change and never materializes the WSL + // slot that did. + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) }) it('does not seed source-home hook trust when adding a self-contained account', async () => { @@ -1372,6 +1377,12 @@ describe('CodexAccountService config sync', () => { wslLinuxHomePath, managedHomeRuntime: 'wsl' }) + // Why: a WSL add must sync the WSL runtime home, not the default host lane, + // or the account it just selected stays unmaterialized until the next switch. + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Debian' + }) } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -3860,3 +3871,124 @@ describe('CodexAccountService config sync', () => { }) }) }) + +describe('CodexAccountService.addAccountFromHome', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-accounts-')) + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) + testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = testState.userDataDir + mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true }) + }) + + afterEach(() => { + rmSync(testState.userDataDir, { recursive: true, force: true }) + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath + } + }) + + it('registers a managed Codex account by importing an authenticated CODEX_HOME', async () => { + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + const sourceHome = mkdtempSync(join(tmpdir(), 'orca-codex-source-')) + writeFileSync( + join(sourceHome, 'auth.json'), + createCodexAuthJson('new@example.com', 'provider-account-1', 'refresh-token'), + 'utf-8' + ) + + try { + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.addAccountFromHome(sourceHome) + + expect(result.accounts).toHaveLength(1) + expect(result.accounts[0]?.email).toBe('new@example.com') + const managedHomePath = store.getSettings().codexManagedAccounts[0].managedHomePath + expect(existsSync(join(managedHomePath, 'auth.json'))).toBe(true) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + } finally { + rmSync(sourceHome, { recursive: true, force: true }) + vi.doUnmock('../codex-cli/command') + } + }) + + it('restores settings and runtime selection when post-write activation fails', async () => { + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + const sourceHome = mkdtempSync(join(tmpdir(), 'orca-codex-source-rollback-')) + writeFileSync( + join(sourceHome, 'auth.json'), + createCodexAuthJson('new@example.com', 'provider-account-1', 'refresh-token'), + 'utf-8' + ) + + try { + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + let managedHomePath: string | null = null + runtimeHome.syncForCurrentSelection.mockImplementationOnce(() => { + managedHomePath = store.getSettings().codexManagedAccounts[0]?.managedHomePath ?? null + throw new Error('activation failed') + }) + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + await expect(service.addAccountFromHome(sourceHome)).rejects.toThrow('activation failed') + + expect(store.getSettings().codexManagedAccounts).toHaveLength(0) + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(2) + expect(managedHomePath).not.toBeNull() + expect(existsSync(managedHomePath!)).toBe(false) + } finally { + rmSync(sourceHome, { recursive: true, force: true }) + vi.doUnmock('../codex-cli/command') + } + }) + + it('rejects when the source home has no auth.json', async () => { + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + const sourceHome = mkdtempSync(join(tmpdir(), 'orca-codex-source-empty-')) + + try { + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + await expect(service.addAccountFromHome(sourceHome)).rejects.toThrow( + /No Codex credentials found/ + ) + expect(store.getSettings().codexManagedAccounts).toHaveLength(0) + } finally { + rmSync(sourceHome, { recursive: true, force: true }) + vi.doUnmock('../codex-cli/command') + } + }) +}) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 677d6d569db..3618f138144 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -285,6 +285,19 @@ export class CodexAccountService { return this.serializeMutation(() => this.doAddAccount(target)) } + /** + * Registers a managed Codex account from an already-authenticated `CODEX_HOME` + * instead of driving `codex login` here. Lets the `orca account add --agent codex` + * CLI run the login in the user's own terminal on a headless host and then import + * the captured `auth.json` into managed storage. + */ + async addAccountFromHome( + sourceHome: string, + target?: CodexAccountAddTarget + ): Promise { + return this.serializeMutation(() => this.doAddAccountFromHome(sourceHome, target)) + } + async reauthenticateAccount(accountId: string): Promise { return this.serializeMutation(() => this.doReauthenticateAccount(accountId)) } @@ -708,60 +721,133 @@ export class CodexAccountService { const accountId = randomUUID() const managedHome = this.createManagedHome(accountId, target) const { managedHomePath } = managedHome - try { const canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) this.assertOAuthAccountAddAllowed(canonicalConfig) this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig, accountId) await this.runCodexLogin(managedHomePath) - const identity = this.readIdentityFromHome(managedHomePath, accountId) - if (!identity.email) { - throw new Error('Codex login completed, but Orca could not resolve the account email.') - } - - const now = Date.now() - const account: CodexManagedAccount = { - id: accountId, - email: identity.email, - managedHomePath, - managedHomeRuntime: managedHome.managedHomeRuntime, - wslDistro: managedHome.wslDistro, - wslLinuxHomePath: managedHome.wslLinuxHomePath, - providerAccountId: identity.providerAccountId, - workspaceLabel: identity.workspaceLabel, - workspaceAccountId: identity.workspaceAccountId, - createdAt: now, - updatedAt: now, - lastAuthenticatedAt: now - } - - const settings = this.store.getSettings() - const selection = normalizeCodexRuntimeSelection(settings) - const targetSelection = getCodexSelectionTargetForAccount(account) - this.store.updateSettings({ - codexManagedAccounts: [...settings.codexManagedAccounts, account], - activeCodexManagedAccountId: - targetSelection.runtime === 'host' ? account.id : selection.host, - activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( - selection, - account.id, - targetSelection - ) - }) - this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.clearLastWrittenAuthJson(account.id) - this.runtimeHome.syncForCurrentSelection() - - // Why: switching activates the new account, so cache the outgoing account's usage for the switcher. - const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) - this.startQuotaRefreshInBackground(outgoingAccountId, targetSelection) - return this.getSnapshot() + return await this.persistCapturedCodexAccount(accountId, managedHome) } catch (error) { this.safeRemoveManagedHome(managedHomePath, accountId) throw error } } + private async doAddAccountFromHome( + sourceHome: string, + target?: CodexAccountAddTarget + ): Promise { + const accountId = randomUUID() + const managedHome = this.createManagedHome(accountId, target) + const { managedHomePath } = managedHome + try { + const canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) + this.assertOAuthAccountAddAllowed(canonicalConfig) + this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig, accountId) + this.importCodexAuthFromHome(sourceHome, managedHomePath, accountId) + return await this.persistCapturedCodexAccount(accountId, managedHome) + } catch (error) { + this.safeRemoveManagedHome(managedHomePath, accountId) + throw error + } + } + + // Why: copy the auth.json from an already-authenticated CODEX_HOME (e.g. a temp + // dir the CLI ran `codex login` into) into the managed home. Mirrors the login + // step of doAddAccount without spawning an interactive browser flow. + private importCodexAuthFromHome( + sourceHome: string, + managedHomePath: string, + accountId: string + ): void { + const trimmed = sourceHome.trim() + if (!trimmed) { + throw new Error('A Codex home directory path is required.') + } + const authPath = join(resolve(trimmed), 'auth.json') + if (!existsSync(authPath)) { + throw new Error( + `No Codex credentials found in ${resolve(trimmed)}. Run \`codex login\` into this directory first.` + ) + } + const trustedHome = this.assertManagedHomePath(managedHomePath, accountId) + writeFileAtomically(join(trustedHome, 'auth.json'), readFileSync(authPath, 'utf-8'), { + mode: 0o600 + }) + } + + private async persistCapturedCodexAccount( + accountId: string, + managedHome: ManagedHomeLocation + ): Promise { + const identity = this.readIdentityFromHome(managedHome.managedHomePath, accountId) + if (!identity.email) { + throw new Error('Codex login completed, but Orca could not resolve the account email.') + } + + const now = Date.now() + const account: CodexManagedAccount = { + id: accountId, + email: identity.email, + managedHomePath: managedHome.managedHomePath, + managedHomeRuntime: managedHome.managedHomeRuntime, + wslDistro: managedHome.wslDistro, + wslLinuxHomePath: managedHome.wslLinuxHomePath, + providerAccountId: identity.providerAccountId, + workspaceLabel: identity.workspaceLabel, + workspaceAccountId: identity.workspaceAccountId, + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now + } + + const settings = this.store.getSettings() + const selection = normalizeCodexRuntimeSelection(settings) + const targetSelection = getCodexSelectionTargetForAccount(account) + this.store.updateSettings({ + codexManagedAccounts: [...settings.codexManagedAccounts, account], + activeCodexManagedAccountId: targetSelection.runtime === 'host' ? account.id : selection.host, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + selection, + account.id, + targetSelection + ) + }) + try { + this.safeSyncCanonicalConfigToManagedHomes() + this.runtimeHome.clearLastWrittenAuthJson(account.id) + // Why: pass the account's selection target so a WSL account syncs the WSL + // runtime home instead of the default host target. + this.runtimeHome.syncForCurrentSelection(targetSelection) + } catch (error) { + // Why: settings were already written; if a post-write step fails, restore the + // previous account/selection so the caller's managed-home cleanup cannot leave + // a dangling, broken managed account behind in settings. + this.store.updateSettings({ + codexManagedAccounts: settings.codexManagedAccounts, + activeCodexManagedAccountId: settings.activeCodexManagedAccountId, + activeCodexManagedAccountIdsByRuntime: settings.activeCodexManagedAccountIdsByRuntime + }) + // Why: a failed post-write step must restore both persisted selection and + // the runtime home it drives before the new managed home is removed. + try { + this.runtimeHome.syncForCurrentSelection(targetSelection) + } catch (rollbackError) { + console.warn( + '[codex-accounts] Failed to restore runtime home during rollback:', + rollbackError + ) + } + throw error + } + + // Why: switching activates the new account, so cache the outgoing account's usage for the + // switcher — in the background, since the probe must never block or fail a durable add. + const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) + this.startQuotaRefreshInBackground(outgoingAccountId, targetSelection) + return this.getSnapshot() + } + private async doReauthenticateAccount(accountId: string): Promise { const account = this.requireAccount(accountId) const managedHomePath = this.ensureManagedHomeForReauthentication(account) diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index 177b7e0ea9c..61e08eb457e 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -1,226 +1 @@ -import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' -import { homedir } from 'node:os' -import { delimiter, dirname, join } from 'node:path' - -type ResolveCommandOptions = { - pathEnv?: string | null - platform?: NodeJS.Platform - homePath?: string -} - -function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { - if (platform === 'win32') { - return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName] - } - - return [commandName] -} - -function splitPath(pathEnv: string | null | undefined): string[] { - if (!pathEnv) { - return [] - } - - return pathEnv - .split(delimiter) - .map((entry) => entry.trim()) - .filter(Boolean) -} - -function parseVersionSegment(raw: string): number[] { - return raw - .replace(/^v/i, '') - .split('.') - .map((segment) => Number.parseInt(segment, 10)) - .map((segment) => (Number.isFinite(segment) ? segment : 0)) -} - -function compareVersionDesc(left: string, right: string): number { - const leftParts = parseVersionSegment(left) - const rightParts = parseVersionSegment(right) - const length = Math.max(leftParts.length, rightParts.length) - - for (let index = 0; index < length; index += 1) { - const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) - if (delta !== 0) { - return delta - } - } - - return right.localeCompare(left) -} - -function findFirstExecutable( - platform: NodeJS.Platform, - directories: string[], - executableNames: string[] -): string | null { - for (const directory of directories) { - for (const executableName of executableNames) { - const candidate = join(directory, executableName) - if (isRunnableCommand(platform, candidate)) { - return candidate - } - } - } - - return null -} - -function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { - try { - const stats = statSync(candidate) - if (!stats.isFile()) { - return false - } - if (platform === 'win32') { - return true - } - // Why: GUI fallback probing should skip placeholders/directories so spawn - // can continue to a runnable CLI instead of failing later with EACCES/EISDIR. - accessSync(candidate, constants.X_OK) - return true - } catch { - return false - } -} - -function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] { - const directories = [ - join(homePath, '.volta', 'bin'), - join(homePath, '.asdf', 'shims'), - join(homePath, '.fnm', 'aliases', 'default', 'bin'), - // Why: mise (formerly rtx) exposes managed tool binaries via a shims - // directory, similar to asdf. Without this, users who installed node - // or CLI tools through mise can't be found by the fallback probe. - join(homePath, '.local', 'share', 'mise', 'shims') - ] - - if (platform === 'win32') { - // Why: Anthropic's native Windows installer places claude.exe here, and - // GUI-launched Orca may not inherit the user's PATH entry for it. - directories.push(join(homePath, '.local', 'bin')) - directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) - directories.push(join(homePath, 'AppData', 'Local', 'pnpm')) - directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin')) - } else { - directories.push(join(homePath, '.local', 'bin')) - // Why: pnpm uses platform-specific global bin directories that differ from - // npm's ~/.local/bin. macOS follows the ~/Library convention while Linux - // uses the XDG-compatible ~/.local/share path. Without these, users who - // installed via `pnpm add -g` can't be found by the fallback probe. - if (platform === 'darwin') { - directories.push(join(homePath, 'Library', 'pnpm')) - } else { - directories.push(join(homePath, '.local', 'share', 'pnpm')) - } - directories.push(join(homePath, '.yarn', 'bin')) - } - - // Why: bun uses ~/.bun/bin on all platforms for globally installed packages. - directories.push(join(homePath, '.bun', 'bin')) - - return directories -} - -function getNvmVersionDirectories(homePath: string): string[] { - const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node') - if (!existsSync(nvmVersionsDir)) { - return [] - } - - return readdirSync(nvmVersionsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(compareVersionDesc) - .map((entry) => join(nvmVersionsDir, entry, 'bin')) -} - -function getVersionManagerDirectories( - platform: NodeJS.Platform, - homePath: string, - executableNames: string[] -): string[] { - const directories = getBaseVersionManagerDirectories(platform, homePath) - - // Why: GUI-launched Electron apps do not inherit shell init from nvm, so - // command resolution probes the newest installed Node versions explicitly. - const firstNvmMatch = findFirstExecutable( - platform, - getNvmVersionDirectories(homePath), - executableNames - ) - if (firstNvmMatch) { - directories.unshift(dirname(firstNvmMatch)) - } - - return directories -} - -export function resolveCliCommand( - commandName: string, - options: ResolveCommandOptions = {} -): string { - const platform = options.platform ?? process.platform - const executableNames = getExecutableNames(platform, commandName) - const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null - const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames) - if (pathCandidate) { - return pathCandidate - } - - const homePath = options.homePath ?? homedir() - const versionManagerCandidate = findFirstExecutable( - platform, - getVersionManagerDirectories(platform, homePath, executableNames), - executableNames - ) - return versionManagerCandidate ?? commandName -} - -export function resolveCliCommands( - commandNames: readonly string[], - options: ResolveCommandOptions = {} -): Map { - const platform = options.platform ?? process.platform - const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null - const pathDirectories = splitPath(pathEnv) - const homePath = options.homePath ?? homedir() - // Why: agent detection probes many CLIs at once; compute expensive install - // directories, especially nvm versions, once per detection pass. - const installDirectories = [ - ...getNvmVersionDirectories(homePath), - ...getBaseVersionManagerDirectories(platform, homePath) - ] - const resolved = new Map() - - for (const commandName of new Set(commandNames)) { - const executableNames = getExecutableNames(platform, commandName) - const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames) - const installCandidate = - pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames) - resolved.set(commandName, installCandidate ?? commandName) - } - - return resolved -} - -export function resolveCodexCommand(options: ResolveCommandOptions = {}): string { - return resolveCliCommand('codex', options) -} - -export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string { - return resolveCliCommand('claude', options) -} - -// Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node -// version manager directories. CLI tools like codex/claude are Node scripts -// with #!/usr/bin/env node shebangs — they need `node` in PATH to execute, -// not just to be *found*. This function returns the version manager bin paths -// so the caller can augment process.env.PATH at startup. -export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] { - const platform = options.platform ?? process.platform - const homePath = options.homePath ?? homedir() - const nodeNames = getExecutableNames(platform, 'node') - return getVersionManagerDirectories(platform, homePath, nodeNames) -} +export * from '../../shared/node-cli-command-resolution' diff --git a/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts b/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts index 4a23aaa68ee..e8c8fe8ee21 100644 --- a/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts +++ b/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts @@ -22,13 +22,13 @@ describe('promptForGpuFallbackRestart', () => { await expect(promptForGpuFallbackRestart(parentWindow as never)).resolves.toBe('restart') expect(showMessageBoxMock).toHaveBeenCalledWith(parentWindow, { type: 'warning', - buttons: ['Restart with Software Rendering', 'Keep Running'], + buttons: ['Restart in Safe Graphics Mode', 'Keep Running'], defaultId: 0, cancelId: 1, - title: 'Restart Orca?', + title: 'Restart Orca in Safe Graphics Mode?', message: "Orca's graphics process has crashed repeatedly.", detail: - 'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.' + 'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.' }) }) diff --git a/src/main/crash-reporting/gpu-fallback-restart-prompt.ts b/src/main/crash-reporting/gpu-fallback-restart-prompt.ts index 52235faecf3..3c53089867b 100644 --- a/src/main/crash-reporting/gpu-fallback-restart-prompt.ts +++ b/src/main/crash-reporting/gpu-fallback-restart-prompt.ts @@ -4,13 +4,13 @@ export type GpuFallbackRestartDecision = 'restart' | 'continue' const GPU_FALLBACK_RESTART_OPTIONS: MessageBoxOptions = { type: 'warning', - buttons: ['Restart with Software Rendering', 'Keep Running'], + buttons: ['Restart in Safe Graphics Mode', 'Keep Running'], defaultId: 0, cancelId: 1, - title: 'Restart Orca?', + title: 'Restart Orca in Safe Graphics Mode?', message: "Orca's graphics process has crashed repeatedly.", detail: - 'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.' + 'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.' } export async function promptForGpuFallbackRestart( diff --git a/src/main/daemon/daemon-audit-classifier.ts b/src/main/daemon/daemon-audit-classifier.ts new file mode 100644 index 00000000000..4642acb7191 --- /dev/null +++ b/src/main/daemon/daemon-audit-classifier.ts @@ -0,0 +1,216 @@ +import { lstat } from 'node:fs/promises' +import { + probeDaemonProcessIdentity, + type DaemonEvidenceSource, + type DaemonEvidenceSources, + type DaemonProcessEvidence, + type ExactDaemonIncarnation +} from './daemon-incarnation-evidence' +import type { + DaemonAuditFailureTrigger, + DaemonAuditGoneReason +} from '../../shared/daemon-audit-eligibility' + +export type { DaemonAuditTrigger } from '../../shared/daemon-audit-eligibility' + +export type DaemonAuditContext = { + protocolGeneration: number + provider: 'local-daemon' + endpoint: string + tokenPath: string + endpointKind: 'unix-socket' | 'windows-named-pipe' + profileScope: string +} + +export type DaemonAuditObservation = + | { + state: 'present' + reason: 'authenticated_inventory' + trigger: 'inventory_answered' + evidenceSources: DaemonEvidenceSources + context: DaemonAuditContext + exactIncarnation: ExactDaemonIncarnation | null + reachability: 'authenticated' + inventoryAuthority: 'authoritative' + processLiveness: 'unknown' + processReason: null + endpointState: DaemonEndpointState + observedAtMs: number + } + | { + state: 'gone' + reason: DaemonAuditGoneReason + trigger: DaemonAuditFailureTrigger + evidenceSources: DaemonEvidenceSources + context: DaemonAuditContext + exactIncarnation: ExactDaemonIncarnation + reachability: 'authenticated' | 'disconnected' | 'unknown' + inventoryAuthority: 'unavailable' + processLiveness: 'gone' + processReason: DaemonAuditGoneReason + endpointState: DaemonEndpointState + observedAtMs: number + } + | { + state: 'unknown' + reason: DaemonAuditFailureTrigger + trigger: DaemonAuditFailureTrigger + evidenceSources: DaemonEvidenceSources + context: DaemonAuditContext + exactIncarnation: ExactDaemonIncarnation | null + reachability: 'authenticated' | 'disconnected' | 'unknown' + inventoryAuthority: 'unavailable' + processLiveness: 'present' | 'unknown' + processReason: + | Extract['reason'] + | null + endpointState: DaemonEndpointState + observedAtMs: number + } + +export type DaemonEndpointState = 'missing' | 'named-pipe' | 'non-socket' | 'socket' | 'unknown' + +export type DaemonAuditClassifierDependencies = { + probeProcessIdentity?: typeof probeDaemonProcessIdentity + inspectEndpointState?: (context: DaemonAuditContext) => Promise +} + +export type DaemonAuditClassificationOptions = { + additionalEvidenceSources?: readonly DaemonEvidenceSource[] + endpointGoneProof?: 'windows_named_pipe_missing' + dependencies?: DaemonAuditClassifierDependencies +} + +export function recordAuthenticatedInventory( + context: DaemonAuditContext, + exactIncarnation: ExactDaemonIncarnation | null +): DaemonAuditObservation { + return { + state: 'present', + reason: 'authenticated_inventory', + trigger: 'inventory_answered', + evidenceSources: ['authenticated_inventory'], + context, + exactIncarnation, + reachability: 'authenticated', + inventoryAuthority: 'authoritative', + processLiveness: 'unknown', + processReason: null, + endpointState: context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'socket', + observedAtMs: Date.now() + } +} + +export async function classifyDaemonAuditFailure( + context: DaemonAuditContext, + trigger: DaemonAuditFailureTrigger, + exactIncarnation: ExactDaemonIncarnation | null, + options: DaemonAuditClassificationOptions = {} +): Promise { + const probeProcessIdentity = + options.dependencies?.probeProcessIdentity ?? probeDaemonProcessIdentity + const inspectEndpoint = options.dependencies?.inspectEndpointState ?? inspectDaemonEndpointState + const [processEvidence, endpointState] = await Promise.all([ + probeProcessIdentity(exactIncarnation, { + socketPath: context.endpoint, + tokenPath: context.tokenPath + }), + inspectEndpoint(context) + ]) + const reachability = reachabilityForTrigger(trigger) + const evidenceSources = combineEvidenceSources( + processEvidence.evidenceSources, + context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [], + options.additionalEvidenceSources ?? [] + ) + const windowsNamedPipeMissing = + options.endpointGoneProof === 'windows_named_pipe_missing' && + context.endpointKind === 'windows-named-pipe' + const observedEndpointState = windowsNamedPipeMissing ? 'missing' : endpointState + if (windowsNamedPipeMissing && exactIncarnation && processEvidence.state !== 'present') { + return { + state: 'gone', + reason: 'windows_named_pipe_missing', + trigger, + evidenceSources, + context, + exactIncarnation, + reachability, + inventoryAuthority: 'unavailable', + processLiveness: 'gone', + processReason: 'windows_named_pipe_missing', + endpointState: observedEndpointState, + observedAtMs: Date.now() + } + } + if (processEvidence.state === 'gone') { + return { + state: 'gone', + reason: processEvidence.reason, + trigger, + evidenceSources, + context, + exactIncarnation: processEvidence.exactIncarnation, + reachability, + inventoryAuthority: 'unavailable', + processLiveness: 'gone', + processReason: processEvidence.reason, + endpointState: observedEndpointState, + observedAtMs: Date.now() + } + } + return { + state: 'unknown', + reason: trigger, + trigger, + evidenceSources, + context, + exactIncarnation, + reachability, + inventoryAuthority: 'unavailable', + processLiveness: processEvidence.state, + processReason: processEvidence.reason, + endpointState: observedEndpointState, + observedAtMs: Date.now() + } +} + +async function inspectDaemonEndpointState( + context: DaemonAuditContext +): Promise { + if (context.endpointKind === 'windows-named-pipe') { + return 'named-pipe' + } + try { + const stats = await lstat(context.endpoint) + return stats.isSocket() ? 'socket' : 'non-socket' + } catch (error) { + return hasErrorCode(error, 'ENOENT') ? 'missing' : 'unknown' + } +} + +function reachabilityForTrigger( + trigger: DaemonAuditFailureTrigger +): 'authenticated' | 'disconnected' | 'unknown' { + if ( + trigger === 'transport_closed' || + trigger === 'token_missing_after_authenticated_disconnect' + ) { + return 'disconnected' + } + return 'unknown' +} + +function combineEvidenceSources( + first: DaemonEvidenceSources, + ...rest: readonly (readonly DaemonEvidenceSource[])[] +): DaemonEvidenceSources { + return [...new Set([first[0], ...first.slice(1), ...rest.flat()])] as [ + DaemonEvidenceSource, + ...DaemonEvidenceSource[] + ] +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} diff --git a/src/main/daemon/daemon-audit-eligibility-event.test.ts b/src/main/daemon/daemon-audit-eligibility-event.test.ts new file mode 100644 index 00000000000..b17b5ba3892 --- /dev/null +++ b/src/main/daemon/daemon-audit-eligibility-event.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { validate } from '../telemetry/validator' +import { recordAuthenticatedInventory, type DaemonAuditContext } from './daemon-audit-classifier' + +const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() })) +vi.mock('../telemetry/client', () => ({ track: trackMock })) + +import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event' + +const context: DaemonAuditContext = { + protocolGeneration: 23, + provider: 'local-daemon', + endpoint: '/profile/daemon.sock', + tokenPath: '/profile/daemon.token', + endpointKind: 'unix-socket', + profileScope: '/profile' +} + +beforeEach(() => { + trackMock.mockReset() +}) + +describe('daemon audit eligibility telemetry', () => { + it('uses a dedicated validator-accepted event family', () => { + trackDaemonAuditEligibility(recordAuthenticatedInventory(context, null)) + + expect(trackMock).toHaveBeenCalledOnce() + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_audit_eligibility') + expect(name).not.toBe('daemon_lifecycle') + expect(props).toMatchObject({ + state: 'present', + reason: 'authenticated_inventory', + evidence_sources: ['authenticated_inventory'], + protocol_generation: 23, + exact_incarnation: 'unavailable', + process_reason: null + }) + expect(validate('daemon_audit_eligibility', props).ok).toBe(true) + }) + + it('cannot affect callers when telemetry throws', () => { + trackMock.mockImplementation(() => { + throw new Error('transport failed') + }) + + expect(() => + trackDaemonAuditEligibility(recordAuthenticatedInventory(context, null)) + ).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-audit-eligibility-event.ts b/src/main/daemon/daemon-audit-eligibility-event.ts new file mode 100644 index 00000000000..0511f7a384a --- /dev/null +++ b/src/main/daemon/daemon-audit-eligibility-event.ts @@ -0,0 +1,36 @@ +import { track } from '../telemetry/client' +import type { DaemonAuditObservation } from './daemon-audit-classifier' + +export function trackDaemonAuditEligibility(observation: DaemonAuditObservation): void { + try { + track('daemon_audit_eligibility', { + state: observation.state, + reason: observation.reason, + trigger: observation.trigger, + evidence_sources: [...observation.evidenceSources], + protocol_generation: observation.context.protocolGeneration, + provider: observation.context.provider, + endpoint_kind: observation.context.endpointKind, + profile_scope: observation.context.profileScope ? 'configured' : 'unspecified', + exact_incarnation: exactIncarnationKind(observation), + reachability: observation.reachability, + inventory_authority: observation.inventoryAuthority, + process_liveness: observation.processLiveness, + process_reason: observation.processReason, + endpoint_state: observation.endpointState + }) + } catch { + // Audit telemetry cannot affect daemon availability. + } +} + +function exactIncarnationKind( + observation: DaemonAuditObservation +): 'endpoint-identity' | 'endpoint-identity-linux-ticks' | 'unavailable' { + if (!observation.exactIncarnation) { + return 'unavailable' + } + return observation.exactIncarnation.linuxStartTicks && observation.exactIncarnation.bootId + ? 'endpoint-identity-linux-ticks' + : 'endpoint-identity' +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index ba98af21c5b..215786fb472 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -19,6 +19,7 @@ import { } from '../providers/macos-tcc-login-shell' import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' +import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity' export type ParsedDaemonArgs = { socketPath: string @@ -255,9 +256,8 @@ async function main(): Promise { // Signal readiness to parent via IPC (if available) if (process.send) { - // Why: Windows has no cheap OS query for a child's start time, so the - // daemon self-reports it here for the pid file's pid-recycling guard. - process.send({ type: 'ready', startedAtMs }) + const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs) + process.send({ type: 'ready', ...readyIdentity }) } daemonLog.log('ready') diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 152ed019029..ccfb7d1f0e9 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -198,7 +198,10 @@ describe('parseDaemonPidFile', () => { pid: 12345, startedAtMs: 1_700_000_000_000, entryPath: null, - appVersion: null + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null }) }) @@ -213,7 +216,25 @@ describe('parseDaemonPidFile', () => { pid: 12345, startedAtMs: 1_700_000_000_000, entryPath: '/repo/out/main/daemon-entry.js', - appVersion: '1.2.3' + appVersion: '1.2.3', + launchNonce: null, + linuxStartTicks: null, + bootId: null + }) + }) + + it('preserves exact-incarnation launch and Linux process identity', () => { + const serialized = serializeDaemonPidFile({ + pid: 12345, + startedAtMs: 1_700_000_000_000, + launchNonce: 'launch-a', + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + expect(parseDaemonPidFile(serialized)).toMatchObject({ + launchNonce: 'launch-a', + linuxStartTicks: '4242', + bootId: 'boot-a' }) }) @@ -224,7 +245,10 @@ describe('parseDaemonPidFile', () => { pid: 9999, startedAtMs: null, entryPath: null, - appVersion: null + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null }) }) @@ -236,13 +260,19 @@ describe('parseDaemonPidFile', () => { pid: 12345, startedAtMs: null, entryPath: null, - appVersion: null + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null }) expect(parseDaemonPidFile(' 12345\n')).toEqual({ pid: 12345, startedAtMs: null, entryPath: null, - appVersion: null + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null }) }) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 2989f9b2268..71bc7f3279b 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -40,11 +40,14 @@ const WIN32_START_TIME_TOLERANCE_MS = 10_000 // also covers a live-but-wedged daemon that simply missed the RPC budget. export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy' -type ParsedDaemonPid = { +export type ParsedDaemonPid = { pid: number startedAtMs: number | null entryPath: string | null appVersion: string | null + launchNonce: string | null + linuxStartTicks: string | null + bootId: string | null } function canConnectSocket(socketPath: string): Promise { @@ -301,7 +304,7 @@ export function getMacDaemonSystemResolverHealth( }) } -function commandLineMatchesDaemon( +export function commandLineMatchesDaemon( commandLine: string, socketPath: string, tokenPath: string @@ -321,6 +324,9 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { startedAtMs?: unknown entryPath?: unknown appVersion?: unknown + launchNonce?: unknown + linuxStartTicks?: unknown + bootId?: unknown } if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) { return { @@ -330,7 +336,10 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { ? parsed.startedAtMs : null, entryPath: typeof parsed.entryPath === 'string' ? parsed.entryPath : null, - appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null + appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null, + launchNonce: typeof parsed.launchNonce === 'string' ? parsed.launchNonce : null, + linuxStartTicks: typeof parsed.linuxStartTicks === 'string' ? parsed.linuxStartTicks : null, + bootId: typeof parsed.bootId === 'string' ? parsed.bootId : null } } } catch { @@ -338,7 +347,17 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { } const pid = Number(trimmed) - return Number.isFinite(pid) ? { pid, startedAtMs: null, entryPath: null, appVersion: null } : null + return Number.isFinite(pid) + ? { + pid, + startedAtMs: null, + entryPath: null, + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null + } + : null } function getLinuxProcessStartedAtMs(pid: number): number | null { diff --git a/src/main/daemon/daemon-incarnation-evidence-types.ts b/src/main/daemon/daemon-incarnation-evidence-types.ts new file mode 100644 index 00000000000..537a03fc191 --- /dev/null +++ b/src/main/daemon/daemon-incarnation-evidence-types.ts @@ -0,0 +1,75 @@ +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { + DaemonAuditGoneReason, + DaemonEvidenceSource, + DaemonProcessGoneReason, + DaemonProcessPresentReason, + DaemonProcessUnknownReason +} from '../../shared/daemon-audit-eligibility' + +export const WINDOWS_CREATION_TIME_TOLERANCE_MS = 10_000 + +// Oracle contract validated 2026-07-29; omitted proofs remain unknown. +export const DAEMON_GONE_PROOFS = { + linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'], + darwin: ['pid_missing'], + win32: ['windows_process_missing', 'windows_creation_time_mismatch', 'windows_named_pipe_missing'] +} as const satisfies Readonly< + Record<'linux' | 'darwin' | 'win32', readonly DaemonAuditGoneReason[]> +> + +export type { + DaemonAuditGoneReason, + DaemonEvidenceSource, + DaemonProcessGoneReason, + DaemonProcessPresentReason, + DaemonProcessUnknownReason +} from '../../shared/daemon-audit-eligibility' + +export type DaemonEvidenceSources = readonly [DaemonEvidenceSource, ...DaemonEvidenceSource[]] + +export type ExactDaemonIncarnation = { + identity: DaemonEndpointIdentity + linuxStartTicks?: string + bootId?: string +} + +export type DaemonProcessEvidence = + | { + state: 'present' + reason: DaemonProcessPresentReason + evidenceSources: DaemonEvidenceSources + } + | { + state: 'gone' + reason: DaemonProcessGoneReason + evidenceSources: DaemonEvidenceSources + exactIncarnation: ExactDaemonIncarnation + } + | { + state: 'unknown' + reason: DaemonProcessUnknownReason + evidenceSources: DaemonEvidenceSources + } + +export type ProcessSignalEvidence = 'occupied' | 'permission_denied' | 'missing' | 'unavailable' + +export type LinuxStatEvidence = + | { status: 'present'; value: string } + | { status: 'missing' } + | { status: 'unavailable' } + +export type WindowsProcessEvidence = + | { status: 'present'; commandLine: string | null; startedAtMs: number | null } + | { status: 'missing' } + | { status: 'unavailable' } + +export type DaemonProcessProbeDependencies = { + platform?: NodeJS.Platform + signalProcess?: (pid: number) => ProcessSignalEvidence + readLinuxStat?: (pid: number) => Promise + readBootIdentity?: () => Promise + readCommandLine?: (pid: number, platform: NodeJS.Platform) => Promise + readProcessStartedAtMs?: (pid: number) => number | null + queryWindowsProcess?: (pid: number) => Promise +} diff --git a/src/main/daemon/daemon-incarnation-evidence.test.ts b/src/main/daemon/daemon-incarnation-evidence.test.ts new file mode 100644 index 00000000000..73e4a337e3c --- /dev/null +++ b/src/main/daemon/daemon-incarnation-evidence.test.ts @@ -0,0 +1,387 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DAEMON_GONE_PROOFS, + probeDaemonProcessIdentity, + WINDOWS_CREATION_TIME_TOLERANCE_MS, + type DaemonProcessProbeDependencies, + type ExactDaemonIncarnation +} from './daemon-incarnation-evidence' +import { + classifyDaemonAuditFailure, + recordAuthenticatedInventory, + type DaemonAuditClassifierDependencies, + type DaemonAuditContext +} from './daemon-audit-classifier' + +const endpoint = { socketPath: '/runtime/daemon.sock', tokenPath: '/runtime/daemon.token' } +const exactIncarnation: ExactDaemonIncarnation = { + identity: { pid: 42, startedAtMs: 1_700_000_000_000, launchNonce: 'launch-a' }, + linuxStartTicks: '4242', + bootId: 'boot-a' +} +const auditClassifierDependencies = { + probeProcessIdentity: async () => ({ + state: 'unknown', + reason: 'inspection_failed', + evidenceSources: ['process_signal'] + }), + inspectEndpointState: async (context) => + context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'missing' +} satisfies DaemonAuditClassifierDependencies + +function linuxStat(state: string, startTicks: string): string { + return `42 (orca daemon with spaces) ${[state, ...Array(18).fill('0'), startTicks].join(' ')}` +} + +function linuxDependencies( + overrides: DaemonProcessProbeDependencies = {} +): DaemonProcessProbeDependencies { + return { + platform: 'linux', + signalProcess: () => 'occupied', + readLinuxStat: async () => ({ status: 'present', value: linuxStat('S', '4242') }), + readBootIdentity: async () => 'boot-a', + readCommandLine: async () => + `node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`, + ...overrides + } +} + +describe('daemon process identity evidence', () => { + it('pins the dated conclusive-gone oracle contract', () => { + expect(DAEMON_GONE_PROOFS).toEqual({ + linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'], + darwin: ['pid_missing'], + win32: [ + 'windows_process_missing', + 'windows_creation_time_mismatch', + 'windows_named_pipe_missing' + ] + }) + }) + + it('proves Linux pid reuse from native start ticks without derived milliseconds', async () => { + const readProcessStartedAtMs = vi.fn(() => exactIncarnation.identity.startedAtMs) + + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ + readLinuxStat: async () => ({ status: 'present', value: linuxStat('S', '4243') }), + readProcessStartedAtMs + }) + ) + ).resolves.toMatchObject({ + state: 'gone', + reason: 'linux_start_ticks_mismatch' + }) + expect(readProcessStartedAtMs).not.toHaveBeenCalled() + }) + + it('proves Linux reboot invalidated the recorded incarnation', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ readBootIdentity: async () => 'boot-b' }) + ) + ).resolves.toMatchObject({ state: 'gone', reason: 'linux_boot_changed' }) + }) + + it('treats a matching unreaped Linux zombie as gone', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ + readLinuxStat: async () => ({ status: 'present', value: linuxStat('Z', '4242') }) + }) + ) + ).resolves.toMatchObject({ state: 'gone', reason: 'linux_zombie' }) + }) + + it('keeps EPERM unknown without an independent identity match', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ + signalProcess: () => 'permission_denied', + readLinuxStat: async () => ({ status: 'unavailable' }) + }) + ) + ).resolves.toMatchObject({ state: 'unknown', reason: 'permission_denied' }) + }) + + it('keeps a missing proc stat unknown when signal zero still sees the pid', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ + readLinuxStat: async () => ({ status: 'missing' }) + }) + ) + ).resolves.toMatchObject({ state: 'unknown', reason: 'inspection_failed' }) + }) + + it('keeps an unreadable command line unknown even when Linux start identity matches', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ readCommandLine: async () => undefined }) + ) + ).resolves.toMatchObject({ state: 'unknown', reason: 'command_line_unavailable' }) + }) + + it('never treats signal-zero success alone as present', async () => { + await expect( + probeDaemonProcessIdentity( + { identity: exactIncarnation.identity }, + endpoint, + linuxDependencies({ + readCommandLine: async () => undefined + }) + ) + ).resolves.toMatchObject({ state: 'unknown' }) + }) + + it('never turns a legacy identity gap into gone', async () => { + await expect( + probeDaemonProcessIdentity(null, endpoint, linuxDependencies()) + ).resolves.toMatchObject({ state: 'unknown', reason: 'exact_identity_unavailable' }) + }) + + it('uses Windows CreationDate as the primary identity regardless of command line', async () => { + const base = { + platform: 'win32' as const, + signalProcess: () => 'occupied' as const + } + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: 'unreadable', + startedAtMs: + exactIncarnation.identity.startedAtMs + WINDOWS_CREATION_TIME_TOLERANCE_MS + 1 + }) + }) + ).resolves.toMatchObject({ + state: 'gone', + reason: 'windows_creation_time_mismatch' + }) + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: null, + startedAtMs: exactIncarnation.identity.startedAtMs + }) + }) + ).resolves.toMatchObject({ + state: 'present', + reason: 'windows_identity_match' + }) + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: 'unrelated process', + startedAtMs: exactIncarnation.identity.startedAtMs + }) + }) + ).resolves.toMatchObject({ + state: 'present', + reason: 'windows_identity_match' + }) + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: 'node daemon-entry', + startedAtMs: null + }) + }) + ).resolves.toMatchObject({ + state: 'unknown', + reason: 'windows_process_start_time_unavailable' + }) + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ status: 'missing' }) + }) + ).resolves.toMatchObject({ + state: 'gone', + reason: 'windows_process_missing' + }) + }) + + it('requires Windows CIM evidence even when signal-zero reports a missing pid', async () => { + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + platform: 'win32', + signalProcess: () => 'missing', + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: null, + startedAtMs: exactIncarnation.identity.startedAtMs + }) + }) + ).resolves.toMatchObject({ + state: 'present', + reason: 'windows_identity_match' + }) + }) + + it('keeps a macOS lstart mismatch unknown', async () => { + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + platform: 'darwin', + signalProcess: () => 'occupied', + readCommandLine: async () => + `node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`, + readProcessStartedAtMs: () => exactIncarnation.identity.startedAtMs + 2_500 + }) + ).resolves.toMatchObject({ state: 'unknown', reason: 'macos_start_time_mismatch' }) + }) + + it('accepts ESRCH as conclusive POSIX process disappearance', async () => { + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + platform: 'darwin', + signalProcess: () => 'missing' + }) + ).resolves.toMatchObject({ state: 'gone', reason: 'pid_missing' }) + }) +}) + +describe('daemon audit availability evidence', () => { + const context: DaemonAuditContext = { + protocolGeneration: 23, + provider: 'local-daemon', + endpoint: endpoint.socketPath, + tokenPath: endpoint.tokenPath, + endpointKind: 'unix-socket', + profileScope: '/profile' + } + + it('records authoritative inventory as present without endpoint identity', () => { + expect(recordAuthenticatedInventory(context, null)).toMatchObject({ + state: 'present', + reason: 'authenticated_inventory', + exactIncarnation: null, + inventoryAuthority: 'authoritative' + }) + }) + + it('represents failed legacy inventory as unknown, never an empty process list', async () => { + const observation = await classifyDaemonAuditFailure(context, 'inventory_failed', null, { + dependencies: auditClassifierDependencies + }) + + expect(observation).toMatchObject({ + state: 'unknown', + reason: 'inventory_failed', + inventoryAuthority: 'unavailable', + processLiveness: 'unknown' + }) + expect(observation.evidenceSources.length).toBeGreaterThan(0) + expect(observation).not.toHaveProperty('processes') + }) + + it('keeps token removal after disconnect as contributing evidence only', async () => { + const observation = await classifyDaemonAuditFailure( + context, + 'token_missing_after_authenticated_disconnect', + null, + { + additionalEvidenceSources: ['token_file'], + dependencies: auditClassifierDependencies + } + ) + + expect(observation.state).toBe('unknown') + expect(observation.evidenceSources).toContain('token_file') + }) + + it('accepts Windows named-pipe absence only with exact incarnation evidence', async () => { + const windowsContext: DaemonAuditContext = { + ...context, + endpoint: '\\\\?\\pipe\\orca-daemon', + endpointKind: 'windows-named-pipe' + } + const observation = await classifyDaemonAuditFailure( + windowsContext, + 'inventory_failed', + exactIncarnation, + { + additionalEvidenceSources: ['windows_named_pipe'], + endpointGoneProof: 'windows_named_pipe_missing', + dependencies: auditClassifierDependencies + } + ) + + expect(observation).toMatchObject({ + state: 'gone', + reason: 'windows_named_pipe_missing', + endpointState: 'missing', + exactIncarnation + }) + }) + + it('keeps contradictory Windows pipe and process evidence unknown', async () => { + const windowsContext: DaemonAuditContext = { + ...context, + endpoint: '\\\\?\\pipe\\orca-daemon', + endpointKind: 'windows-named-pipe' + } + const dependencies = { + ...auditClassifierDependencies, + probeProcessIdentity: async () => ({ + state: 'present', + reason: 'windows_identity_match', + evidenceSources: ['windows_cim', 'endpoint_identity'] + }) + } satisfies DaemonAuditClassifierDependencies + + await expect( + classifyDaemonAuditFailure(windowsContext, 'inventory_failed', exactIncarnation, { + additionalEvidenceSources: ['windows_named_pipe'], + endpointGoneProof: 'windows_named_pipe_missing', + dependencies + }) + ).resolves.toMatchObject({ + state: 'unknown', + reason: 'inventory_failed', + processLiveness: 'present', + processReason: 'windows_identity_match', + endpointState: 'missing' + }) + }) + + it('rejects named-pipe disappearance as a gone proof for Unix endpoints', async () => { + const observation = await classifyDaemonAuditFailure( + context, + 'inventory_failed', + exactIncarnation, + { + additionalEvidenceSources: ['windows_named_pipe'], + endpointGoneProof: 'windows_named_pipe_missing', + dependencies: auditClassifierDependencies + } + ) + + expect(observation).toMatchObject({ + state: 'unknown', + reason: 'inventory_failed', + processLiveness: 'unknown' + }) + }) +}) diff --git a/src/main/daemon/daemon-incarnation-evidence.ts b/src/main/daemon/daemon-incarnation-evidence.ts new file mode 100644 index 00000000000..b5c5429a9f2 --- /dev/null +++ b/src/main/daemon/daemon-incarnation-evidence.ts @@ -0,0 +1,270 @@ +import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity' +import { + commandLineMatchesDaemon, + getProcessStartedAtMs, + startTimesWithinTolerance +} from './daemon-health' +import { + WINDOWS_CREATION_TIME_TOLERANCE_MS, + type DaemonEvidenceSources, + type DaemonProcessEvidence, + type DaemonProcessProbeDependencies, + type ExactDaemonIncarnation, + type ProcessSignalEvidence +} from './daemon-incarnation-evidence-types' +import { + inspectProcessSignal, + queryWindowsProcess, + readLinuxStat, + readProcessCommandLine +} from './daemon-process-inspection' + +export { + DAEMON_GONE_PROOFS, + WINDOWS_CREATION_TIME_TOLERANCE_MS, + type DaemonEvidenceSource, + type DaemonEvidenceSources, + type DaemonProcessEvidence, + type DaemonProcessProbeDependencies, + type ExactDaemonIncarnation, + type LinuxStatEvidence, + type ProcessSignalEvidence, + type WindowsProcessEvidence +} from './daemon-incarnation-evidence-types' + +const POSIX_START_TIME_TOLERANCE_MS = 1_500 + +export async function probeDaemonProcessIdentity( + exactIncarnation: ExactDaemonIncarnation | null, + endpoint: { socketPath: string; tokenPath: string }, + dependencies: DaemonProcessProbeDependencies = {} +): Promise { + if (!exactIncarnation) { + return unknown('exact_identity_unavailable', ['pid_record']) + } + const platform = dependencies.platform ?? process.platform + const signalProcess = dependencies.signalProcess ?? inspectProcessSignal + const signal = signalProcess(exactIncarnation.identity.pid) + if (platform !== 'win32' && signal === 'missing') { + return gone('pid_missing', ['process_signal', 'endpoint_identity'], exactIncarnation) + } + + if (platform === 'linux') { + return await probeLinuxProcess(exactIncarnation, endpoint, signal, dependencies) + } + if (platform === 'win32') { + return await probeWindowsProcess(exactIncarnation, endpoint, signal, dependencies) + } + return await probeMacosProcess(exactIncarnation, endpoint, signal, dependencies) +} + +async function probeLinuxProcess( + exactIncarnation: ExactDaemonIncarnation, + endpoint: { socketPath: string; tokenPath: string }, + signal: ProcessSignalEvidence, + dependencies: DaemonProcessProbeDependencies +): Promise { + const stat = await (dependencies.readLinuxStat ?? readLinuxStat)(exactIncarnation.identity.pid) + if (stat.status === 'missing') { + return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [ + 'linux_proc_stat', + 'process_signal' + ]) + } + if (stat.status === 'unavailable') { + return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [ + 'linux_proc_stat', + 'process_signal' + ]) + } + + const expectedTicks = exactIncarnation.linuxStartTicks + const expectedBootId = exactIncarnation.bootId + if (expectedTicks || expectedBootId) { + if (!expectedTicks || !expectedBootId) { + return unknown('linux_identity_incomplete', ['pid_record']) + } + const bootId = await (dependencies.readBootIdentity ?? readBootIdentity)() + if (!bootId) { + return unknown('inspection_failed', ['boot_identity']) + } + if (bootId !== expectedBootId) { + return gone( + 'linux_boot_changed', + ['boot_identity', 'pid_record', 'endpoint_identity'], + exactIncarnation + ) + } + const currentTicks = parseLinuxStartTicks(stat.value) + if (!currentTicks) { + return unknown('inspection_failed', ['linux_proc_stat']) + } + if (currentTicks !== expectedTicks) { + return gone( + 'linux_start_ticks_mismatch', + ['linux_proc_stat', 'boot_identity', 'pid_record'], + exactIncarnation + ) + } + if (parseLinuxProcessState(stat.value) === 'Z') { + return gone( + 'linux_zombie', + ['linux_proc_stat', 'boot_identity', 'pid_record'], + exactIncarnation + ) + } + } + + const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)( + exactIncarnation.identity.pid, + 'linux' + ) + if (commandLine === undefined) { + return unknown('command_line_unavailable', ['process_command_line']) + } + if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) { + return unknown('command_line_mismatch', ['process_command_line']) + } + if (expectedTicks && expectedBootId) { + return present('linux_identity_match', [ + 'linux_proc_stat', + 'boot_identity', + 'process_command_line', + 'endpoint_identity' + ]) + } + + const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)( + exactIncarnation.identity.pid + ) + if (startedAtMs === null) { + return unknown('process_start_time_unavailable', ['process_start_time']) + } + return startTimesWithinTolerance( + startedAtMs, + exactIncarnation.identity.startedAtMs, + POSIX_START_TIME_TOLERANCE_MS + ) + ? present('linux_identity_match', [ + 'process_command_line', + 'process_start_time', + 'endpoint_identity' + ]) + : unknown('linux_identity_incomplete', ['process_start_time', 'endpoint_identity']) +} + +async function probeMacosProcess( + exactIncarnation: ExactDaemonIncarnation, + endpoint: { socketPath: string; tokenPath: string }, + signal: ProcessSignalEvidence, + dependencies: DaemonProcessProbeDependencies +): Promise { + const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)( + exactIncarnation.identity.pid, + 'darwin' + ) + if (commandLine === undefined) { + return unknown( + signal === 'permission_denied' ? 'permission_denied' : 'command_line_unavailable', + ['process_command_line', 'process_signal'] + ) + } + if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) { + return unknown('command_line_mismatch', ['process_command_line']) + } + const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)( + exactIncarnation.identity.pid + ) + if (startedAtMs === null) { + return unknown('process_start_time_unavailable', ['process_start_time']) + } + return startTimesWithinTolerance( + startedAtMs, + exactIncarnation.identity.startedAtMs, + POSIX_START_TIME_TOLERANCE_MS + ) + ? present('macos_identity_match', [ + 'process_command_line', + 'process_start_time', + 'endpoint_identity' + ]) + : unknown('macos_start_time_mismatch', ['process_start_time', 'endpoint_identity']) +} + +async function probeWindowsProcess( + exactIncarnation: ExactDaemonIncarnation, + endpoint: { socketPath: string; tokenPath: string }, + signal: ProcessSignalEvidence, + dependencies: DaemonProcessProbeDependencies +): Promise { + const identity = await (dependencies.queryWindowsProcess ?? queryWindowsProcess)( + exactIncarnation.identity.pid + ) + if (identity.status === 'missing') { + return gone('windows_process_missing', ['windows_cim', 'endpoint_identity'], exactIncarnation) + } + if (identity.status === 'unavailable') { + return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [ + 'windows_cim', + 'process_signal' + ]) + } + if (identity.startedAtMs === null) { + return unknown('windows_process_start_time_unavailable', ['windows_cim']) + } + if ( + !startTimesWithinTolerance( + identity.startedAtMs, + exactIncarnation.identity.startedAtMs, + WINDOWS_CREATION_TIME_TOLERANCE_MS + ) + ) { + return gone( + 'windows_creation_time_mismatch', + ['windows_cim', 'endpoint_identity'], + exactIncarnation + ) + } + return present( + 'windows_identity_match', + identity.commandLine && + commandLineMatchesDaemon(identity.commandLine, endpoint.socketPath, endpoint.tokenPath) + ? ['windows_cim', 'process_command_line', 'endpoint_identity'] + : ['windows_cim', 'endpoint_identity'] + ) +} + +export function parseLinuxProcessState(statLine: string): string | null { + const commandEnd = statLine.lastIndexOf(')') + if (commandEnd < 0) { + return null + } + return ( + statLine + .slice(commandEnd + 1) + .trim() + .split(/\s+/, 1)[0] ?? null + ) +} + +function present( + reason: Extract['reason'], + evidenceSources: DaemonEvidenceSources +): DaemonProcessEvidence { + return { state: 'present', reason, evidenceSources } +} + +function gone( + reason: Extract['reason'], + evidenceSources: DaemonEvidenceSources, + exactIncarnation: ExactDaemonIncarnation +): DaemonProcessEvidence { + return { state: 'gone', reason, evidenceSources, exactIncarnation } +} + +function unknown( + reason: Extract['reason'], + evidenceSources: DaemonEvidenceSources +): DaemonProcessEvidence { + return { state: 'unknown', reason, evidenceSources } +} diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 77881045cb0..444627f59e6 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -241,7 +241,10 @@ vi.mock('fs', () => ({ writeFileSync: writeFileSyncMock })) -vi.mock('child_process', () => ({ fork: forkMock })) +vi.mock('child_process', async (importOriginal) => ({ + ...(await importOriginal>()), + fork: forkMock +})) vi.mock('net', () => ({ connect: netConnectMock })) @@ -1754,7 +1757,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) + queueMicrotask(() => + cb({ + type: 'ready', + startedAtMs: 1_000_000, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + ) } return this }, @@ -1782,6 +1792,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(JSON.parse(pidContents as string)).toEqual({ pid: 12345, startedAtMs: 1_000_000, + linuxStartTicks: '4242', + bootId: 'boot-a', entryPath: FAKE_DAEMON_ENTRY_PATH, appVersion: '1.2.3', launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 24fc9ef4dd2..d7fae082341 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -53,6 +53,7 @@ import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys } from '../claude-accounts/live-pty-gate' +import { parseDaemonReadyIdentity } from './daemon-ready-identity' // Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start. function logDaemonMilestone(event: string, details: Record = {}): void { @@ -600,14 +601,8 @@ function createOutOfProcessLauncher( if (settled) { return } - const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs - if ( - !Number.isSafeInteger(child.pid) || - (child.pid as number) <= 0 || - typeof selfReported !== 'number' || - !Number.isFinite(selfReported) || - selfReported <= 0 - ) { + const readyIdentity = parseDaemonReadyIdentity(msg) + if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0 || !readyIdentity) { void fail(new Error('Daemon readiness identity is incomplete')) return } @@ -617,7 +612,7 @@ function createOutOfProcessLauncher( pidPath, serializeDaemonPidFile({ pid: child.pid as number, - startedAtMs: selfReported, + ...readyIdentity, entryPath, appVersion: app.getVersion(), launchNonce @@ -718,7 +713,9 @@ export async function initDaemonPtyProvider( // Why: fail-open may already have spawned fallback PTYs; don't install late, but retire an empty daemon (live sessions reject it and survive). const abortedStartupAdapter = new DaemonPtyAdapter({ socketPath: info.socketPath, - tokenPath: info.tokenPath + tokenPath: info.tokenPath, + pidPath: getDaemonPidPath(runtimeDir), + profileScope: runtimeDir }) releaseDaemonAdoptionLease(newSpawner.getHandle()) await abortedStartupAdapter.disconnectOnly() @@ -728,6 +725,8 @@ export async function initDaemonPtyProvider( const newAdapter = new DaemonPtyAdapter({ socketPath: info.socketPath, tokenPath: info.tokenPath, + pidPath: getDaemonPidPath(runtimeDir), + profileScope: runtimeDir, historyPath: getHistoryDir(), // Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying. respawn: async (reason: DaemonRespawnReason) => { @@ -925,6 +924,8 @@ async function runRestartDaemon(): Promise { const newCurrent = new DaemonPtyAdapter({ socketPath: info.socketPath, tokenPath: info.tokenPath, + pidPath: getDaemonPidPath(runtimeDir), + profileScope: runtimeDir, historyPath: getHistoryDir(), respawn: async (reason: DaemonRespawnReason) => { // Why: attribute rather than emit — the launcher below is the one that completes the @@ -1155,6 +1156,8 @@ export async function createLegacyDaemonAdapters( new DaemonPtyAdapter({ socketPath, tokenPath, + pidPath: getDaemonPidPath(runtimeDir, protocolVersion), + profileScope: runtimeDir, protocolVersion, historyPath }) diff --git a/src/main/daemon/daemon-process-inspection.test.ts b/src/main/daemon/daemon-process-inspection.test.ts new file mode 100644 index 00000000000..2b612fd068c --- /dev/null +++ b/src/main/daemon/daemon-process-inspection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { queryWindowsProcess, readProcessCommandLine } from './daemon-process-inspection' + +describe('daemon process inspection', () => { + it('falls back to ps when Linux procfs returns an empty command line', async () => { + const readTextFile = vi.fn(async () => '') + const runCommand = vi.fn(async () => 'node daemon-entry --socket daemon.sock') + + await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe( + 'node daemon-entry --socket daemon.sock' + ) + expect(readTextFile).toHaveBeenCalledWith('/proc/42/cmdline') + expect(runCommand).toHaveBeenCalledWith('ps', ['-p', '42', '-o', 'command='], 2_000) + }) + + it('uses a non-empty Linux procfs command line without spawning ps', async () => { + const readTextFile = vi.fn(async () => 'node\0daemon-entry') + const runCommand = vi.fn() + + await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe( + 'node\0daemon-entry' + ) + expect(runCommand).not.toHaveBeenCalled() + }) + + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])( + 'rejects unsafe Windows pid %s before command interpolation', + async (pid) => { + const runCommand = vi.fn() + + await expect(queryWindowsProcess(pid, { runCommand })).resolves.toEqual({ + status: 'unavailable' + }) + expect(runCommand).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts new file mode 100644 index 00000000000..6dbc8ed55c4 --- /dev/null +++ b/src/main/daemon/daemon-process-inspection.ts @@ -0,0 +1,122 @@ +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { promisify } from 'node:util' +import type { + LinuxStatEvidence, + ProcessSignalEvidence, + WindowsProcessEvidence +} from './daemon-incarnation-evidence-types' + +const execFileAsync = promisify(execFile) + +export type DaemonProcessInspectionDependencies = { + readTextFile?: (path: string) => Promise + runCommand?: (file: string, args: string[], timeoutMs: number) => Promise +} + +export function inspectProcessSignal(pid: number): ProcessSignalEvidence { + try { + process.kill(pid, 0) + return 'occupied' + } catch (error) { + if (hasErrorCode(error, 'ESRCH')) { + return 'missing' + } + if (hasErrorCode(error, 'EPERM')) { + return 'permission_denied' + } + return 'unavailable' + } +} + +export async function readLinuxStat(pid: number): Promise { + try { + return { status: 'present', value: await readFile(`/proc/${pid}/stat`, 'utf8') } + } catch (error) { + return { status: hasErrorCode(error, 'ENOENT') ? 'missing' : 'unavailable' } + } +} + +export async function readProcessCommandLine( + pid: number, + platform: NodeJS.Platform, + dependencies: DaemonProcessInspectionDependencies = {} +): Promise { + const readTextFile = + dependencies.readTextFile ?? (async (path: string) => await readFile(path, 'utf8')) + const runCommand = dependencies.runCommand ?? runInspectionCommand + if (platform === 'linux') { + try { + const procCommandLine = await readTextFile(`/proc/${pid}/cmdline`) + if (procCommandLine.length > 0) { + return procCommandLine + } + } catch { + // Fall through to ps for procfs privilege or mount restrictions. + } + } + try { + const stdout = await runCommand('ps', ['-p', String(pid), '-o', 'command='], 2_000) + return stdout.trim() || undefined + } catch { + return undefined + } +} + +export async function queryWindowsProcess( + pid: number, + dependencies: DaemonProcessInspectionDependencies = {} +): Promise { + if (!Number.isSafeInteger(pid) || pid <= 0) { + return { status: 'unavailable' } + } + const runCommand = dependencies.runCommand ?? runInspectionCommand + try { + const stdout = await runCommand( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` + + `if (!$p) { @{ exists = $false } | ConvertTo-Json -Compress } else { ` + + `$start = $null; if ($p.CreationDate) { ` + + `$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` + + `@{ exists = $true; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress }` + ], + 3_000 + ) + const parsed = JSON.parse(stdout.trim()) as { + exists?: unknown + cmd?: unknown + start?: unknown + } + if (parsed.exists === false) { + return { status: 'missing' } + } + if (parsed.exists !== true) { + return { status: 'unavailable' } + } + return { + status: 'present', + commandLine: typeof parsed.cmd === 'string' && parsed.cmd ? parsed.cmd : null, + startedAtMs: + typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null + } + } catch { + return { status: 'unavailable' } + } +} + +async function runInspectionCommand( + file: string, + args: string[], + timeoutMs: number +): Promise { + const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', timeout: timeoutMs }) + return stdout +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index cadee006fc0..5a3d766b85d 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -19,7 +19,7 @@ import type { SubprocessHandle } from './session' import type { PendingOutputRecord } from './types' import type { DaemonFileLog } from './daemon-file-log' import type * as DaemonHealthModule from './daemon-health' -import { getDaemonSocketPath } from './daemon-spawner' +import { getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks' @@ -1449,6 +1449,58 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(procs[0]).toHaveProperty('title') expect(procs[0].cwd).toBe('/repo/owned-before-osc7') expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7') + expect(adapter.getLastAuditObservation()).toMatchObject({ + state: 'present', + reason: 'authenticated_inventory', + inventoryAuthority: 'authoritative' + }) + }) + + it('loads persisted Linux birth identity for audit observations', async () => { + adapter.dispose() + await server.shutdown() + const startedAtMs = 1_700_000_000_000 + const launchNonce = 'launch-with-linux-identity' + server = new DaemonServer({ + socketPath, + tokenPath, + startedAtMs, + launchNonce, + log: daemonLog, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + const pidPath = join(dir, 'daemon.pid') + writeFileSync( + pidPath, + serializeDaemonPidFile({ + pid: process.pid, + startedAtMs, + launchNonce, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + ) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + try { + adapter = new DaemonPtyAdapter({ socketPath, tokenPath, pidPath }) + + await expect(adapter.listProcesses()).resolves.toEqual([]) + + expect(adapter.getLastAuditObservation()?.exactIncarnation).toMatchObject({ + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } }) it('reports the daemon session WSL owner', async () => { @@ -1470,6 +1522,104 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } } }) + + it('retains authenticated identity and reports replacement across a same-endpoint reconnect', async () => { + await adapter.listProcesses() + const firstIdentity = adapter.getLastAuthenticatedDaemonIdentity() + expect(firstIdentity).not.toBeNull() + const identityChanges: { + previous: NonNullable + current: NonNullable + }[] = [] + adapter.onDaemonIdentityChanged(() => { + throw new Error('audit listener failed') + }) + adapter.onDaemonIdentityChanged((event) => identityChanges.push(event)) + + await server.shutdown() + await waitFor( + () => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected() + ) + expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(firstIdentity) + server = new DaemonServer({ + socketPath, + tokenPath, + launchNonce: 'replacement-launch', + startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000, + log: daemonLog, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + + await expect(adapter.listProcesses()).resolves.toEqual([]) + + expect(identityChanges).toEqual([ + { + previous: firstIdentity, + current: { + pid: process.pid, + startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000, + launchNonce: 'replacement-launch' + } + } + ]) + expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(identityChanges[0]?.current) + }) + + it('isolates audit observation listeners from inventory and later listeners', async () => { + const laterListener = vi.fn() + adapter.onAuditEligibilityObservation(() => { + throw new Error('audit listener failed') + }) + adapter.onAuditEligibilityObservation(laterListener) + + await expect(adapter.listProcesses()).resolves.toEqual([]) + + expect(laterListener).toHaveBeenCalledOnce() + expect(laterListener).toHaveBeenCalledWith( + expect.objectContaining({ + state: 'present', + reason: 'authenticated_inventory' + }) + ) + expect(adapter.getLastAuditObservation()).toMatchObject({ + state: 'present', + reason: 'authenticated_inventory' + }) + }) + + it('audits token ENOENT only after an authenticated disconnect', async () => { + const observations: { + trigger: string + state: string + evidenceSources: readonly string[] + }[] = [] + adapter.onAuditEligibilityObservation((observation) => observations.push(observation)) + await adapter.listProcesses() + + await server.shutdown() + await waitFor( + () => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected() + ) + await expect(adapter.listProcesses()).rejects.toThrow() + await waitFor(() => + observations.some( + (observation) => observation.trigger === 'token_missing_after_authenticated_disconnect' + ) + ) + + expect(observations).toContainEqual( + expect.objectContaining({ + trigger: 'token_missing_after_authenticated_disconnect', + state: 'unknown', + evidenceSources: expect.arrayContaining(['token_file']) + }) + ) + }) }) describe('hasChildProcesses / getForegroundProcess', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 4be7931bee7..9fcf5a8633d 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,8 +1,13 @@ /* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */ import { basename } from 'node:path' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' import { DaemonClient } from './client' -import { getMacDaemonSystemResolverHealth } from './daemon-health' +import { + getMacDaemonSystemResolverHealth, + parseDaemonPidFile, + type ParsedDaemonPid +} from './daemon-health' import { HistoryManager, type HistoryCheckpointResult, @@ -63,6 +68,16 @@ import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks' import { NdjsonLineTooLongError } from './ndjson' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import { + classifyDaemonAuditFailure, + recordAuthenticatedInventory, + type DaemonAuditContext, + type DaemonAuditObservation, + type DaemonAuditTrigger +} from './daemon-audit-classifier' +import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event' type PendingDaemonSpawnOperation = { exitsBySessionId: Map @@ -100,6 +115,8 @@ function providerSequenceForSpawn( export type DaemonPtyAdapterOptions = { socketPath: string tokenPath: string + pidPath?: string + profileScope?: string protocolVersion?: number /** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */ historyPath?: string @@ -109,6 +126,11 @@ export type DaemonPtyAdapterOptions = { export type DaemonRespawnReason = 'daemon_died' | 'unhealthy_resolver' +export type DaemonIdentityChangeEvent = { + previous: DaemonEndpointIdentity + current: DaemonEndpointIdentity +} + const MAX_TOMBSTONES = 1000 const MAX_CONCURRENT_CHECKPOINTS = 4 @@ -130,7 +152,15 @@ export class DaemonPtyAdapter implements IPtyProvider { readonly protocolVersion: number private socketPath: string private tokenPath: string + private pidPath: string | null + private pidRecord: ParsedDaemonPid | null private client: DaemonClient + private auditContext: DaemonAuditContext + private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null + private exactDaemonIncarnation: ExactDaemonIncarnation | null = null + private lastAuditObservation: DaemonAuditObservation | null = null + private auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = [] + private identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = [] private historyManager: HistoryManager | null private historyReader: HistoryReader | null private respawnFn: DaemonPtyAdapterOptions['respawn'] | null @@ -219,6 +249,16 @@ export class DaemonPtyAdapter implements IPtyProvider { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath + this.pidPath = opts.pidPath ?? null + this.pidRecord = readDaemonPidRecord(this.pidPath) + this.auditContext = { + protocolGeneration: this.protocolVersion, + provider: 'local-daemon', + endpoint: opts.socketPath, + tokenPath: opts.tokenPath, + endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', + profileScope: opts.profileScope ?? '' + } this.client = new DaemonClient({ socketPath: opts.socketPath, tokenPath: opts.tokenPath, @@ -247,6 +287,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.producerResumesOwedOnReconnect.add(id) } this.pausedProducerSessionIds.clear() + this.observeAuditFailure('transport_closed') }) } @@ -254,6 +295,26 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.historyManager } + getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { + return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null + } + + getLastAuditObservation(): DaemonAuditObservation | null { + return this.lastAuditObservation + } + + onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void { + this.identityChangeListeners.push(listener) + return () => removeListener(this.identityChangeListeners, listener) + } + + onAuditEligibilityObservation( + listener: (observation: DaemonAuditObservation) => void + ): () => void { + this.auditObservationListeners.push(listener) + return () => removeListener(this.auditObservationListeners, listener) + } + supportsAgentSessionClaims(): boolean { return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION } @@ -1262,36 +1323,57 @@ export class DaemonPtyAdapter implements IPtyProvider { } async listProcesses(opts?: { deadlineMs?: number }): Promise { - // Why: connect + listSessions share the caller's one absolute deadline so a - // wedged handshake cannot burn the whole teardown budget before the list issues. - await this.ensureConnected(opts?.deadlineMs) - const result = await this.client.request( - 'listSessions', - undefined, - remainingRequestTimeoutMs(opts?.deadlineMs) - ) - const admission = new PtyProcessListAdmission() - const processes: PtyProcessInfo[] = [] - for (const session of result.sessions) { - if (!session.isAlive) { - continue - } - const { worktreeId } = parsePtySessionId(session.sessionId) - processes.push( - admission.admit({ - id: session.sessionId, - ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), - // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. - cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', - title: 'shell', - ...(worktreeId ? { worktreeId } : {}), - ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), - ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), - ...this.validatedAgentSessionOwners(session.agentSessionOwners) - }) + try { + // Why: connect + listSessions share the caller's one absolute deadline so a + // wedged handshake cannot burn the whole teardown budget before the list issues. + await this.ensureConnected(opts?.deadlineMs) + const result = await this.client.request( + 'listSessions', + undefined, + remainingRequestTimeoutMs(opts?.deadlineMs) ) + const admission = new PtyProcessListAdmission() + const processes: PtyProcessInfo[] = [] + for (const session of result.sessions) { + if (!session.isAlive) { + continue + } + const { worktreeId } = parsePtySessionId(session.sessionId) + processes.push( + admission.admit({ + id: session.sessionId, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. + cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', + title: 'shell', + ...(worktreeId ? { worktreeId } : {}), + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), + ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + }) + ) + } + this.publishAuditObservation( + recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation) + ) + return processes + } catch (error) { + const missingAuthenticatedToken = + isMissingTokenFileError(error) && this.client.hasObservedAuthenticatedDisconnect() + const missingNamedPipe = isMissingWindowsNamedPipeError(error) + this.observeAuditFailure( + missingAuthenticatedToken + ? 'token_missing_after_authenticated_disconnect' + : 'inventory_failed', + this.exactDaemonIncarnation, + [ + ...(missingAuthenticatedToken ? (['token_file'] as const) : []), + ...(missingNamedPipe ? (['windows_named_pipe'] as const) : []) + ], + missingNamedPipe ? 'windows_named_pipe_missing' : undefined + ) + throw error } - return processes } private validatedAgentSessionOwners( @@ -1452,6 +1534,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.wslDistrosBySessionId.clear() this.pausedProducerSessionIds.clear() this.producerResumesOwedOnReconnect.clear() + this.auditObservationListeners.length = 0 + this.identityChangeListeners.length = 0 this.removeEventListener?.() this.removeEventListener = null // Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions @@ -1470,6 +1554,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } // Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit. await this.client.ensureConnected() + this.recordAuthenticatedIdentity() } // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, @@ -1532,6 +1617,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks. this.releasePendingRespawnAdoptionLease() } + this.recordAuthenticatedIdentity() // Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the // daemon-side backgrounded set (process state lost with the old daemon) needs a resync. const isFreshConnection = this.removeEventListener === null @@ -1543,6 +1629,104 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + private recordAuthenticatedIdentity(): void { + const current = this.client.getDaemonIdentity() + if (!current) { + return + } + const previous = this.lastAuthenticatedIdentity + if (previous && sameEndpointIdentity(previous, current)) { + return + } + this.lastAuthenticatedIdentity = { ...current } + this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) + if (!previous) { + return + } + const event = { previous: { ...previous }, current: { ...current } } + notifyAuditListeners(this.identityChangeListeners, event) + } + + private async resolveExactDaemonIncarnation( + exactIncarnation: ExactDaemonIncarnation | null + ): Promise { + if ( + !exactIncarnation || + process.platform !== 'linux' || + (exactIncarnation.linuxStartTicks && exactIncarnation.bootId) + ) { + return exactIncarnation + } + const cachedIncarnation = exactDaemonIncarnationForPidRecord( + exactIncarnation.identity, + this.pidRecord + ) + if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) { + return cachedIncarnation + } + const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity) + this.pidRecord = pidRecord ?? this.pidRecord + return pidRecord?.linuxStartTicks && pidRecord.bootId + ? { + identity: { ...exactIncarnation.identity }, + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : exactIncarnation + } + + private cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void { + if ( + exactIncarnation && + this.lastAuthenticatedIdentity && + sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity) + ) { + this.exactDaemonIncarnation = exactIncarnation + } + } + + private async readMatchingPidRecord( + identity: DaemonEndpointIdentity + ): Promise { + if (!this.pidPath) { + return null + } + try { + const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8')) + return parsed?.pid === identity.pid && + parsed.startedAtMs === identity.startedAtMs && + parsed.launchNonce === identity.launchNonce + ? parsed + : null + } catch { + return null + } + } + + private observeAuditFailure( + trigger: Exclude, + exactIncarnation = this.exactDaemonIncarnation, + additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], + endpointGoneProof?: 'windows_named_pipe_missing' + ): void { + void this.resolveExactDaemonIncarnation(exactIncarnation) + .then((resolvedIncarnation) => { + this.cacheExactDaemonIncarnation(resolvedIncarnation) + return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, { + additionalEvidenceSources, + endpointGoneProof + }) + }) + .then((observation) => this.publishAuditObservation(observation)) + .catch(() => {}) + } + + private publishAuditObservation(observation: DaemonAuditObservation): void { + this.lastAuditObservation = observation + trackDaemonAuditEligibility(observation) + notifyAuditListeners(this.auditObservationListeners, observation) + } + private resyncBackgroundedSessions(): void { for (const id of this.backgroundedSessionIds) { // Harmless no-op for sessions the daemon doesn't know (yet). @@ -1829,6 +2013,13 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon. const missingRetiredEndpointToken = isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect() + if (missingRetiredEndpointToken) { + this.observeAuditFailure( + 'token_missing_after_authenticated_disconnect', + this.exactDaemonIncarnation, + ['token_file'] + ) + } if ( this.respawnAdoptionClosed || !this.respawnFn || @@ -2135,6 +2326,65 @@ export class DaemonPtyAdapter implements IPtyProvider { } } +function sameEndpointIdentity( + left: DaemonEndpointIdentity, + right: DaemonEndpointIdentity +): boolean { + return ( + left.pid === right.pid && + left.startedAtMs === right.startedAtMs && + left.launchNonce === right.launchNonce + ) +} + +function exactDaemonIncarnationForPidRecord( + identity: DaemonEndpointIdentity, + pidRecord: ParsedDaemonPid | null +): ExactDaemonIncarnation { + return { + identity: { ...identity }, + ...(process.platform === 'linux' && + pidRecord?.pid === identity.pid && + pidRecord.startedAtMs === identity.startedAtMs && + pidRecord.launchNonce === identity.launchNonce && + pidRecord.linuxStartTicks && + pidRecord.bootId + ? { + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : {}) + } +} + +function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null { + if (!pidPath) { + return null + } + try { + return parseDaemonPidFile(readFileSync(pidPath, 'utf8')) + } catch { + return null + } +} + +function removeListener(listeners: T[], listener: T): void { + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } +} + +function notifyAuditListeners(listeners: readonly ((value: T) => void)[], value: T): void { + for (const listener of listeners.slice()) { + try { + listener(value) + } catch { + // Audit observers cannot affect daemon operations. + } + } +} + // Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); // message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. function isDaemonGoneError(err: unknown): boolean { @@ -2161,3 +2411,11 @@ function isMissingTokenFileError(err: unknown): boolean { const errno = err as NodeJS.ErrnoException return errno.code === 'ENOENT' && errno.syscall === 'open' } + +function isMissingWindowsNamedPipeError(err: unknown): boolean { + if (process.platform !== 'win32' || !(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + return errno.code === 'ENOENT' && errno.syscall === 'connect' +} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 44df6bbaf33..17ad0dd835c 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -546,6 +546,35 @@ describe('DaemonPtyRouter', () => { await expect(router.listProcesses()).rejects.toThrow('legacy unavailable') }) + it('keeps a legacy adapter that exits after construction in fail-closed aggregates', async () => { + const current = createAdapter('current', ['current-session']) + const legacy = createAdapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy exited')) + + await expect(router.listProcesses()).rejects.toThrow('legacy exited') + await expect(router.listProcesses()).rejects.toThrow('legacy exited') + expect(router.getLegacyAdapters()).toEqual([legacy]) + expect(current.listProcesses).toHaveBeenCalledTimes(2) + }) + + it('pins colliding unmapped legacy ids falling through to the current daemon', async () => { + const sessionId = 'cross-generation-collision' + const current = createAdapter('current', [sessionId]) + const legacy = createAdapter('legacy', [sessionId]) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(legacy.listProcesses).mockRejectedValueOnce(new Error('legacy discovery failed')) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + await router.discoverLegacySessions() + router.write(sessionId, 'misrouted\n') + + expect(current.write).toHaveBeenCalledWith(sessionId, 'misrouted\n') + expect(legacy.write).not.toHaveBeenCalled() + warn.mockRestore() + }) + it('merges startup reconciliation and updates route mappings', async () => { const current = createAdapter('current', [], { alive: ['current-alive'], diff --git a/src/main/daemon/daemon-ready-identity.test.ts b/src/main/daemon/daemon-ready-identity.test.ts new file mode 100644 index 00000000000..a2ffb2022bf --- /dev/null +++ b/src/main/daemon/daemon-ready-identity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { parseDaemonReadyIdentity } from './daemon-ready-identity' + +describe('parseDaemonReadyIdentity', () => { + it('accepts additive Linux incarnation identity', () => { + expect( + parseDaemonReadyIdentity({ + type: 'ready', + startedAtMs: 1_700_000_000_000, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + ).toEqual({ + startedAtMs: 1_700_000_000_000, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + }) + + it('keeps older ready payloads compatible', () => { + expect(parseDaemonReadyIdentity({ type: 'ready', startedAtMs: 123 })).toEqual({ + startedAtMs: 123 + }) + }) + + it('rejects partial Linux identity instead of persisting a false proof', () => { + expect( + parseDaemonReadyIdentity({ + type: 'ready', + startedAtMs: 123, + linuxStartTicks: '4242' + }) + ).toBeNull() + }) +}) diff --git a/src/main/daemon/daemon-ready-identity.ts b/src/main/daemon/daemon-ready-identity.ts new file mode 100644 index 00000000000..49a02bffdd0 --- /dev/null +++ b/src/main/daemon/daemon-ready-identity.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity' + +export type DaemonReadyIdentity = { + startedAtMs: number + linuxStartTicks?: string + bootId?: string +} + +export async function readCurrentDaemonReadyIdentity( + startedAtMs: number +): Promise { + if (process.platform !== 'linux') { + return { startedAtMs } + } + try { + const linuxStartTicks = parseLinuxStartTicks(readFileSync('/proc/self/stat', 'utf8')) + const bootId = await readBootIdentity() + return linuxStartTicks && bootId ? { startedAtMs, linuxStartTicks, bootId } : { startedAtMs } + } catch { + return { startedAtMs } + } +} + +export function parseDaemonReadyIdentity(message: unknown): DaemonReadyIdentity | null { + if (!message || typeof message !== 'object') { + return null + } + const value = message as { + startedAtMs?: unknown + linuxStartTicks?: unknown + bootId?: unknown + } + if ( + typeof value.startedAtMs !== 'number' || + !Number.isFinite(value.startedAtMs) || + value.startedAtMs <= 0 + ) { + return null + } + const hasLinuxStartTicks = value.linuxStartTicks !== undefined + const hasBootId = value.bootId !== undefined + if (hasLinuxStartTicks !== hasBootId) { + return null + } + if (!hasLinuxStartTicks) { + return { startedAtMs: value.startedAtMs } + } + if ( + typeof value.linuxStartTicks !== 'string' || + value.linuxStartTicks.length === 0 || + typeof value.bootId !== 'string' || + value.bootId.length === 0 + ) { + return null + } + return { + startedAtMs: value.startedAtMs, + linuxStartTicks: value.linuxStartTicks, + bootId: value.bootId + } +} diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index ba0ea239c67..68b2a5afce4 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -14,6 +14,8 @@ export type DaemonPidFile = { entryPath?: string appVersion?: string launchNonce?: string + linuxStartTicks?: string + bootId?: string } export type DaemonProcessHandle = { diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 79c71945cbf..fbbfdfe4408 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -405,4 +405,19 @@ describe('DegradedDaemonPtyProvider', () => { expect(provider.getCurrentDaemonSessionIds()).toEqual([]) expect(provider.hasPty('legacy-session')).toBe(true) }) + + it('keeps an exited legacy daemon poisoning listProcesses after construction', async () => { + const current = createDaemonAdapter('daemon', ['current-session']) + const legacy = createDaemonAdapter('legacy', ['legacy-session']) + const fallback = createProvider('fallback', ['fallback-session']) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) + await provider.discoverDaemonSessions() + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy exited')) + + await expect(provider.listProcesses()).rejects.toThrow('legacy exited') + await expect(provider.listProcesses()).rejects.toThrow('legacy exited') + expect(provider.getLegacyAdapters()).toEqual([legacy]) + expect(current.listProcesses).toHaveBeenCalledTimes(3) + expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 12edb3b8a7c..490f15124d7 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -58,14 +58,16 @@ describe('createProductionLauncher', () => { it('returns a launcher function', () => { const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js' + getDaemonEntryPath: () => '/fake/path.js', + getAppVersion: () => '1.2.3' }) expect(typeof launcher).toBe('function') }) it('rejects either ownership argument without its pair before forking', async () => { const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js' + getDaemonEntryPath: () => '/fake/path.js', + getAppVersion: () => '1.2.3' }) await expect( @@ -126,12 +128,18 @@ describe('createProductionLauncher', () => { forkMock.mockReturnValueOnce(child) const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const pidPath = join(dir, 'daemon.pid') const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a') - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) + handlers.message[0]?.({ + type: 'ready', + startedAtMs: 123_456, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) const handle = await launch expect(handle.shutdown).toEqual(expect.any(Function)) @@ -143,7 +151,10 @@ describe('createProductionLauncher', () => { expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({ pid: 12345, startedAtMs: 123_456, + linuxStartTicks: '4242', + bootId: 'boot-a', entryPath: join(dir, 'daemon-entry.js'), + appVersion: '1.2.3', launchNonce: 'launch-a' }) expect(forkMock).toHaveBeenCalledWith( @@ -194,7 +205,8 @@ describe('createProductionLauncher', () => { } forkMock.mockReturnValueOnce(child) const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) @@ -241,7 +253,8 @@ describe('createProductionLauncher', () => { forkMock.mockReturnValueOnce(child) const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) @@ -302,7 +315,8 @@ describe('createProductionLauncher', () => { forkMock.mockReturnValueOnce(child) const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) handlers.message[0]?.({ type: 'ready' }) @@ -359,7 +373,8 @@ describe('createProductionLauncher', () => { const pidPath = join(dir, 'occupied.pid') writeFileSync(pidPath, 'occupied') const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts index b9fe704a41d..79bf439eead 100644 --- a/src/main/daemon/production-launcher.ts +++ b/src/main/daemon/production-launcher.ts @@ -5,11 +5,13 @@ import { type DaemonLauncher, type DaemonProcessHandle } from './daemon-spawner' +import { parseDaemonReadyIdentity, type DaemonReadyIdentity } from './daemon-ready-identity' const READY_TIMEOUT_MS = 10_000 export type ProductionLauncherOptions = { getDaemonEntryPath: () => string + getAppVersion: () => string } export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher { @@ -45,9 +47,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } ) - let startedAtMs: number + let readyIdentity: DaemonReadyIdentity try { - startedAtMs = await waitForReady(child) + readyIdentity = await waitForReady(child) } catch (error) { return rejectAfterChildCleanup(child, error) } @@ -60,8 +62,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo pidPath, serializeDaemonPidFile({ pid: child.pid as number, - startedAtMs, + ...readyIdentity, entryPath, + appVersion: opts.getAppVersion(), launchNonce }), { mode: 0o600, flag: 'wx' } @@ -81,7 +84,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } } -function waitForReady(child: ChildProcess): Promise { +function waitForReady(child: ChildProcess): Promise { return new Promise((resolve, reject) => { let timeout: ReturnType | undefined let settled = false @@ -106,8 +109,8 @@ function waitForReady(child: ChildProcess): Promise { if (settled) { return } - const startedAtMs = (msg as { startedAtMs?: unknown }).startedAtMs - if (typeof startedAtMs !== 'number' || !Number.isFinite(startedAtMs) || startedAtMs <= 0) { + const readyIdentity = parseDaemonReadyIdentity(msg) + if (!readyIdentity) { fail(new Error('Daemon readiness identity is incomplete')) return } @@ -115,7 +118,7 @@ function waitForReady(child: ChildProcess): Promise { // Why: the daemon is detached after readiness, so startup listeners // must not keep the child process closure alive for the daemon lifetime. cleanupStartupListeners() - resolve(startedAtMs) + resolve(readyIdentity) } } function onError(err: Error): void { diff --git a/src/main/git/git-status-read-lease-owner.test.ts b/src/main/git/git-status-read-lease-owner.test.ts new file mode 100644 index 00000000000..c6ae5649690 --- /dev/null +++ b/src/main/git/git-status-read-lease-owner.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest' +import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + return { promise, resolve, reject } +} + +describe('GitStatusReadLeaseOwner', () => { + it('cancels first and later callers without aborting a remaining lease', async () => { + const owner = new GitStatusReadLeaseOwner() + const pending = deferred() + const load = vi.fn((_signal: AbortSignal) => pending.promise) + const firstController = new AbortController() + const secondController = new AbortController() + const thirdController = new AbortController() + const firstError = new Error('first cancelled') + const thirdError = new Error('third cancelled') + + const first = owner.lease('repo', firstController.signal, load) + const second = owner.lease('repo', secondController.signal, load) + const third = owner.lease('repo', thirdController.signal, load) + firstController.abort(firstError) + thirdController.abort(thirdError) + + await expect(first).rejects.toBe(firstError) + await expect(third).rejects.toBe(thirdError) + expect(load).toHaveBeenCalledOnce() + expect(load.mock.calls[0][0].aborted).toBe(false) + + pending.resolve('fresh') + await expect(second).resolves.toBe('fresh') + }) + + it('aborts underlying work when every live lease cancels and admits a fresh read', async () => { + const owner = new GitStatusReadLeaseOwner() + const firstPending = deferred() + const secondPending = deferred() + const signals: AbortSignal[] = [] + const load = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return signals.length === 1 ? firstPending.promise : secondPending.promise + }) + const firstController = new AbortController() + const secondController = new AbortController() + + const first = owner.lease('repo', firstController.signal, load) + const second = owner.lease('repo', secondController.signal, load) + firstController.abort(new Error('first cancelled')) + expect(signals[0]?.aborted).toBe(false) + secondController.abort(new Error('second cancelled')) + + await expect(first).rejects.toThrow('first cancelled') + await expect(second).rejects.toThrow('second cancelled') + expect(signals[0]?.aborted).toBe(true) + + const fresh = owner.lease('repo', undefined, load) + expect(load).toHaveBeenCalledTimes(2) + secondPending.resolve('fresh') + await expect(fresh).resolves.toBe('fresh') + firstPending.reject(new Error('underlying aborted')) + await Promise.resolve() + }) + + it('rejects a pre-aborted caller without starting or joining work', async () => { + const owner = new GitStatusReadLeaseOwner() + const pending = deferred() + const load = vi.fn(() => pending.promise) + const active = owner.lease('repo', undefined, load) + const controller = new AbortController() + const abortError = new Error('already cancelled') + controller.abort(abortError) + + await expect(owner.lease('repo', controller.signal, load)).rejects.toBe(abortError) + expect(load).toHaveBeenCalledOnce() + + pending.resolve('active') + await expect(active).resolves.toBe('active') + }) + + it('cleans listeners and entries after success, failure, and cancellation', async () => { + const owner = new GitStatusReadLeaseOwner() + const successController = new AbortController() + const successRemove = vi.spyOn(successController.signal, 'removeEventListener') + await expect( + owner.lease('success', successController.signal, async () => 'success') + ).resolves.toBe('success') + expect(successRemove).toHaveBeenCalledOnce() + + const failureController = new AbortController() + const failureRemove = vi.spyOn(failureController.signal, 'removeEventListener') + const failure = new Error('failed') + await expect( + owner.lease('failure', failureController.signal, async () => { + throw failure + }) + ).rejects.toBe(failure) + expect(failureRemove).toHaveBeenCalledOnce() + + const cancelController = new AbortController() + const cancelRemove = vi.spyOn(cancelController.signal, 'removeEventListener') + const cancelPending = deferred() + const cancelled = owner.lease('cancel', cancelController.signal, () => cancelPending.promise) + cancelController.abort() + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(cancelRemove).toHaveBeenCalledOnce() + + const freshLoad = vi.fn(async () => 'fresh') + await expect(owner.lease('success', undefined, freshLoad)).resolves.toBe('fresh') + await expect(owner.lease('failure', undefined, freshLoad)).resolves.toBe('fresh') + await expect(owner.lease('cancel', undefined, freshLoad)).resolves.toBe('fresh') + expect(freshLoad).toHaveBeenCalledTimes(3) + cancelPending.reject(new Error('underlying aborted')) + await Promise.resolve() + }) + + it('invalidates joinability without breaking already-issued leases', async () => { + const owner = new GitStatusReadLeaseOwner() + const firstPending = deferred() + const secondPending = deferred() + const load = vi + .fn<(_signal: AbortSignal) => Promise>() + .mockReturnValueOnce(firstPending.promise) + .mockReturnValueOnce(secondPending.promise) + + const first = owner.lease('repo', undefined, load) + owner.invalidate() + const second = owner.lease('repo', undefined, load) + expect(load).toHaveBeenCalledTimes(2) + + firstPending.resolve('pre-mutation') + secondPending.resolve('post-mutation') + await expect(first).resolves.toBe('pre-mutation') + await expect(second).resolves.toBe('post-mutation') + }) + + it('never caches a settled read', async () => { + const owner = new GitStatusReadLeaseOwner() + const load = vi.fn(async () => `read-${load.mock.calls.length}`) + + await expect(owner.lease('repo', undefined, load)).resolves.toBe('read-1') + await expect(owner.lease('repo', undefined, load)).resolves.toBe('read-2') + expect(load).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/git/git-status-read-lease-owner.ts b/src/main/git/git-status-read-lease-owner.ts new file mode 100644 index 00000000000..a0012da7d3a --- /dev/null +++ b/src/main/git/git-status-read-lease-owner.ts @@ -0,0 +1,101 @@ +type StatusReadEntry = { + controller: AbortController + promise: Promise + liveLeases: number + settled: boolean +} + +function getAbortReason(signal: AbortSignal): unknown { + try { + signal.throwIfAborted() + } catch (error) { + return error + } + return new DOMException('This operation was aborted', 'AbortError') +} + +export class GitStatusReadLeaseOwner { + private readonly entries = new Map>() + + lease( + key: string, + signal: AbortSignal | undefined, + load: (sharedSignal: AbortSignal) => Promise + ): Promise { + if (signal?.aborted) { + return Promise.reject(getAbortReason(signal)) + } + + let entry = this.entries.get(key) + if (!entry) { + const controller = new AbortController() + const promise = load(controller.signal) + const createdEntry = { controller, promise, liveLeases: 0, settled: false } + entry = createdEntry + this.entries.set(key, createdEntry) + void promise.then( + () => this.settle(key, createdEntry), + () => this.settle(key, createdEntry) + ) + } + + entry.liveLeases += 1 + return this.createLease(key, entry, signal) + } + + invalidate(): void { + this.entries.clear() + } + + private createLease( + key: string, + entry: StatusReadEntry, + signal: AbortSignal | undefined + ): Promise { + return new Promise((resolve, reject) => { + let active = true + const release = (abortReason?: unknown): boolean => { + if (!active) { + return false + } + active = false + signal?.removeEventListener('abort', onAbort) + entry.liveLeases -= 1 + if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) { + if (this.entries.get(key) === entry) { + this.entries.delete(key) + } + entry.controller.abort(abortReason) + } + return true + } + const onAbort = (): void => { + const reason = getAbortReason(signal!) + if (release(reason)) { + reject(reason) + } + } + + signal?.addEventListener('abort', onAbort, { once: true }) + void entry.promise.then( + (value) => { + if (release()) { + resolve(value) + } + }, + (error: unknown) => { + if (release()) { + reject(error) + } + } + ) + }) + } + + private settle(key: string, entry: StatusReadEntry): void { + entry.settled = true + if (this.entries.get(key) === entry) { + this.entries.delete(key) + } + } +} diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index abe618a327a..4bd645c7141 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -11,6 +11,7 @@ import { const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, + gitStreamOptionsMock, lstatMock, realpathMock, readFileMock, @@ -20,6 +21,7 @@ const { } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileAsyncBufferMock: vi.fn(), + gitStreamOptionsMock: vi.fn(), lstatMock: vi.fn(), realpathMock: vi.fn(), readFileMock: vi.fn(), @@ -37,10 +39,11 @@ vi.mock('./runner', () => ({ // keep working unchanged and call ordering (status, then numstat) is preserved. gitStreamStdout: async ( args: string[], - options: { onStdout: (chunk: string) => boolean | void } + options: { signal?: AbortSignal; onStdout: (chunk: string) => boolean | void } ) => { // Forward args so arg-routing mock implementations (e.g. `args.includes`) // still match the status read. + gitStreamOptionsMock(options) const { stdout } = await gitExecFileAsyncMock(args) const stoppedEarly = options.onStdout(stdout ?? '') === true return { stoppedEarly } @@ -991,6 +994,7 @@ describe('getSubmoduleStatus', () => { clearEffectiveUpstreamStatusCacheForTests() gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() + gitStreamOptionsMock.mockReset() lstatMock.mockReset() readFileMock.mockReset() existsSyncMock.mockReset() @@ -1105,6 +1109,7 @@ describe('getStatus', () => { clearEffectiveUpstreamStatusCacheForTests() gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() + gitStreamOptionsMock.mockReset() lstatMock.mockReset() readFileMock.mockReset() existsSyncMock.mockReset() @@ -1133,12 +1138,22 @@ describe('getStatus', () => { return Promise.resolve({ stdout: '' }) }) + const runBurst = async (withSignals: boolean): Promise => { + gitExecFileAsyncMock.mockClear() + await Promise.all( + Array.from({ length: 10 }, () => + getStatus('/repo', withSignals ? { signal: new AbortController().signal } : {}) + ) + ) + return gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('status') + ).length + } + const startedAt = performance.now() - await Promise.all(Array.from({ length: 10 }, () => getStatus('/repo'))) + const unsignalledStatusCommandCalls = await runBurst(false) + const signalledStatusCommandCalls = await runBurst(true) const durationMs = performance.now() - startedAt - const statusCommandCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('status') - ).length const { mkdirSync, writeFileSync } = await vi.importActual('fs') mkdirSync(path.dirname(benchPath), { recursive: true }) writeFileSync( @@ -1146,7 +1161,16 @@ describe('getStatus', () => { JSON.stringify({ scenario: 'git-status-concurrent-burst', concurrentCalls: 10, - statusCommandCalls, + unsignalledStatusCommandCalls, + signalledStatusCommandCalls, + statusArgs: [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ], durationMs }) ) @@ -1183,6 +1207,133 @@ describe('getStatus', () => { expect(statusCommandCalls).toBe(2) }) + it('shares one physical status read across distinct caller signals', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let releaseStatus!: () => void + let statusCommandCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + statusCommandCalls += 1 + return new Promise<{ stdout: string }>((resolve) => { + releaseStatus = () => resolve({ stdout: '' }) + }) + } + return Promise.resolve({ stdout: '' }) + }) + const firstController = new AbortController() + const secondController = new AbortController() + const firstError = new Error('first caller cancelled') + const first = getStatus('/repo', { signal: firstController.signal }) + const second = getStatus('/repo', { signal: secondController.signal }) + + await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) + const underlyingSignal = gitStreamOptionsMock.mock.calls[0]?.[0].signal as AbortSignal + firstController.abort(firstError) + await expect(first).rejects.toBe(firstError) + expect(underlyingSignal?.aborted).toBe(false) + + releaseStatus() + await expect(second).resolves.toMatchObject({ entries: [] }) + expect(statusCommandCalls).toBe(1) + }) + + it('aborts physical status work after its last live caller cancels', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let statusCommandCalls = 0 + let rejectStatus!: (error: unknown) => void + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (!args.includes('status')) { + return Promise.resolve({ stdout: '' }) + } + statusCommandCalls += 1 + if (statusCommandCalls > 1) { + return Promise.resolve({ stdout: '' }) + } + return new Promise<{ stdout: string }>((_resolve, reject) => { + rejectStatus = reject + }) + }) + const firstController = new AbortController() + const secondController = new AbortController() + const first = getStatus('/repo', { signal: firstController.signal }) + const second = getStatus('/repo', { signal: secondController.signal }) + + await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) + const underlyingSignal = gitStreamOptionsMock.mock.calls[0]?.[0].signal as AbortSignal + underlyingSignal.addEventListener('abort', () => rejectStatus(underlyingSignal.reason), { + once: true + }) + firstController.abort(new Error('first cancelled')) + await expect(first).rejects.toThrow('first cancelled') + expect(underlyingSignal.aborted).toBe(false) + secondController.abort(new Error('second cancelled')) + await expect(second).rejects.toThrow('second cancelled') + expect(underlyingSignal.aborted).toBe(true) + + await expect(getStatus('/repo')).resolves.toMatchObject({ entries: [] }) + expect(statusCommandCalls).toBe(2) + }) + + it('rejects a pre-aborted caller without starting or joining status work', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let releaseStatus!: () => void + let statusCommandCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + statusCommandCalls += 1 + return new Promise<{ stdout: string }>((resolve) => { + releaseStatus = () => resolve({ stdout: '' }) + }) + } + return Promise.resolve({ stdout: '' }) + }) + const active = getStatus('/repo') + await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) + const controller = new AbortController() + const abortError = new Error('already cancelled') + controller.abort(abortError) + + await expect(getStatus('/repo', { signal: controller.signal })).rejects.toBe(abortError) + expect(statusCommandCalls).toBe(1) + + releaseStatus() + await active + }) + + it('isolates status reads by worktree, host, and output-affecting options', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let statusCommandCalls = 0 + const releases: (() => void)[] = [] + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + statusCommandCalls += 1 + return new Promise<{ stdout: string }>((resolve) => { + releases.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + const reads = [ + getStatus('/repo'), + getStatus('/other-repo'), + getStatus('/repo', { wslDistro: 'Ubuntu' }), + getStatus('/repo', { includeIgnored: true }), + getStatus('/repo', { reuseLineStats: true }), + getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }), + getStatus('/repo', { limit: 1 }), + getStatus('/repo', { sharedLinkPaths: ['node_modules'] }) + ] + + await vi.waitFor(() => expect(statusCommandCalls).toBe(8)) + releases.splice(0).forEach((release) => release()) + await Promise.all(reads) + expect(statusCommandCalls).toBe(8) + }) + it('clears in-flight status reads when a mutation runs', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) @@ -1213,6 +1364,42 @@ describe('getStatus', () => { expect(statusCommandCalls).toBe(2) }) + it('fences reads started before, during, and after a concurrent mutation', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let statusCommandCalls = 0 + let releaseMutation!: () => void + const releaseStatusReads: (() => void)[] = [] + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + statusCommandCalls += 1 + return new Promise<{ stdout: string }>((resolve) => { + releaseStatusReads.push(() => resolve({ stdout: '' })) + }) + } + if (args.includes('add')) { + return new Promise<{ stdout: string }>((resolve) => { + releaseMutation = () => resolve({ stdout: '' }) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const beforeMutation = getStatus('/repo', { signal: new AbortController().signal }) + await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) + const mutation = stageFile('/repo', 'src/file.ts') + const duringMutation = getStatus('/repo', { signal: new AbortController().signal }) + await vi.waitFor(() => expect(statusCommandCalls).toBe(2)) + releaseMutation() + await mutation + const afterMutation = getStatus('/repo', { signal: new AbortController().signal }) + await vi.waitFor(() => expect(statusCommandCalls).toBe(3)) + + releaseStatusReads.splice(0).forEach((release) => release()) + await Promise.all([beforeMutation, duringMutation, afterMutation]) + expect(statusCommandCalls).toBe(3) + }) + it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockImplementation((target: string) => target.endsWith('MERGE_HEAD')) @@ -1631,7 +1818,10 @@ describe('getStatus', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M'], - { cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) } + expect.objectContaining({ + cwd: '/repo', + env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) + }) ) expect(result.entries).toEqual([ { path: 'docs/a => b.txt', status: 'modified', area: 'unstaged', added: 1, removed: 0 } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 98d04ec93b9..120c84869d2 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -52,6 +52,7 @@ import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit' import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' +import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' import { beginGitStatusLineStatsCacheWrite, @@ -92,12 +93,12 @@ const effectiveUpstreamStatusInFlight = new Map>() const gitDiffReadDedupe = new InFlightPromiseDedupe() const effectiveUpstreamStatusWriteGeneration = new Map() -const statusReadsInFlight = new Map>() +const statusReadLeaseOwner = new GitStatusReadLeaseOwner() // Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() - statusReadsInFlight.clear() + statusReadLeaseOwner.invalidate() clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() @@ -216,31 +217,17 @@ export async function getStatus( options: GetStatusOptions = {} ): Promise { gitDiffReadDedupe.clear() - if (options.signal) { - return runGetStatus(worktreePath, options) - } // Why: dedupe only concurrent identical reads; after settle, callers must run a fresh read. const cacheKey = getStatusReadKey(worktreePath, options) - const inFlightStatus = statusReadsInFlight.get(cacheKey) - if (inFlightStatus) { - return inFlightStatus - } - - const statusPromise = runGetStatus(worktreePath, options) - statusReadsInFlight.set(cacheKey, statusPromise) - try { - return await statusPromise - } finally { - if (statusReadsInFlight.get(cacheKey) === statusPromise) { - statusReadsInFlight.delete(cacheKey) - } - } + return statusReadLeaseOwner.lease(cacheKey, options.signal, (sharedSignal) => + runGetStatus(worktreePath, { ...options, signal: sharedSignal }) + ) } function getStatusReadKey(worktreePath: string, options: GetStatusOptions): string { // Why: each key part can change the output shape or runtime routing. const limit = resolveGitStatusLimit(options.limit) - return [ + return stableInFlightKey([ worktreePath, options.wslDistro ?? '', options.includeIgnored === true, @@ -248,8 +235,8 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri options.bypassEffectiveUpstreamNegativeCache === true, limit, // Why: this changes which entries survive, so it must not share a cache slot. - (options.sharedLinkPaths ?? []).join('\u0001') - ].join('\0') + options.sharedLinkPaths ?? [] + ]) } /** Remove untracked entries that are shared symlinks Orca created. diff --git a/src/main/github/client.ts b/src/main/github/client.ts index f1df222b93d..d68c77ad263 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -658,11 +658,12 @@ function checkRollupEntries(value: unknown): unknown[] { function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] { const entries = checkRollupEntries(value) if (entries.length === 0) { - return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0 } + return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0, neutral: 0 } } let passed = 0 let failed = 0 let pending = 0 + let neutral = 0 for (const entry of entries) { if (typeof entry !== 'object' || entry === null) { pending += 1 @@ -671,7 +672,7 @@ function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSumma const raw = entry as Record const conclusion = String(raw.conclusion ?? raw.state ?? '').toUpperCase() const status = String(raw.status ?? '').toUpperCase() - if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(conclusion)) { + if (['SUCCESS', 'SKIPPED'].includes(conclusion)) { passed += 1 } else if ( ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes( @@ -679,18 +680,19 @@ function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSumma ) ) { failed += 1 - } else if (status === 'COMPLETED' && conclusion) { - failed += 1 + } else if (status === 'COMPLETED') { + neutral += 1 } else { pending += 1 } } return { - state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success', + state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : neutral > 0 ? 'neutral' : 'success', total: entries.length, passed, failed, - pending + pending, + neutral } } diff --git a/src/main/github/mappers.ts b/src/main/github/mappers.ts index c61c8c9f55c..888b02ea527 100644 --- a/src/main/github/mappers.ts +++ b/src/main/github/mappers.ts @@ -1,4 +1,5 @@ import type { PRInfo, IssueInfo, CheckStatus, PRCheckDetail } from '../../shared/types' +import { derivePRCheckStatusFromRollup } from '../../shared/pr-check-status' // ── REST API check-runs mapping ─────────────────────────────────────── // The REST check-runs endpoint returns separate status + conclusion fields @@ -143,44 +144,5 @@ export function mapIssueInfo(data: { } export function deriveCheckStatus(rollup: unknown[] | null | undefined): CheckStatus { - if (!rollup || !Array.isArray(rollup) || rollup.length === 0) { - return 'neutral' - } - - let hasFailure = false - let hasPending = false - - for (const check of rollup as { status?: string; conclusion?: string; state?: string }[]) { - const conclusion = check.conclusion?.toUpperCase() - const status = check.status?.toUpperCase() - const state = check.state?.toUpperCase() - - if ( - conclusion === 'FAILURE' || - conclusion === 'TIMED_OUT' || - conclusion === 'CANCELLED' || - // Why: action_required (e.g. an unapproved workflow run) blocks merge until - // someone acts; treat it as needs-attention rather than a silent pass. - conclusion === 'ACTION_REQUIRED' || - state === 'FAILURE' || - state === 'ERROR' - ) { - hasFailure = true - } else if ( - status === 'IN_PROGRESS' || - status === 'QUEUED' || - status === 'PENDING' || - state === 'PENDING' - ) { - hasPending = true - } - } - - if (hasFailure) { - return 'failure' - } - if (hasPending) { - return 'pending' - } - return 'success' + return derivePRCheckStatusFromRollup(rollup) } diff --git a/src/main/gitlab/mappers-workitem.test.ts b/src/main/gitlab/mappers-workitem.test.ts index 1c5fe731373..0bbcfec3bca 100644 --- a/src/main/gitlab/mappers-workitem.test.ts +++ b/src/main/gitlab/mappers-workitem.test.ts @@ -52,6 +52,18 @@ describe('mapMRToWorkItem', () => { expect(item.isCrossRepository).toBe(true) }) + it('maps GitLab mergeability when the MR payload includes merge status', () => { + expect( + mapMRToWorkItem( + { iid: 1, title: 't', state: 'opened', detailed_merge_status: 'mergeable' }, + 'g/p' + ).mergeable + ).toBe('MERGEABLE') + expect( + mapMRToWorkItem({ iid: 2, title: 't', state: 'opened', has_conflicts: true }, 'g/p').mergeable + ).toBe('CONFLICTING') + }) + it('does not flag cross-repository when project ids are absent', () => { const item = mapMRToWorkItem({ iid: 1, title: 't', state: 'opened' }, 'g/p') expect(item.isCrossRepository).toBe(false) diff --git a/src/main/gitlab/mappers.test.ts b/src/main/gitlab/mappers.test.ts index 91fd9b62b09..d96da3eb9be 100644 --- a/src/main/gitlab/mappers.test.ts +++ b/src/main/gitlab/mappers.test.ts @@ -38,8 +38,9 @@ describe('mapPipelineJobStatusToConclusion', () => { expect(mapPipelineJobStatusToConclusion('skipped')).toBe('skipped') }) - it("maps 'manual' to neutral so it doesn't stall pending forever", () => { - expect(mapPipelineJobStatusToConclusion('manual')).toBe('neutral') + it('maps manual and action-required jobs to an actionable conclusion', () => { + expect(mapPipelineJobStatusToConclusion('manual')).toBe('action_required') + expect(mapPipelineJobStatusToConclusion('action_required')).toBe('action_required') }) it('maps active lifecycle states to pending', () => { @@ -227,7 +228,7 @@ describe('derivePipelineStatus', () => { expect(derivePipelineStatus('success')).toBe('success') expect(derivePipelineStatus('failed')).toBe('failure') expect(derivePipelineStatus('running')).toBe('pending') - expect(derivePipelineStatus('manual')).toBe('neutral') + expect(derivePipelineStatus('manual')).toBe('failure') }) it('rolls up an array of jobs', () => { @@ -238,9 +239,18 @@ describe('derivePipelineStatus', () => { it('failure beats pending in the rollup', () => { expect(derivePipelineStatus([{ status: 'failed' }, { status: 'running' }])).toBe('failure') + expect(derivePipelineStatus([{ status: 'manual' }, { status: 'success' }])).toBe('failure') }) it('handles a single object with status', () => { expect(derivePipelineStatus({ status: 'success' })).toBe('success') }) + + it('keeps malformed and unknown array jobs neutral', () => { + expect(derivePipelineStatus([{ status: 'future_status' }])).toBe('neutral') + expect(derivePipelineStatus([{}])).toBe('neutral') + expect(derivePipelineStatus([{ status: 'success' }, { status: 'future_status' }])).toBe( + 'neutral' + ) + }) }) diff --git a/src/main/gitlab/mappers.ts b/src/main/gitlab/mappers.ts index 55dffb89c64..7b45c9b64f0 100644 --- a/src/main/gitlab/mappers.ts +++ b/src/main/gitlab/mappers.ts @@ -161,19 +161,20 @@ export function derivePipelineStatus( } let hasFailure = false let hasPending = false + let hasUnknown = false for (const job of rollup) { - const s = job.status?.toLowerCase() - if (s === 'failed') { - hasFailure = true - } else if ( - s === 'created' || - s === 'pending' || - s === 'running' || - s === 'waiting_for_resource' || - s === 'preparing' || - s === 'scheduled' + const s = job.status?.toLowerCase() ?? '' + const conclusion = mapPipelineJobStatusToConclusion(s) + if ( + conclusion === 'failure' || + conclusion === 'cancelled' || + conclusion === 'action_required' ) { + hasFailure = true + } else if (conclusion === 'pending') { hasPending = true + } else if (conclusion !== 'success' && conclusion !== 'skipped' && conclusion !== 'neutral') { + hasUnknown = true } } if (hasFailure) { @@ -182,7 +183,7 @@ export function derivePipelineStatus( if (hasPending) { return 'pending' } - return 'success' + return hasUnknown ? 'neutral' : 'success' } // ── Raw → GitLabWorkItem mapping ──────────────────────────────────── @@ -209,6 +210,8 @@ type GitLabMRRawForWorkItem = { * when the workspace flow can't safely resolve the head. */ source_project_id?: number target_project_id?: number + has_conflicts?: boolean + detailed_merge_status?: string } export function mapMRToWorkItem( @@ -237,6 +240,9 @@ export function mapMRToWorkItem( data.target_project_id !== undefined && data.source_project_id !== data.target_project_id, repoId, + ...(data.has_conflicts !== undefined || data.detailed_merge_status !== undefined + ? { mergeable: deriveMergeable(data) } + : {}), ...(projectRef ? { projectRef } : {}) } } @@ -283,7 +289,7 @@ function classifyPipelineString(status: string): CheckStatus { if (s === 'success') { return 'success' } - if (s === 'failed') { + if (s === 'failed' || s === 'manual' || s === 'action_required') { return 'failure' } if ( diff --git a/src/main/hang-watchdog/hang-detection-marker.ts b/src/main/hang-watchdog/hang-detection-marker.ts index a6e6fbddd08..a32d872b8ec 100644 --- a/src/main/hang-watchdog/hang-detection-marker.ts +++ b/src/main/hang-watchdog/hang-detection-marker.ts @@ -1,7 +1,7 @@ import { readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -// Why: written by the plain-Node watchdog child when main-thread heartbeats stop, and rewritten if +// Why: written by the watchdog worker when main-thread heartbeats stop, and rewritten if // they resume; consumed on the next launch to report how long the stall lasted and whether it ever // cleared. `selfRecovered` separates a real deadlock from a long-but-survivable stall — the two are // indistinguishable at detection time, and only the latter would have been a destructive kill. diff --git a/src/main/hang-watchdog/hang-watchdog-child-loop.test.ts b/src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts similarity index 96% rename from src/main/hang-watchdog/hang-watchdog-child-loop.test.ts rename to src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts index 8d390c04e8e..8d1c3ebfcd1 100644 --- a/src/main/hang-watchdog/hang-watchdog-child-loop.test.ts +++ b/src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop' +import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop' const TIMEOUT_MS = 45_000 const CHECK_INTERVAL_MS = 5_000 @@ -8,7 +8,7 @@ function loopWithClock(startAt = 0) { let now = startAt const onHangDetected = vi.fn() const onHangResolved = vi.fn() - const loop = createHangWatchdogChildLoop({ + const loop = createHangWatchdogDetectionLoop({ timeoutMs: TIMEOUT_MS, checkIntervalMs: CHECK_INTERVAL_MS, now: () => now, @@ -18,7 +18,7 @@ function loopWithClock(startAt = 0) { return { loop, onHangDetected, onHangResolved, advance: (ms: number) => (now += ms) } } -describe('createHangWatchdogChildLoop', () => { +describe('createHangWatchdogDetectionLoop', () => { it('does not fire while heartbeats keep arriving', () => { const { loop, onHangDetected, advance } = loopWithClock() for (let i = 0; i < 100; i++) { diff --git a/src/main/hang-watchdog/hang-watchdog-child-loop.ts b/src/main/hang-watchdog/hang-watchdog-detection-loop.ts similarity index 86% rename from src/main/hang-watchdog/hang-watchdog-child-loop.ts rename to src/main/hang-watchdog/hang-watchdog-detection-loop.ts index 3a8ebb34fdf..9d45cba7797 100644 --- a/src/main/hang-watchdog/hang-watchdog-child-loop.ts +++ b/src/main/hang-watchdog/hang-watchdog-detection-loop.ts @@ -1,4 +1,4 @@ -export type HangWatchdogChildLoopConfig = { +export type HangWatchdogDetectionLoopConfig = { timeoutMs: number checkIntervalMs: number now: () => number @@ -7,14 +7,14 @@ export type HangWatchdogChildLoopConfig = { onHangResolved: (unresponsiveMs: number) => void } -export type HangWatchdogChildLoop = { +export type HangWatchdogDetectionLoop = { recordHeartbeat: () => void tick: () => void } -export function createHangWatchdogChildLoop( - config: HangWatchdogChildLoopConfig -): HangWatchdogChildLoop { +export function createHangWatchdogDetectionLoop( + config: HangWatchdogDetectionLoopConfig +): HangWatchdogDetectionLoop { let lastHeartbeatAt = config.now() let lastTickAt = config.now() let detected = false diff --git a/src/main/hang-watchdog/hang-watchdog-entry-path.ts b/src/main/hang-watchdog/hang-watchdog-entry-path.ts deleted file mode 100644 index 74f8b1d0089..00000000000 --- a/src/main/hang-watchdog/hang-watchdog-entry-path.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' - -export function resolveHangWatchdogEntryPath( - appPath: string, - isPackaged: boolean, - pathExists: (candidate: string) => boolean = existsSync -): string { - // Why: ELECTRON_RUN_AS_NODE bypasses asar integration, so the packaged entry must be forked from app.asar.unpacked. - const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath - const adjacentBuildEntry = join(basePath, 'main-thread-hang-watchdog-entry.js') - // Why: electron-vite's unpackaged appPath is already out/main; appending out/main again would silently disable the watchdog in dev and E2E builds. - if (!isPackaged && pathExists(adjacentBuildEntry)) { - return adjacentBuildEntry - } - return join(basePath, 'out', 'main', 'main-thread-hang-watchdog-entry.js') -} diff --git a/src/main/hang-watchdog/hang-watchdog-worker-path.test.ts b/src/main/hang-watchdog/hang-watchdog-worker-path.test.ts new file mode 100644 index 00000000000..3ade5b1cbf3 --- /dev/null +++ b/src/main/hang-watchdog/hang-watchdog-worker-path.test.ts @@ -0,0 +1,26 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path' + +describe('resolveHangWatchdogWorkerPath', () => { + it('resolves packaged workers inside app.asar', () => { + const appPath = join('/apps', 'orca', 'app.asar') + expect(resolveHangWatchdogWorkerPath(appPath, true)).toBe( + join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js') + ) + }) + + it('uses an adjacent entry when the dev app path is out/main', () => { + const appPath = join('/repo', 'out', 'main') + const adjacent = join(appPath, 'main-thread-hang-watchdog-entry.js') + const pathExists = vi.fn((candidate: string) => candidate === adjacent) + expect(resolveHangWatchdogWorkerPath(appPath, false, pathExists)).toBe(adjacent) + }) + + it('resolves through out/main from a dev project root', () => { + const appPath = join('/repo', 'orca') + expect(resolveHangWatchdogWorkerPath(appPath, false, () => false)).toBe( + join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js') + ) + }) +}) diff --git a/src/main/hang-watchdog/hang-watchdog-worker-path.ts b/src/main/hang-watchdog/hang-watchdog-worker-path.ts new file mode 100644 index 00000000000..7efc300cb45 --- /dev/null +++ b/src/main/hang-watchdog/hang-watchdog-worker-path.ts @@ -0,0 +1,14 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +export function resolveHangWatchdogWorkerPath( + appPath: string, + isPackaged: boolean, + pathExists: (candidate: string) => boolean = existsSync +): string { + const adjacentBuildEntry = join(appPath, 'main-thread-hang-watchdog-entry.js') + if (!isPackaged && pathExists(adjacentBuildEntry)) { + return adjacentBuildEntry + } + return join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js') +} diff --git a/src/main/hang-watchdog/hang-watchdog-worker-protocol.ts b/src/main/hang-watchdog/hang-watchdog-worker-protocol.ts new file mode 100644 index 00000000000..14e6d007796 --- /dev/null +++ b/src/main/hang-watchdog/hang-watchdog-worker-protocol.ts @@ -0,0 +1,12 @@ +export const HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS = 2_000 +export const HANG_WATCHDOG_TIMEOUT_MS = 45_000 +export const HANG_WATCHDOG_CHECK_INTERVAL_MS = 5_000 + +export type HangWatchdogWorkerData = { + parentPid: number + markerPath: string + timeoutMs: number + checkIntervalMs: number +} + +export type MainToHangWatchdogWorkerMessage = { type: 'heartbeat' } | { type: 'shutdown' } diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts index 0f36e8acad9..a1ececdadb4 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts @@ -12,7 +12,11 @@ vi.mock('node:child_process', () => ({ spawn: spawnMock })) -import { recordHangObservation } from './main-thread-hang-watchdog-entry' +import { + isHangWatchdogWorkerData, + recordHangObservation, + runWatchdog +} from './main-thread-hang-watchdog-entry' describe('recordHangObservation', () => { let dir: string @@ -67,10 +71,8 @@ describe('recordHangObservation', () => { }) }) - // Why: this is the safety contract of the whole PR. Observing a hang must never kill, relaunch, - // or exit — a false positive would SIGKILL a live main thread mid-write. If a future change - // reintroduces recovery, this test must fail loudly rather than ship silently. it('never spawns, signals, or exits', () => { + // Why: a false positive must never kill a live main thread while writes may be in flight. recordHangObservation({ parentPid: 4242, markerPath: join(dir, 'marker.json'), @@ -104,3 +106,75 @@ describe('recordHangObservation', () => { ).not.toThrow() }) }) + +describe('watchdog worker entry', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('accepts only complete positive worker data', () => { + expect( + isHangWatchdogWorkerData({ + parentPid: 42, + markerPath: '/tmp/marker', + timeoutMs: 100, + checkIntervalMs: 25 + }) + ).toBe(true) + for (const value of [ + null, + {}, + { parentPid: 0, markerPath: '/tmp/marker', timeoutMs: 100, checkIntervalMs: 25 }, + { parentPid: 42, markerPath: 7, timeoutMs: 100, checkIntervalMs: 25 }, + { parentPid: 42, markerPath: '/tmp/marker', timeoutMs: 0, checkIntervalMs: 25 }, + { parentPid: 42, markerPath: '/tmp/marker', timeoutMs: 100, checkIntervalMs: Infinity } + ]) { + expect(isHangWatchdogWorkerData(value)).toBe(false) + } + }) + + it('routes heartbeats and shuts down its timer and port', () => { + const markerPath = join(tmpdir(), `hang-watchdog-entry-${process.pid}.json`) + let onMessage: ((message: { type: 'heartbeat' | 'shutdown' }) => void) | undefined + const port = { + on: vi.fn( + (_event: 'message', listener: (message: { type: 'heartbeat' | 'shutdown' }) => void) => { + onMessage = listener + } + ), + close: vi.fn() + } + try { + runWatchdog( + { + parentPid: process.pid, + markerPath, + timeoutMs: 100, + checkIntervalMs: 25 + }, + port + ) + vi.advanceTimersByTime(75) + onMessage?.({ type: 'heartbeat' }) + vi.advanceTimersByTime(75) + expect(consumeHangDetectionMarker(markerPath)).toBeNull() + vi.advanceTimersByTime(50) + expect(consumeHangDetectionMarker(markerPath)).toMatchObject({ selfRecovered: false }) + + onMessage?.({ type: 'heartbeat' }) + expect(consumeHangDetectionMarker(markerPath)).toMatchObject({ selfRecovered: true }) + onMessage?.({ type: 'shutdown' }) + expect(port.close).toHaveBeenCalledOnce() + vi.advanceTimersByTime(1_000) + expect(port.close).toHaveBeenCalledOnce() + expect(consumeHangDetectionMarker(markerPath)).toBeNull() + } finally { + rmSync(markerPath, { force: true }) + } + }) +}) diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts index 8d45c725a72..2c60b17e9ff 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts @@ -1,20 +1,17 @@ -// Forked with ELECTRON_RUN_AS_NODE from the main process. Watches heartbeats from the main -// thread; if they stop (e.g. the macOS 26 AppKit scene-update deadlock) it records a marker so -// the next launch can report the stall, and rewrites it if the heartbeats come back. -// -// Why no kill: a deadlocked main thread has already lost whatever sat in the persistence debounce -// window, so killing it recovers nothing the user could not recover by force-quitting. A false -// positive, though, would SIGKILL a live main thread that was merely blocked — most plausibly on -// I/O, i.e. exactly when writes are in flight. All of the downside sits in the misfire, so this -// measures first: `selfRecovered` counts the stalls a killer would have gotten wrong. -// -// Must never import electron. -import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop' +import { isMainThread, parentPort, workerData } from 'node:worker_threads' +import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop' import { writeHangDetectionMarker } from './hang-detection-marker' +import type { + HangWatchdogWorkerData, + MainToHangWatchdogWorkerMessage +} from './hang-watchdog-worker-protocol' -const DEFAULT_TIMEOUT_MS = 45_000 -const DEFAULT_CHECK_INTERVAL_MS = 5_000 +type HangWatchdogPort = { + on: (event: 'message', listener: (message: MainToHangWatchdogWorkerMessage) => void) => unknown + close: () => void +} +// Observation only: a false positive must never kill a live main thread mid-write. export function recordHangObservation(options: { parentPid: number markerPath: string @@ -36,37 +33,65 @@ export function recordHangObservation(options: { } } -function runWatchdog(parentPid: number): void { - const markerPath = process.env.ORCA_HANG_WATCHDOG_MARKER_PATH ?? '' - const timeoutMs = Number(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS - const checkIntervalMs = - Number(process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS) || DEFAULT_CHECK_INTERVAL_MS - - const loop = createHangWatchdogChildLoop({ - timeoutMs, - checkIntervalMs, +export function runWatchdog( + config: HangWatchdogWorkerData, + port: HangWatchdogPort | null = parentPort +): void { + if (!port) { + return + } + const loop = createHangWatchdogDetectionLoop({ + timeoutMs: config.timeoutMs, + checkIntervalMs: config.checkIntervalMs, now: () => Date.now(), onHangDetected: (unresponsiveMs) => - recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: false }), + recordHangObservation({ + parentPid: config.parentPid, + markerPath: config.markerPath, + unresponsiveMs, + selfRecovered: false + }), // Why: rewriting the marker keeps one observation per stall rather than two rows to reconcile. onHangResolved: (unresponsiveMs) => - recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: true }) + recordHangObservation({ + parentPid: config.parentPid, + markerPath: config.markerPath, + unresponsiveMs, + selfRecovered: true + }) }) - process.on('message', (message) => { - const type = (message as { type?: string } | null)?.type - if (type === 'heartbeat') { + let checkTimer: ReturnType | null = setInterval( + () => loop.tick(), + config.checkIntervalMs + ) + port.on('message', (message: MainToHangWatchdogWorkerMessage) => { + if (message.type === 'heartbeat') { loop.recordHeartbeat() - } else if (type === 'shutdown') { - process.exit(0) + } else if (message.type === 'shutdown') { + if (checkTimer) { + clearInterval(checkTimer) + checkTimer = null + } + port.close() } }) - // Why: a normal parent exit closes the IPC channel; the watchdog must not outlive it and misfire. - process.on('disconnect', () => process.exit(0)) - setInterval(() => loop.tick(), checkIntervalMs) } -const configuredParentPid = Number(process.env.ORCA_HANG_WATCHDOG_PARENT_PID) -if (Number.isInteger(configuredParentPid) && configuredParentPid > 0) { - runWatchdog(configuredParentPid) +export function isHangWatchdogWorkerData(value: unknown): value is HangWatchdogWorkerData { + const data = value as Partial | null + return ( + !!data && + Number.isInteger(data.parentPid) && + (data.parentPid ?? 0) > 0 && + typeof data.markerPath === 'string' && + Number.isFinite(data.timeoutMs) && + (data.timeoutMs ?? 0) > 0 && + Number.isFinite(data.checkIntervalMs) && + (data.checkIntervalMs ?? 0) > 0 + ) +} + +if (!isMainThread && isHangWatchdogWorkerData(workerData)) { + runWatchdog(workerData) } diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog.test.ts b/src/main/hang-watchdog/main-thread-hang-watchdog.test.ts index b5c1063a915..eaaea54225b 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog.test.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' -const { forkMock, appMock } = vi.hoisted(() => ({ - forkMock: vi.fn(), +const { workerState, appMock } = vi.hoisted(() => ({ + workerState: { + calls: [] as unknown[][], + instance: null as object | null, + error: null as Error | null + }, appMock: { isPackaged: true, getAppPath: vi.fn(() => '/apps/orca/app.asar'), @@ -9,8 +14,16 @@ const { forkMock, appMock } = vi.hoisted(() => ({ } })) -vi.mock('node:child_process', () => ({ - fork: forkMock +vi.mock('node:worker_threads', () => ({ + Worker: class WorkerMock { + constructor(...args: unknown[]) { + workerState.calls.push(args) + if (workerState.error) { + throw workerState.error + } + return workerState.instance as WorkerMock + } + } })) vi.mock('electron', () => ({ @@ -29,28 +42,33 @@ function withPlatform(platform: NodeJS.Platform, run: () => T): T { } } -function fakeChild() { +function fakeWorker() { return { - connected: true, - stderr: { on: vi.fn() }, + postMessage: vi.fn(), + unref: vi.fn(), on: vi.fn(), - send: vi.fn(), - disconnect: vi.fn(), - kill: vi.fn() + once: vi.fn() } } describe('installMainThreadHangWatchdog', () => { beforeEach(() => { vi.useFakeTimers() - forkMock.mockReset() + workerState.calls = [] + workerState.instance = null + workerState.error = null appMock.on.mockReset() appMock.isPackaged = true delete process.env.ORCA_HANG_WATCHDOG_FORCE + delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS + delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS }) afterEach(() => { vi.useRealTimers() + delete process.env.ORCA_HANG_WATCHDOG_FORCE + delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS + delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS }) it('is a no-op off macOS', () => { @@ -60,7 +78,7 @@ describe('installMainThreadHangWatchdog', () => { expect( withPlatform('linux', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) ).toBeNull() - expect(forkMock).not.toHaveBeenCalled() + expect(workerState.calls).toHaveLength(0) }) it('is a no-op in unpackaged builds unless forced', () => { @@ -69,62 +87,98 @@ describe('installMainThreadHangWatchdog', () => { withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) ).toBeNull() process.env.ORCA_HANG_WATCHDOG_FORCE = '1' - const child = fakeChild() - forkMock.mockReturnValue(child) + const worker = fakeWorker() + workerState.instance = worker expect( withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) ).not.toBeNull() delete process.env.ORCA_HANG_WATCHDOG_FORCE }) - it('forks the watchdog as plain Node with pid, bundle, and marker config', () => { - const child = fakeChild() - forkMock.mockReturnValue(child) + it('starts a worker with pid, marker, and timing config', () => { + const worker = fakeWorker() + workerState.instance = worker const handle = withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }) ) expect(handle).not.toBeNull() - const [, , options] = forkMock.mock.calls[0] - expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(options.env.ORCA_HANG_WATCHDOG_PARENT_PID).toBe(String(process.pid)) - expect(options.env.ORCA_HANG_WATCHDOG_MARKER_PATH).toContain('/ud') + const [workerPath, rawOptions] = workerState.calls[0] + const options = rawOptions as { + name: string + workerData: { + parentPid: number + markerPath: string + timeoutMs: number + checkIntervalMs: number + } + } + expect(workerPath).toBe( + join('/apps/orca/app.asar', 'out', 'main', 'main-thread-hang-watchdog-entry.js') + ) + expect(options.name).toBe('orca-main-thread-hang-watchdog') + expect(options.workerData).toMatchObject({ + parentPid: process.pid, + markerPath: join('/ud', 'main-thread-hang.json'), + timeoutMs: 45_000, + checkIntervalMs: 5_000 + }) + expect(worker.unref).toHaveBeenCalled() }) - it('sends heartbeats on an interval and shutdown+disconnect on stop', () => { - const child = fakeChild() - forkMock.mockReturnValue(child) + it('sends heartbeats on an interval and shutdown on stop', () => { + const worker = fakeWorker() + workerState.instance = worker const handle = withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }) ) vi.advanceTimersByTime(6_000) - const heartbeats = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat') + const heartbeats = worker.postMessage.mock.calls.filter(([m]) => m.type === 'heartbeat') expect(heartbeats.length).toBe(3) handle?.stop() - expect(child.send.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true) - expect(child.disconnect).toHaveBeenCalled() + expect(worker.postMessage.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true) - // Why: quit fires will-quit twice; a second stop must not resend or throw. handle?.stop() - const shutdowns = child.send.mock.calls.filter(([m]) => m.type === 'shutdown') + const shutdowns = worker.postMessage.mock.calls.filter(([m]) => m.type === 'shutdown') expect(shutdowns.length).toBe(1) vi.advanceTimersByTime(10_000) - const heartbeatsAfterStop = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat') + const heartbeatsAfterStop = worker.postMessage.mock.calls.filter( + ([m]) => m.type === 'heartbeat' + ) expect(heartbeatsAfterStop.length).toBe(3) }) + it('passes test timing overrides to the worker', () => { + process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS = '900' + process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS = '100' + workerState.instance = fakeWorker() + withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) + const options = workerState.calls[0][1] as { + workerData: { timeoutMs: number; checkIntervalMs: number } + } + expect(options.workerData).toMatchObject({ timeoutMs: 900, checkIntervalMs: 100 }) + }) + it('registers stop on will-quit', () => { - const child = fakeChild() - forkMock.mockReturnValue(child) + workerState.instance = fakeWorker() withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) expect(appMock.on).toHaveBeenCalledWith('will-quit', expect.any(Function)) }) - it('returns null and stays inert when the fork itself fails', () => { - forkMock.mockImplementation(() => { - throw new Error('spawn failure') - }) + it('stops heartbeat work when the worker exits', () => { + const worker = fakeWorker() + workerState.instance = worker + withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) + const exitListener = worker.once.mock.calls.find(([event]) => event === 'exit')?.[1] + expect(exitListener).toEqual(expect.any(Function)) + exitListener() + vi.advanceTimersByTime(6_000) + expect(worker.postMessage).not.toHaveBeenCalled() + }) + + it('returns null and stays inert when worker construction fails', () => { + workerState.error = new Error('worker failure') expect( withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) ).toBeNull() diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog.ts b/src/main/hang-watchdog/main-thread-hang-watchdog.ts index f859f8b5745..46454429fc7 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog.ts @@ -1,19 +1,25 @@ -import { fork, type ChildProcess } from 'node:child_process' +import { Worker } from 'node:worker_threads' import { app } from 'electron' -import { resolveHangWatchdogEntryPath } from './hang-watchdog-entry-path' import { hangDetectionMarkerPath } from './hang-detection-marker' - -const HEARTBEAT_INTERVAL_MS = 2_000 +import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path' +import { + HANG_WATCHDOG_CHECK_INTERVAL_MS, + HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS, + HANG_WATCHDOG_TIMEOUT_MS, + type HangWatchdogWorkerData, + type MainToHangWatchdogWorkerMessage +} from './hang-watchdog-worker-protocol' export type MainThreadHangWatchdogHandle = { stop: () => void - child: ChildProcess + worker: Worker +} + +function positiveTiming(value: string | undefined, fallback: number): number { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback } -// Why: macOS 26 scene-backed AppKit windows can deadlock the main thread inside -// FrontBoardServices with no crash and no event loop left to self-report. A plain-Node sibling -// process watches heartbeats and records the stall so the next launch can report it. It observes -// only — see main-thread-hang-watchdog-entry.ts for why it does not kill the parent. export function installMainThreadHangWatchdog(options: { userDataPath: string }): MainThreadHangWatchdogHandle | null { @@ -24,58 +30,57 @@ export function installMainThreadHangWatchdog(options: { if (!app.isPackaged && process.env.ORCA_HANG_WATCHDOG_FORCE !== '1') { return null } - const entryPath = resolveHangWatchdogEntryPath(app.getAppPath(), app.isPackaged) - let child: ChildProcess + const workerPath = resolveHangWatchdogWorkerPath(app.getAppPath(), app.isPackaged) + const workerData: HangWatchdogWorkerData = { + parentPid: process.pid, + markerPath: hangDetectionMarkerPath(options.userDataPath), + timeoutMs: positiveTiming(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS, HANG_WATCHDOG_TIMEOUT_MS), + checkIntervalMs: positiveTiming( + process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS, + HANG_WATCHDOG_CHECK_INTERVAL_MS + ) + } + let worker: Worker try { - child = fork(entryPath, [], { - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', - ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid), - ORCA_HANG_WATCHDOG_MARKER_PATH: hangDetectionMarkerPath(options.userDataPath) - } + // Why: the worker survives an AppKit main-thread deadlock without another Electron process. + worker = new Worker(workerPath, { + name: 'orca-main-thread-hang-watchdog', + workerData }) } catch (error) { - console.error('[hang-watchdog] failed to fork watchdog process:', error) + console.error('[hang-watchdog] failed to start watchdog worker:', error) return null } - child.stderr?.on('data', (chunk: Buffer) => { - console.error('[hang-watchdog]', String(chunk).trimEnd()) + worker.on('error', (error) => { + console.error('[hang-watchdog] watchdog worker failed:', error) }) - // Why: the watchdog is a safety net — if it dies, run without it; a restart loop here must never affect the app. - child.on('error', () => {}) - const heartbeatTimer = setInterval(() => { - if (child.connected) { - try { - child.send({ type: 'heartbeat' }, () => {}) - } catch { - // Channel raced closed between the check and the send. - } - } - }, HEARTBEAT_INTERVAL_MS) let stopped = false + const postMessage = (message: MainToHangWatchdogWorkerMessage): void => { + if (stopped && message.type === 'heartbeat') { + return + } + try { + worker.postMessage(message) + } catch { + // The worker already exited. + } + } + const heartbeatTimer = setInterval(() => { + postMessage({ type: 'heartbeat' }) + }, HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS) const stop = (): void => { if (stopped) { return } stopped = true clearInterval(heartbeatTimer) - if (child.connected) { - try { - child.send({ type: 'shutdown' }, () => {}) - } catch { - // Already disconnecting. - } - } - // Why: disconnect guarantees the child observes parent shutdown and exits instead of misreading quit as a hang. - try { - child.disconnect() - } catch { - // Channel already closed. - } + postMessage({ type: 'shutdown' }) } - // Why: will-quit fires twice during quit; stop is idempotent. + worker.once('exit', () => { + stopped = true + clearInterval(heartbeatTimer) + }) + worker.unref() app.on('will-quit', stop) - return { stop, child } + return { stop, worker } } diff --git a/src/main/index.ts b/src/main/index.ts index 5dbd92065f0..59eeccbbad7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -127,6 +127,7 @@ import { type GpuFallbackEnvironment, type WindowsGpuFallbackEnvironment } from './startup/gpu-fallback-marker' +import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -1518,10 +1519,13 @@ function maybeApplyGpuFallbackForThisLaunch(): void { return } app.disableHardwareAcceleration() - app.commandLine.appendSwitch('disable-gpu') + const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) gpuFallbackActiveThisLaunch = true + // Why: with no GPU child left, child-process-gone can't report a GPU fault, so + // name the applied switches in the trail any later crash report carries. recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow + crashesInWindow: marker.crashesInWindow, + switches: appliedSwitches.join(',') }) } @@ -1541,23 +1545,6 @@ async function handleGpuChildCrash(reason: string, exitCode: number | null): Pro crashesInWindow: result.crashesInWindow }) const engagedAt = Date.now() - const environment = getWindowsGpuFallbackEnvironment() - if (!environment) { - return - } - try { - writeGpuFallbackMarker( - app.getPath('userData'), - { - engagedAt, - crashesInWindow: result.crashesInWindow - }, - environment - ) - } catch (error) { - console.warn('[gpu-fallback] failed to persist marker:', error) - return - } const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined let restartDecision: GpuFallbackRestartDecision try { @@ -1578,6 +1565,23 @@ async function handleGpuChildCrash(reason: string, exitCode: number | null): Pro recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData) return } + const environment = getWindowsGpuFallbackEnvironment() + if (!environment) { + return + } + try { + writeGpuFallbackMarker( + app.getPath('userData'), + { + engagedAt, + crashesInWindow: result.crashesInWindow + }, + environment + ) + } catch (error) { + console.warn('[gpu-fallback] failed to persist marker:', error) + return + } isQuitting = true relaunchApp('gpu-fallback', fallbackData) // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 7651d130651..c09fb2d7f80 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -8,6 +8,8 @@ const { getGuestWebContentsIdMock, getWebContentsIdByTabIdMock, getWorktreeIdForTabMock, + getAuthorizedGuestMock, + setGrabModeMock, openDevToolsMock, setAnnotationViewportBridgeMock, cancelDownloadMock, @@ -22,6 +24,8 @@ const { getGuestWebContentsIdMock: vi.fn(), getWebContentsIdByTabIdMock: vi.fn(() => new Map()), getWorktreeIdForTabMock: vi.fn(), + getAuthorizedGuestMock: vi.fn(), + setGrabModeMock: vi.fn(), openDevToolsMock: vi.fn().mockResolvedValue(true), setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true), cancelDownloadMock: vi.fn(), @@ -53,6 +57,8 @@ vi.mock('../browser/browser-manager', () => ({ getGuestWebContentsId: getGuestWebContentsIdMock, getWebContentsIdByTabId: getWebContentsIdByTabIdMock, getWorktreeIdForTab: getWorktreeIdForTabMock, + getAuthorizedGuest: getAuthorizedGuestMock, + setGrabMode: setGrabModeMock, openDevTools: openDevToolsMock, setAnnotationViewportBridge: setAnnotationViewportBridgeMock, cancelDownload: cancelDownloadMock @@ -79,6 +85,9 @@ describe('registerBrowserHandlers', () => { getWebContentsIdByTabIdMock.mockReset() getWebContentsIdByTabIdMock.mockReturnValue(new Map()) getWorktreeIdForTabMock.mockReset() + getAuthorizedGuestMock.mockReset() + setGrabModeMock.mockReset() + setGrabModeMock.mockResolvedValue(true) openDevToolsMock.mockReset() setAnnotationViewportBridgeMock.mockReset() cancelDownloadMock.mockReset() @@ -331,6 +340,255 @@ describe('registerBrowserHandlers', () => { } }) + it('waits for authorized registration even when an old guest is still live', async () => { + const guest = { id: 123 } as Electron.WebContents + getGuestWebContentsIdMock.mockReturnValue(122) + getAuthorizedGuestMock.mockReturnValueOnce(null).mockReturnValue(guest) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { + browserPageId: string + workspaceId: string + worktreeId: string + webContentsId: number + } + ) => boolean + + const pendingResult = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + await Promise.resolve() + expect(setGrabModeMock).not.toHaveBeenCalled() + + expect( + registerHandler( + { sender }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + ).toBe(true) + + await expect(pendingResult).resolves.toEqual({ ok: true }) + expect(setGrabModeMock).toHaveBeenCalledWith('page-1', true, guest) + }) + + it('returns not-ready when grab registration does not arrive', async () => { + vi.useFakeTimers() + try { + getGuestWebContentsIdMock.mockReturnValue(null) + getAuthorizedGuestMock.mockReturnValue(null) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + + const pendingResult = setGrabModeHandler( + { sender }, + { browserPageId: 'page-1', enabled: true } + ) + await vi.advanceTimersByTimeAsync(1_001) + + await expect(pendingResult).resolves.toEqual({ ok: false, reason: 'not-ready' }) + expect(setGrabModeMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('does not enable grab mode after a pending request is cancelled', async () => { + const guest = { id: 123 } as Electron.WebContents + let registered = false + getAuthorizedGuestMock.mockImplementation(() => (registered ? guest : null)) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { + browserPageId: string + workspaceId: string + worktreeId: string + webContentsId: number + } + ) => boolean + + const pendingEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + await expect( + setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: false }) + ).resolves.toEqual({ ok: true }) + + registered = true + expect( + registerHandler( + { sender }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + ).toBe(true) + + await expect(pendingEnable).resolves.toEqual({ ok: true }) + expect(setGrabModeMock).not.toHaveBeenCalled() + }) + + it('coalesces a cancelled pending enable into one later enable', async () => { + const guest = { id: 123 } as Electron.WebContents + let registered = false + getAuthorizedGuestMock.mockImplementation(() => (registered ? guest : null)) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { + browserPageId: string + workspaceId: string + worktreeId: string + webContentsId: number + } + ) => boolean + + const firstEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + await setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: false }) + const latestEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + + registered = true + registerHandler( + { sender }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + + await expect(Promise.all([firstEnable, latestEnable])).resolves.toEqual([ + { ok: true }, + { ok: true } + ]) + expect(setGrabModeMock).toHaveBeenCalledTimes(1) + expect(setGrabModeMock).toHaveBeenCalledWith('page-1', true, guest) + }) + + it('serializes in-flight mode changes so a stale enable cannot tear down the latest one', async () => { + const guest = { id: 123 } as Electron.WebContents + let resolveFirstEnable!: (success: boolean) => void + const firstEnable = new Promise((resolve) => { + resolveFirstEnable = resolve + }) + getAuthorizedGuestMock.mockReturnValue(guest) + setGrabModeMock.mockReturnValueOnce(firstEnable).mockResolvedValue(true) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + + const staleEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + await vi.waitFor(() => { + expect(setGrabModeMock).toHaveBeenCalledTimes(1) + }) + const disable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: false }) + const latestEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + + resolveFirstEnable(true) + await expect(Promise.all([staleEnable, disable, latestEnable])).resolves.toEqual([ + { ok: true }, + { ok: true }, + { ok: true } + ]) + + expect(setGrabModeMock.mock.calls).toEqual([ + ['page-1', true, guest], + ['page-1', true, guest] + ]) + }) + + it('distinguishes picker injection failure from guest readiness', async () => { + const guest = { id: 123 } as Electron.WebContents + getAuthorizedGuestMock.mockReturnValue(guest) + setGrabModeMock.mockResolvedValue(false) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + + await expect( + setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + ).resolves.toEqual({ ok: false, reason: 'injection-failed' }) + }) + it('resolves worktree and any-tab registration waiters when a guest registers', async () => { vi.useFakeTimers() try { diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index f5c1c8fb268..fb67b002234 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -45,6 +45,9 @@ let agentBrowserBridgeRef: AgentBrowserBridge | null = null const pendingTabRegistrations = new Map void>>() const pendingWorktreeTabRegistrations = new Map void>>() const pendingAnyTabRegistrations = new Set<() => void>() +const grabModeIntentByPageId = new Map() +const grabModeOperationByPageId = new Map>() +const GRAB_REGISTRATION_WAIT_MS = 1_000 function waitForRegistrationSet( registrationResolvers: Set<() => void>, @@ -100,6 +103,10 @@ export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000) if (isLiveBrowserWebContentsId(browserManager.getGuestWebContentsId(browserPageId))) { return Promise.resolve() } + return waitForNextTabRegistration(browserPageId, timeoutMs) +} + +function waitForNextTabRegistration(browserPageId: string, timeoutMs: number): Promise { let registrationResolvers = pendingTabRegistrations.get(browserPageId) if (!registrationResolvers) { registrationResolvers = new Set() @@ -110,6 +117,24 @@ export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000) }) } +function queueGrabModeOperation( + browserPageId: string, + operation: () => Promise +): Promise { + const previous = grabModeOperationByPageId.get(browserPageId) ?? Promise.resolve() + const result = previous.then(operation) + const completion = result.then( + () => {}, + () => {} + ) + grabModeOperationByPageId.set(browserPageId, completion) + return result.finally(() => { + if (grabModeOperationByPageId.get(browserPageId) === completion) { + grabModeOperationByPageId.delete(browserPageId) + } + }) +} + export function waitForWorktreeTabRegistration( worktreeId: string | undefined, timeoutMs = 8_000 @@ -168,6 +193,9 @@ function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean { } export function registerBrowserHandlers(): void { + grabModeIntentByPageId.clear() + // Why: a stale in-flight chain from a prior registration would block new operations forever. + grabModeOperationByPageId.clear() ipcMain.removeHandler('browser:registerGuest') ipcMain.removeHandler('browser:unregisterGuest') ipcMain.removeHandler('browser:openDevTools') @@ -237,6 +265,9 @@ export function registerBrowserHandlers(): void { agentBrowserBridgeRef.onTabClosed(wcId) } browserManager.unregisterGuest(args.browserPageId) + grabModeIntentByPageId.delete(args.browserPageId) + // Why: don't let a reused browserPageId queue behind the destroyed guest's pending chain. + grabModeOperationByPageId.delete(args.browserPageId) return true }) @@ -370,12 +401,44 @@ export function registerBrowserHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return { ok: false, reason: 'not-authorized' } } - const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + const intent = { + generation: (grabModeIntentByPageId.get(args.browserPageId)?.generation ?? 0) + 1, + enabled: args.enabled + } + grabModeIntentByPageId.set(args.browserPageId, intent) + const isCurrentIntent = (): boolean => + grabModeIntentByPageId.get(args.browserPageId) === intent + let guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest && args.enabled) { + // Why: fast file:// pages can expose the toolbar before did-attach registration reaches main. + await waitForNextTabRegistration(args.browserPageId, GRAB_REGISTRATION_WAIT_MS).catch( + () => {} + ) + if (!isCurrentIntent()) { + return { ok: true } + } + guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + } if (!guest) { + if (!args.enabled) { + return { ok: true } + } return { ok: false, reason: 'not-ready' } } - const success = await browserManager.setGrabMode(args.browserPageId, args.enabled, guest) - return success ? { ok: true } : { ok: false, reason: 'not-ready' } + return queueGrabModeOperation(args.browserPageId, async () => { + if (!isCurrentIntent()) { + return { ok: true } + } + guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest) { + return args.enabled ? { ok: false, reason: 'not-ready' } : { ok: true } + } + const success = await browserManager.setGrabMode(args.browserPageId, args.enabled, guest) + if (!isCurrentIntent()) { + return { ok: true } + } + return success ? { ok: true } : { ok: false, reason: 'injection-failed' } + }) } ) diff --git a/src/main/ipc/local-agent-install-dir-detection.ts b/src/main/ipc/local-agent-install-dir-detection.ts index 1364b5bc7d7..7493affdb47 100644 --- a/src/main/ipc/local-agent-install-dir-detection.ts +++ b/src/main/ipc/local-agent-install-dir-detection.ts @@ -1,18 +1 @@ -import path from 'node:path' -import { resolveCliCommands } from '../codex-cli/command' - -// Why: local agent detection may run before shell-PATH hydration, but the -// fallback must stay bounded because it runs on the main process. -export function detectCommandsInInstallDirs(commands: readonly string[]): Set { - if (commands.length === 0) { - return new Set() - } - try { - const resolvedCommands = resolveCliCommands(commands) - return new Set( - commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command)) - ) - } catch { - return new Set() - } -} +export { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection' diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index fead0c97c86..c959bda20a1 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -54,7 +54,7 @@ vi.mock('../startup/hydrate-shell-path', () => ({ mergePathSegments: mergePathSegmentsMock })) -vi.mock('../codex-cli/command', () => ({ +vi.mock('../../shared/node-cli-command-resolution', () => ({ resolveCliCommands: resolveCliCommandsMock })) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 59b69ea25d0..c0798fe39fa 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -17,9 +17,10 @@ import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-err const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it +// Why: bare shells no longer mkdir ~/.omp; OMP status lives under userData (#10196). const expectedOmpStatusExtension = posix.join( - '/tmp/default-omp-agent', - 'extensions', + '/tmp/orca-user-data', + 'omp-managed-status-extension', 'orca-agent-status.ts' ) function expectedAttributionShimDir(): string { @@ -418,15 +419,33 @@ describe('registerPtyHandlers', () => { ORCA_AGENT_HOOK_TOKEN: 'agent-token' }) piBuildPtyEnvMock.mockImplementation( - (_ptyId: string, existingAgentDir?: string, kind?: string) => - kind === 'omp' - ? { - ORCA_OMP_SOURCE_AGENT_DIR: existingAgentDir ?? '/tmp/default-omp-agent', - ORCA_OMP_STATUS_EXTENSION: `${existingAgentDir ?? '/tmp/default-omp-agent'}/extensions/orca-agent-status.ts` - } - : { - ORCA_PI_SOURCE_AGENT_DIR: existingAgentDir ?? '/tmp/default-pi-agent' + ( + _ptyId: string, + existingAgentDir?: string, + kind?: string, + options?: { materializeDefaultHome?: boolean } + ) => { + const materializeDefaultHome = options?.materializeDefaultHome !== false + if (kind === 'omp') { + // Why: bare shells no longer create ~/.omp; only a userData status path is set (#10196). + if (!existingAgentDir && !materializeDefaultHome) { + return { + ORCA_OMP_STATUS_EXTENSION: + '/tmp/orca-user-data/omp-managed-status-extension/orca-agent-status.ts' } + } + return { + ORCA_OMP_SOURCE_AGENT_DIR: existingAgentDir ?? '/tmp/default-omp-agent', + ORCA_OMP_STATUS_EXTENSION: `${existingAgentDir ?? '/tmp/default-omp-agent'}/extensions/orca-agent-status.ts` + } + } + if (!existingAgentDir && !materializeDefaultHome) { + return {} + } + return { + ORCA_PI_SOURCE_AGENT_DIR: existingAgentDir ?? '/tmp/default-pi-agent' + } + } ) isPwshAvailableMock.mockReturnValue(false) spawnMock.mockReturnValue({ @@ -2430,15 +2449,58 @@ describe('registerPtyHandlers', () => { it('installs Pi managed extensions without redirecting Orca terminal PTY homes', async () => { const env = await spawnAndGetEnv(undefined, { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent', 'pi') - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/tmp/user-pi-agent', + 'pi', + { + materializeDefaultHome: false + } + ) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp', { + materializeDefaultHome: false + }) expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent') expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( - '/tmp/default-omp-agent/extensions/orca-agent-status.ts' + '/tmp/orca-user-data/omp-managed-status-extension/orca-agent-status.ts' ) + expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBeUndefined() + }) + + it('does not materialize a missing Pi home when another agent mentions Pi', async () => { + const env = await spawnAndGetEnv( + undefined, + undefined, + undefined, + undefined, + 'codex "ask about pi"', + 'codex' + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledTimes(1) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'pi', { + materializeDefaultHome: false + }) + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + + it('materializes Pi home for an explicit Pi launch through a custom command', async () => { + const env = await spawnAndGetEnv( + undefined, + undefined, + undefined, + undefined, + 'custom-pi-wrapper', + 'pi' + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'pi', { + materializeDefaultHome: true + }) + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/default-pi-agent') }) it('threads command: "omp" through to piBuildPtyEnv and emits OMP status metadata', async () => { @@ -2453,7 +2515,8 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), '/tmp/user-omp-agent', - 'omp' + 'omp', + { materializeDefaultHome: true } ) expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-omp-agent') expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined() @@ -2481,7 +2544,8 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), '/tmp/user-omp-agent', - 'omp' + 'omp', + { materializeDefaultHome: true } ) expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( '/tmp/user-omp-agent/extensions/orca-agent-status.ts' @@ -2494,7 +2558,14 @@ describe('registerPtyHandlers', () => { PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent' }) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent', 'pi') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/tmp/user-pi-agent', + 'pi', + { + materializeDefaultHome: false + } + ) expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/parent-orca-pi-overlay') expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') @@ -2513,7 +2584,9 @@ describe('registerPtyHandlers', () => { 'omp' ) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp', { + materializeDefaultHome: true + }) expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/tmp/default-omp-agent') expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() @@ -2533,7 +2606,9 @@ describe('registerPtyHandlers', () => { 'pi' ) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'pi') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'pi', { + materializeDefaultHome: true + }) expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/default-pi-agent') expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined() @@ -2575,7 +2650,8 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), '/home/tester/.config/pi-agent', - 'pi' + 'pi', + { materializeDefaultHome: false } ) expect(env.PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() @@ -2977,8 +3053,17 @@ describe('registerPtyHandlers', () => { it('installs Pi managed extensions without redirecting homes on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({ PI_CODING_AGENT_DIR: '/user/.pi/agent' }) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/user/.pi/agent', 'pi') - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/user/.pi/agent', + 'pi', + { + materializeDefaultHome: false + } + ) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp', { + materializeDefaultHome: false + }) expect(env.PI_CODING_AGENT_DIR).toBe('/user/.pi/agent') expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/user/.pi/agent') @@ -2986,6 +3071,19 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension) }) + it('does not materialize agent homes when another daemon agent mentions OMP', async () => { + const env = await daemonSpawnAndGetEnv(undefined, undefined, undefined, undefined, { + command: 'codex "ask about omp"', + launchAgent: 'codex' + }) + + expect(piBuildPtyEnvMock).toHaveBeenCalledTimes(1) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'pi', { + materializeDefaultHome: false + }) + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + it('threads command: "omp" through to piBuildPtyEnv on the daemon path with OMP status metadata', async () => { // Why: mirror of the local-spawn OMP threading assertion; the daemon path's `command` forwarding could silently regress otherwise. const env = await daemonSpawnAndGetEnv( @@ -2998,7 +3096,8 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), '/user/.omp/agent', - 'omp' + 'omp', + { materializeDefaultHome: true } ) expect(env.PI_CODING_AGENT_DIR).toBe('/user/.omp/agent') expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined() @@ -3025,7 +3124,8 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), '/user/.omp/agent', - 'omp' + 'omp', + { materializeDefaultHome: true } ) expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( '/user/.omp/agent/extensions/orca-agent-status.ts' @@ -3966,7 +4066,9 @@ describe('registerPtyHandlers', () => { expect(sessionId).toEqual(expect.any(String)) expect((sessionId ?? '').length).toBeGreaterThan(0) expect(spawnOpts.isNewSession).toBe(true) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi', { + materializeDefaultHome: false + }) }) it('respects a caller-provided sessionId instead of minting a new one', async () => { @@ -3981,7 +4083,9 @@ describe('registerPtyHandlers', () => { }) expect(daemonSpawn.mock.calls.at(-1)![0].sessionId).toBe('user-session-42') expect(daemonSpawn.mock.calls.at(-1)![0].isNewSession).toBeUndefined() - expect(piBuildPtyEnvMock).toHaveBeenCalledWith('user-session-42', undefined, 'pi') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith('user-session-42', undefined, 'pi', { + materializeDefaultHome: false + }) }) it('prefixes a minted sessionId with the worktreeId when provided', async () => { @@ -3997,7 +4101,9 @@ describe('registerPtyHandlers', () => { }) const sessionId = daemonSpawn.mock.calls.at(-1)![0].sessionId ?? '' expect(sessionId).toMatch(/^wt-alpha@@[0-9a-f]{8}$/) - expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi', { + materializeDefaultHome: false + }) }) it('reuses one attach-style daemon session for fresh-agent operation retries', async () => { @@ -4076,7 +4182,8 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), '/ambient/pi/agent', - 'pi' + 'pi', + { materializeDefaultHome: false } ) expect(env.PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() @@ -8862,11 +8969,14 @@ describe('registerPtyHandlers', () => { 'ORCA_CLI_COMMAND/u', 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', - 'ORCA_OMP_SOURCE_AGENT_DIR/p', + // Why: bare WSL shells no longer create ~/.omp; only status extension is exported (#10196). 'ORCA_OMP_STATUS_EXTENSION/p', 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD' ]) ) + expect(env.WSLENV?.split(':')).not.toEqual( + expect.arrayContaining(['ORCA_OMP_SOURCE_AGENT_DIR/p']) + ) }) it('forces managed ORCA_USER_DATA_PATH for WSL spawns even when the caller provides a stale root', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ad0b68cdaa2..260f0d4d77d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -59,7 +59,11 @@ import { agentHookServer } from '../agent-hooks/server' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' -import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind' +import { + detectExplicitPiAgentKindFromCommand, + isPiCompatibleAgentType, + type PiAgentKind +} from '../../shared/pi-agent-kind' import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' @@ -1089,7 +1093,12 @@ export function buildPtyHostEnv( // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) - const piAgentKind = detectPiAgentKindFromCommand(launchCommandHint) + const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) + ? opts.launchAgent + : opts.launchAgent === undefined + ? detectExplicitPiAgentKindFromCommand(launchCommandHint) + : null + const piAgentKind = explicitPiAgentKind ?? 'pi' const hasLaunchCommand = typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 @@ -1179,14 +1188,23 @@ export function buildPtyHostEnv( if (opts.agentStatusHooksEnabled) { clearPiAgentShadowEnv(baseEnv, 'pi') clearPiAgentShadowEnv(baseEnv, 'omp') + // Why: bare shells historically defaulted to Pi + OMP shadow prep and + // created ~/./agent even when the user never launches those agents + // (#10196). Only create default homes on an explicit Pi/OMP launch; + // otherwise install only into an existing agent dir (or userData for OMP + // status so a typed `omp` still gets the shell wrapper extension). if (piAgentKind === 'pi') { - const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi') + const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi', { + materializeDefaultHome: explicitPiAgentKind === 'pi' + }) Object.assign(baseEnv, piEnv) exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv) } if (shouldPrepareOmpShadow) { - const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp') + const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp', { + materializeDefaultHome: explicitPiAgentKind === 'omp' + }) Object.assign(baseEnv, ompEnv) exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) } @@ -3296,13 +3314,8 @@ export function registerPtyHandlers( // Why: a daemon death takes down every session at once. The provider signals // each affected pane here so background panes remount + re-attach too, not // just the pane whose write happened to detect the dead endpoint (STA-2373). - // Typed at the call site (not on the capped IPtyProvider): only respawnable - // endpoints like the daemon adapter implement it. - const writeUnavailableSource = localProvider as { - onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void - } localWriteUnavailableUnsub = - writeUnavailableSource.onWriteUnavailable?.((payload) => { + localProvider.onWriteUnavailable?.((payload) => { if ( mainWindow.isDestroyed() || (typeof mainWindow.webContents.isDestroyed === 'function' && diff --git a/src/main/ipc/ssh-pty-output-model-migration.test.ts b/src/main/ipc/ssh-pty-output-model-migration.test.ts index e8007a0c274..ae56e2e45fc 100644 --- a/src/main/ipc/ssh-pty-output-model-migration.test.ts +++ b/src/main/ipc/ssh-pty-output-model-migration.test.ts @@ -1,10 +1,93 @@ import { describe, expect, it, vi } from 'vitest' +import type { SshPtyOutputDataEvent } from './ssh-pty-output-intake' import { createSshPtyOutputIntakeHarness as createHarness, sshPtyOutputEvent as event } from './ssh-pty-output-intake-test-harness' +// Production-shaped ids: bare relay id on the wire side, prefixed app id in intake. +const APP_ID = 'ssh:conn@@pty-1' +const prodEvent = (overrides: Partial = {}): SshPtyOutputDataEvent => + event({ + id: APP_ID, + source: { + relayPtyId: 'pty-1', + spanId: 'span-a', + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'delivery-token-1', + sourceStartSu: 0, + sourceEndSu: 4 + }, + ...overrides + }) + describe('SshPtyOutputModelMigration', () => { + it('fences an in-flight admission when relay and app pty ids differ', async () => { + const harness = createHarness() + const first = harness.intake.acceptData(prodEvent({ data: 'aaaa' })) + harness.completions[0]!.resolve() + await first + const second = harness.intake.acceptData( + prodEvent({ + data: 'bbbb', + source: { + relayPtyId: 'pty-1', + spanId: 'span-b', + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'delivery-token-1', + sourceStartSu: 4, + sourceEndSu: 8 + } + }) + ) + + const migration = harness.intake.beginGenerationMigration(1) + expect([...migration.byPty.keys()]).toEqual([APP_ID]) + const result = migration.byPty.get(APP_ID) + await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toBe('pending') + await expect(Promise.race([migration.completion, Promise.resolve('pending')])).resolves.toBe( + 'pending' + ) + expect(harness.intake.getAcceptedSourceCheckpoints(1)[0]).toMatchObject({ + id: APP_ID, + acceptedSourceEndSu: 4 + }) + + harness.completions[1]!.resolve() + await expect(second).resolves.toMatchObject({ sequence: 8 }) + await expect(result).resolves.toMatchObject({ + status: 'settled', + checkpoint: { id: APP_ID, acceptedSourceEndSu: 8 } + }) + await migration.completion + }) + + it('targets the real pty on migration timeout with production-shaped ids', async () => { + vi.useFakeTimers() + try { + const resetModelForMigration = vi.fn() + const harness = createHarness({ resetModelForMigration }) + const receipt = harness.intake.acceptData(prodEvent()) + const migration = harness.intake.beginGenerationMigration(1, 10_000) + + await vi.advanceTimersByTimeAsync(10_000) + + await expect(migration.byPty.get(APP_ID)).resolves.toEqual({ + status: 'checkpoint-unavailable', + reason: 'timeout' + }) + await expect(receipt).rejects.toThrow('ssh_model_migration_timeout') + expect(resetModelForMigration).toHaveBeenCalledOnce() + expect(resetModelForMigration).toHaveBeenCalledWith(1, APP_ID) + harness.completions[0]!.resolve() + await migration.completion + } finally { + vi.useRealTimers() + } + }) + it('fences a running source span before exporting its migration checkpoint', async () => { const harness = createHarness() const first = harness.intake.acceptData( diff --git a/src/main/ipc/ssh-pty-output-source-obligations.ts b/src/main/ipc/ssh-pty-output-source-obligations.ts index 28f13dc2d73..410b8c05644 100644 --- a/src/main/ipc/ssh-pty-output-source-obligations.ts +++ b/src/main/ipc/ssh-pty-output-source-obligations.ts @@ -41,7 +41,12 @@ export class SshPtyOutputSourceObligations { private readonly coordinator: SshPtySourceObligationCoordinator private readonly remoteConsumers: SshPtyRemoteSourceRangeConsumers private readonly openedTokens = new Set() - private readonly identityByPty = new Map() + // Why: fence/recovery consumers key checkpoints by app pty id, but the wire + // ACK path needs the relay id kept on identity.id — record both. + private readonly identityByPty = new Map< + string, + Readonly<{ appPtyId: string; identity: PtySourceDeliveryIdentity }> + >() constructor(publish: SshPtyOutputDataEventPublisher | undefined) { this.coordinator = new SshPtySourceObligationCoordinator({ @@ -68,7 +73,7 @@ export class SshPtyOutputSourceObligations { if (!this.openedTokens.has(tokenKey)) { this.coordinator.open(identity, span.sourceStartSu) this.openedTokens.add(tokenKey) - this.identityByPty.set(this.ptyKey(event), identity) + this.identityByPty.set(this.ptyKey(event), Object.freeze({ appPtyId: event.id, identity })) } return Object.freeze({ span, @@ -132,21 +137,21 @@ export class SshPtyOutputSourceObligations { } sealPty(event: SshPtyOutputExitEvent): void { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (identity) { this.coordinator.seal(identity) } } markExitPublished(event: SshPtyOutputExitEvent): void { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (identity) { this.coordinator.markExitPublished(identity) } } whenPtyTerminal(event: SshPtyOutputExitEvent): Promise { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity return identity ? this.coordinator.whenTerminal(identity) : Promise.resolve() } @@ -154,7 +159,7 @@ export class SshPtyOutputSourceObligations { event: SshPtyOutputExitEvent, cancel: (request: SshPtySourceCancellationRequest) => Promise ): Promise { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (!identity) { return null } @@ -171,7 +176,7 @@ export class SshPtyOutputSourceObligations { event: SshPtyOutputExitEvent, proof: SshPtySourceCancellationProof ): boolean { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (!identity) { return false } @@ -183,7 +188,7 @@ export class SshPtyOutputSourceObligations { event: SshPtyOutputExitEvent, proof: SshPtySourceCancellationProof ): void { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (identity) { this.coordinator.applyRecoveryCancellationProof(identity, proof) } @@ -198,8 +203,8 @@ export class SshPtyOutputSourceObligations { this.openedTokens.delete(key) } } - for (const [key, identity] of this.identityByPty) { - if (identity.providerGeneration === providerGeneration) { + for (const [key, record] of this.identityByPty) { + if (record.identity.providerGeneration === providerGeneration) { this.identityByPty.delete(key) } } @@ -207,19 +212,20 @@ export class SshPtyOutputSourceObligations { acceptedCheckpoints(providerGeneration: number): readonly SshPtyAcceptedSourceCheckpoint[] { const checkpoints: SshPtyAcceptedSourceCheckpoint[] = [] - for (const identity of this.identityByPty.values()) { - if (identity.providerGeneration !== providerGeneration) { + for (const record of this.identityByPty.values()) { + if (record.identity.providerGeneration !== providerGeneration) { continue } checkpoints.push( Object.freeze({ - id: identity.id, - providerGeneration: identity.providerGeneration, - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - ptyIncarnation: identity.ptyIncarnation, - deliveryToken: identity.deliveryToken, - acceptedSourceEndSu: this.coordinator.modelAcceptedEnd(identity) + // Why: fence/recovery keys are app pty ids; the bare relay id stays on identity.id. + id: record.appPtyId, + providerGeneration: record.identity.providerGeneration, + clientGeneration: record.identity.clientGeneration, + ownerGeneration: record.identity.ownerGeneration, + ptyIncarnation: record.identity.ptyIncarnation, + deliveryToken: record.identity.deliveryToken, + acceptedSourceEndSu: this.coordinator.modelAcceptedEnd(record.identity) }) ) } @@ -284,8 +290,8 @@ export class SshPtyOutputSourceObligations { private removeIdentity(identity: PtySourceDeliveryIdentity): void { this.openedTokens.delete(ptySourceDeliveryKey(identity)) - for (const [key, candidate] of this.identityByPty) { - if (samePtySourceDelivery(candidate, identity)) { + for (const [key, record] of this.identityByPty) { + if (samePtySourceDelivery(record.identity, identity)) { this.identityByPty.delete(key) } } diff --git a/src/main/ipc/tui-agent-detection-commands.ts b/src/main/ipc/tui-agent-detection-commands.ts index dc5d5909543..88d65c1fa34 100644 --- a/src/main/ipc/tui-agent-detection-commands.ts +++ b/src/main/ipc/tui-agent-detection-commands.ts @@ -1,77 +1 @@ -import type { TuiAgent } from '../../shared/types' -import { - getTuiAgentDetectCommands, - TUI_AGENT_CONFIG, - type TuiAgentConfig, - type TuiAgentDetectionRuntime -} from '../../shared/tui-agent-config' - -export type TuiAgentDetectionCommand = { - id: TuiAgent - cmd: string - requiredCommands?: readonly string[] - unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] -} - -export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands() - -function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] { - return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => - getTuiAgentDetectCommands(config).map((cmd) => - buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config) - ) - ) -} - -function buildTuiAgentDetectionCommand( - id: TuiAgent, - cmd: string, - config: TuiAgentConfig -): TuiAgentDetectionCommand { - return { - id, - cmd, - ...(config.detectRequiredCommands?.length - ? { requiredCommands: config.detectRequiredCommands } - : {}), - ...(config.detectUnsupportedRuntimes?.length - ? { unsupportedRuntimes: config.detectUnsupportedRuntimes } - : {}) - } -} - -export function getTuiAgentDetectionProbeCommands( - commands: readonly TuiAgentDetectionCommand[], - runtime: TuiAgentDetectionRuntime -): string[] { - return [ - ...new Set( - commands - .filter((command) => !isDetectionUnsupportedInRuntime(command, runtime)) - .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) - ) - ] -} - -export function resolveDetectedTuiAgentIds( - commands: readonly TuiAgentDetectionCommand[], - foundCommands: ReadonlySet, - runtime: TuiAgentDetectionRuntime -): TuiAgent[] { - const detected = commands - .filter( - (command) => - !isDetectionUnsupportedInRuntime(command, runtime) && - foundCommands.has(command.cmd) && - (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) - ) - .map(({ id }) => id) - return [...new Set(detected)] -} - -export function isDetectionUnsupportedInRuntime( - command: TuiAgentDetectionCommand, - runtime: TuiAgentDetectionRuntime -): boolean { - return command.unsupportedRuntimes?.includes(runtime) === true -} +export * from '../../shared/tui-agent-detection-commands' diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 4a4dcd04bfe..3be2a173ee1 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -17,6 +17,7 @@ import type { LocalBaseRefUpdateSuggestion, Repo, Worktree, + WorktreeCreateBaseFallback, WorktreeHeadIdentity, WorktreeMeta } from '../../shared/types' @@ -1575,8 +1576,32 @@ export async function createRemoteWorktree( 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' ) } - const { baseBranch } = basePlan + let { baseBranch } = basePlan let { remoteTrackingBase } = basePlan + let baseFallback: WorktreeCreateBaseFallback | undefined + + if (remoteTrackingBase) { + const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( + provider, + repo.path, + remoteTrackingBase.ref + ) + const hasNamedLocalBaseRef = await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch) + const hasFallbackLocalBaseRef = + !hasNamedLocalBaseRef && + (await hasRemoteWorktreeBaseRef(provider, repo.path, remoteTrackingBase.branch)) + if (!hasRemoteTrackingBaseRef && (hasNamedLocalBaseRef || hasFallbackLocalBaseRef)) { + // Why: branch reuse and conflict checks must see the local fallback too. + if (hasFallbackLocalBaseRef) { + baseBranch = remoteTrackingBase.branch + } + baseFallback = { + requestedRef: remoteTrackingBase.base, + localRef: baseBranch + } + remoteTrackingBase = null + } + } let branchName = '' let checkoutExistingBranch = false @@ -1683,19 +1708,6 @@ export async function createRemoteWorktree( // Why: addWorktree/setup probes run inside the new path; older relays need that root registered before accepting git/fs ops there. await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath]) - if (remoteTrackingBase) { - const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( - provider, - repo.path, - remoteTrackingBase.ref - ) - const hasLocalBaseRef = - hasRemoteTrackingBaseRef || (await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch)) - if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { - remoteTrackingBase = null - } - } - if (remoteTrackingBase) { try { await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase) @@ -1933,6 +1945,7 @@ export async function createRemoteWorktree( ...(defaultTabs ? { defaultTabs } : {}), ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}), + ...(baseFallback ? { baseFallback } : {}), timing: timing.finish() } } @@ -1971,7 +1984,7 @@ export async function createLocalWorktree( ? await resolveLocalGitUsername(repo.path) : '' - const baseBranch = await resolveWorktreeCreateBase({ + let baseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: args.baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(localGitExecOptions), @@ -2010,6 +2023,7 @@ export async function createLocalWorktree( } let remoteTrackingBase: RemoteTrackingBase | null = null + let baseFallback: WorktreeCreateBaseFallback | undefined let remoteTrackingRefresh: { base: RemoteTrackingBase hadLocalBaseRef: boolean @@ -2029,10 +2043,29 @@ export async function createLocalWorktree( remoteTrackingBase, ...localWorktreeGitOptionArgs ) + const hasNamedLocalBaseRef = await hasLocalWorktreeBaseRefWithOptions( + repo.path, + baseBranch, + localGitExecOptions + ) + const hasFallbackLocalBaseRef = + !hasNamedLocalBaseRef && + (await hasLocalWorktreeBaseRefWithOptions( + repo.path, + remoteTrackingBase.branch, + localGitExecOptions + )) const hasLocalBaseRef = - hasRemoteTrackingBaseRef || - (await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions)) + hasRemoteTrackingBaseRef || hasNamedLocalBaseRef || hasFallbackLocalBaseRef if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { + // Why: use the usable local branch when offline refresh cannot create its tracking ref. + if (hasFallbackLocalBaseRef) { + baseBranch = remoteTrackingBase.branch + } + baseFallback = { + requestedRef: remoteTrackingBase.base, + localRef: baseBranch + } remoteTrackingBase = null } else { emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) @@ -2583,6 +2616,7 @@ export async function createLocalWorktree( ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } : {}), ...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}), + ...(baseFallback ? { baseFallback } : {}), ...(stagedStartup.warning ? { warning: appendWorktreeCreateWarning(includeCopyWarning, stagedStartup.warning) } : includeCopyWarning diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index d8f363a3fc4..2dec930beaf 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -5572,10 +5572,10 @@ describe('registerWorktreeHandlers', () => { getActiveMultiplexerMock.mockReturnValue(mux) store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - await handlers['worktrees:create'](null, { + const result = (await handlers['worktrees:create'](null, { repoId: 'repo-ssh', name: 'slash-local-base' - }) + })) as CreateWorktreeResult expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( '/remote/repo', @@ -5591,6 +5591,10 @@ describe('registerWorktreeHandlers', () => { base: 'team/feature' } ) + expect(result.baseFallback).toEqual({ + requestedRef: 'team/feature', + localRef: 'team/feature' + }) }) it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => { @@ -6396,13 +6400,16 @@ describe('registerWorktreeHandlers', () => { return { stdout: 'created-sha\n', stderr: '' } }) - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-1', - name: 'slash-local-base' - }) - ).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'slash-local-base' + }) + expect(result).toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + expect((result as CreateWorktreeResult).baseFallback).toEqual({ + requestedRef: 'team/feature', + localRef: 'team/feature' + }) expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled() expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', @@ -6413,6 +6420,58 @@ describe('registerWorktreeHandlers', () => { ) }) + it('uses a local branch when its missing remote-tracking base cannot refresh', async () => { + const remoteBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/offline-local-main', + head: 'created-sha', + branch: 'offline-local-main', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { + throw new Error('missing remote-tracking ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/main^{commit}')) { + return { stdout: 'main-sha\n', stderr: '' } + } + if (args[0] === 'rev-parse') { + return { stdout: '', stderr: '' } + } + return { stdout: 'created-sha\n', stderr: '' } + }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'offline-local-main', + baseBranch: 'origin/main' + }) + + expect(result).toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + expect((result as CreateWorktreeResult).baseFallback).toEqual({ + requestedRef: 'origin/main', + localRef: 'main' + }) + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled() + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/offline-local-main', + 'offline-local-main', + 'main', + false + ) + }) + it('keeps an explicit base strict when the pre-create refresh fails', async () => { const remoteBase = { remote: 'origin', diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d7a8c5dd802..d8e01166973 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4930,6 +4930,59 @@ describe('Store', () => { expect(store.getWorktreeMeta('wt-malformed')?.linkedTaskSourceContext).toBeNull() }) + it('drops corrupt worktreeMeta entries while still normalizing valid siblings', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeLineageById: { 'r1::/tmp/wt': { parentWorktreeId: 'wt-sibling' } }, + workspaceLineageByChildKey: { + 'worktree:r1::/tmp/wt': { parentWorkspaceKey: 'worktree:wt-sibling' } + }, + worktreeMeta: { + 'r1::/tmp/wt': null, + 'r1::/tmp/scalar': 5, + 'wt-sibling': { + linkedWorkItem: { + provider: 'jira', + type: 'issue', + number: 0, + title: 'ORCA-123 Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + }, + linkedTaskSourceContext: { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'local', + accountLabel: 44, + providerIdentity: { + provider: 'jira', + siteId: 'site-1', + siteUrl: 'https://company.atlassian.net', + projectKey: 'ORCA' + } + } + } + } + }) + + const store = await createStore() + + expect(store.getWorktreeMeta('wt-sibling')?.linkedWorkItem?.jiraIdentifier).toBe('ORCA-123') + expect(store.getWorktreeMeta('wt-sibling')?.linkedTaskSourceContext).toBeNull() + // Corrupt entries must not survive: gcStaleWorktreeMeta keeps timestamp-less keys, and downstream + // consumers deref worktreeMeta values unguarded (also keeps a rollback to an older build loadable). + expect(store.getAllWorktreeMeta()).not.toHaveProperty('r1::/tmp/wt') + expect(store.getAllWorktreeMeta()).not.toHaveProperty('r1::/tmp/scalar') + expect(store.getWorktreeLineage('r1::/tmp/wt')).toBeUndefined() + store.flush() + const persisted = readDataFile() as PersistedState + expect(persisted.worktreeMeta).not.toHaveProperty('r1::/tmp/wt') + expect(persisted.worktreeMeta).not.toHaveProperty('r1::/tmp/scalar') + expect(persisted.workspaceLineageByChildKey).not.toHaveProperty('worktree:r1::/tmp/wt') + }) + it('creates and updates folder workspaces from folder-backed project groups', async () => { const store = await createStore() const group = store.createProjectGroup({ @@ -6046,10 +6099,12 @@ describe('Store', () => { }) }) - it('tolerates a null worktreeMeta map in the durable file', async () => { - writeDataFile({ worktreeMeta: null }) + it.each([null, [], 5])('repairs a corrupt worktreeMeta map (%#)', async (worktreeMeta) => { + writeDataFile({ worktreeMeta }) const store = await createStore() expect(store.getAllWorktreeMeta()).toEqual({}) + store.flush() + expect((readDataFile() as PersistedState).worktreeMeta).toEqual({}) }) // ── GitHub cache sidecar ─────────────────────────────────────────── @@ -6785,6 +6840,46 @@ describe('Store', () => { expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(true) }) + it('preserves workflows above 20 statuses across load, write, and restart', async () => { + const imported = Array.from({ length: 21 }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })).toReversed() + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { workspaceStatuses: imported }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( + imported.map((status) => status.id) + ) + + const authored = Array.from({ length: 64 }, (_, index) => ({ + id: `final-${String(index + 1).padStart(3, '0')}`, + label: `Final ${index + 1}` + })).toReversed() + store.updateUI({ workspaceStatuses: authored }) + store.flush() + + expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( + authored.map((status) => status.id) + ) + expect( + (readDataFile() as PersistedState).ui.workspaceStatuses?.map((status) => status.id) + ).toEqual(authored.map((status) => status.id)) + + const restarted = await createStore() + expect(restarted.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( + authored.map((status) => status.id) + ) + }) + it('repairs the known-bad reordered default workspace statuses once on load', async () => { writeDataFile({ schemaVersion: 1, @@ -7196,6 +7291,7 @@ describe('Store', () => { 'ci', 'issue', 'linear-issue', + 'jira-issue', 'pr', 'comment', 'ports', @@ -7232,6 +7328,7 @@ describe('Store', () => { 'ci', 'issue', 'linear-issue', + 'jira-issue', 'pr', 'comment', 'ports', @@ -7292,6 +7389,7 @@ describe('Store', () => { 'ci', 'issue', 'linear-issue', + 'jira-issue', 'pr', 'ports', 'inline-agents' @@ -7308,7 +7406,8 @@ describe('Store', () => { ui: { worktreeCardProperties: ['status', 'pr'], _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} @@ -7367,7 +7466,8 @@ describe('Store', () => { 'ports' ], _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} @@ -7388,6 +7488,107 @@ describe('Store', () => { expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') }) + it('backfills jira-issue once for profiles stamped before it joined the defaults', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { + worktreeCardProperties: [ + 'status', + 'unread', + 'issue', + 'linear-issue', + 'pr', + 'ports', + 'inline-agents' + ], + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).toEqual([ + 'status', + 'unread', + 'issue', + 'linear-issue', + 'jira-issue', + 'pr', + 'ports', + 'inline-agents' + ]) + expect( + store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue') + ).toHaveLength(1) + expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) + }) + + it('preserves a deliberate jira-issue removal after the backfill has run', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { + worktreeCardProperties: ['status', 'unread', 'issue', 'linear-issue', 'pr'], + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).toEqual([ + 'status', + 'unread', + 'issue', + 'linear-issue', + 'pr' + ]) + expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue') + }) + + it('leaves fresh default profiles with a single jira-issue entry', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect( + store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue') + ).toHaveLength(1) + expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) + }) + + it('skips the jira-issue backfill when card properties are malformed', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { worktreeCardProperties: 'not-an-array' }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue') + expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) + }) + it('uses the compact preset when card properties are missing in compact mode', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 85de59a8bda..e3c0fa329af 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -425,7 +425,27 @@ function gcStaleWorktreeMeta(state: PersistedState): number { function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { let changed = false - for (const meta of Object.values(state.worktreeMeta ?? {})) { + const rawWorktreeMeta = state.worktreeMeta as unknown + if ( + typeof rawWorktreeMeta !== 'object' || + rawWorktreeMeta === null || + Array.isArray(rawWorktreeMeta) + ) { + state.worktreeMeta = {} + changed = rawWorktreeMeta !== undefined + } + for (const [key, meta] of Object.entries(state.worktreeMeta)) { + // Why: hand-corrupted non-object entries are a real input class; drop them here because gcStaleWorktreeMeta + // keeps timestamp-less keys forever and every downstream consumer trusts the Record type. + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + delete state.worktreeMeta[key] + // Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would + // otherwise re-attach to a worktree recreated at the same repoId::path. + delete state.worktreeLineageById[key] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] + changed = true + continue + } const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( @@ -3284,6 +3304,8 @@ export class Store { const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true + const jiraIssueCardPropDefaulted = + parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true const hadExperimentOn = readDeprecatedExperimentFlag(parsed) const deliberateUncheck = hadExperimentOn && @@ -3322,7 +3344,12 @@ export class Store { } return next })() - const normalized = normalizeWorktreeCardProperties(expandedCandidate) + // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. + const jiraCandidate = + jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') + ? expandedCandidate + : [...expandedCandidate, 'jira-issue' as const] + const normalized = normalizeWorktreeCardProperties(jiraCandidate) const changed = normalized.length !== rawCardProps.length || normalized.some((property, index) => property !== rawCardProps[index]) @@ -3331,7 +3358,8 @@ export class Store { if ( migratedCardProps !== undefined || !inlineAgentsMigrated || - !expandedCardPropsMigrated + !expandedCardPropsMigrated || + !jiraIssueCardPropDefaulted ) { this.loadNeedsSave = true } @@ -3399,7 +3427,8 @@ export class Store { // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. _inlineAgentsDefaultedForExperiment: true, _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true } })(), // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 7516f04dad9..43af655aa1f 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -643,6 +643,35 @@ describe('PiTitlebarExtensionService', () => { rmSync(fakeHome, { recursive: true, force: true }) } }) + + it('bare-shell prep does not create missing ~/.pi or ~/.omp homes (#10196)', () => { + const fakeHome = mkdtempSync(join(tmpdir(), 'orca-no-eager-agent-home-')) + expect(existsSync(join(fakeHome, '.pi'))).toBe(false) + expect(existsSync(join(fakeHome, '.omp'))).toBe(false) + + homedirOverride.current = fakeHome + try { + const svc = new PiTitlebarExtensionService() + const piEnv = svc.buildPtyEnv('pty-bare-pi', undefined, 'pi', { + materializeDefaultHome: false + }) + const ompEnv = svc.buildPtyEnv('pty-bare-omp', undefined, 'omp', { + materializeDefaultHome: false + }) + + expect(piEnv).toEqual({}) + expect(existsSync(join(fakeHome, '.pi'))).toBe(false) + expect(existsSync(join(fakeHome, '.omp'))).toBe(false) + expect(ompEnv.ORCA_OMP_SOURCE_AGENT_DIR).toBeUndefined() + expect(ompEnv.ORCA_OMP_STATUS_EXTENSION).toEqual( + expect.stringContaining('omp-managed-status-extension') + ) + expect(existsSync(ompEnv.ORCA_OMP_STATUS_EXTENSION!)).toBe(true) + } finally { + homedirOverride.current = '' + rmSync(fakeHome, { recursive: true, force: true }) + } + }) }) describe('isSafeDescendCandidate (Windows junction regression guard)', () => { diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 37d030b5e31..538d0a0be9d 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -169,7 +169,8 @@ export class PiTitlebarExtensionService { buildPtyEnv( ptyId: string, existingAgentDir: string | undefined, - kind: PiAgentKind + kind: PiAgentKind, + options?: { materializeDefaultHome?: boolean } ): Record { const sourceAgentDir = existingAgentDir || getDefaultPiAgentDir(kind) try { @@ -180,6 +181,20 @@ export class PiTitlebarExtensionService { // directory should not prevent the terminal from starting. } + // Why: bare shells used to mkdir ~/./agent for every terminal so a + // later typed `pi`/`omp` got hooks. That recreates deleted unused agent + // homes on every open (#10196). Only materialize the default home when the + // caller opts in (explicit agent launch) or the dir already exists. + const materializeDefaultHome = options?.materializeDefaultHome !== false + if (!existsSync(sourceAgentDir) && !materializeDefaultHome) { + if (kind === 'omp') { + const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) + const statusExtensionPath = this.writeOmpFallbackStatusExtension(statusSource) + return statusExtensionPath ? { ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } : {} + } + return {} + } + if (kind === 'omp') { migrateLegacyOmpOverlayState(sourceAgentDir, this.getSourceOverlayDir(sourceAgentDir, 'omp')) } diff --git a/src/main/providers/filesystem-provider-contract.ts b/src/main/providers/filesystem-provider-contract.ts new file mode 100644 index 00000000000..855f36da98c --- /dev/null +++ b/src/main/providers/filesystem-provider-contract.ts @@ -0,0 +1,80 @@ +import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' +import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' + +export type FileStat = { + size: number + type: 'file' | 'directory' | 'symlink' + mtime: number + mtimeMs?: number + dev?: number + ino?: number + nlink?: number +} + +export type FileReadResult = { + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string +} + +export type IFilesystemProvider = { + readDir(dirPath: string): Promise + readFile(filePath: string): Promise + readTerminalArtifact?( + filePath: string, + options: TerminalArtifactAccessOptions + ): Promise + downloadFile?(sourcePath: string, destinationPath: string): Promise + downloadFolder?: (src: string, dest: string, options?: { signal?: AbortSignal }) => Promise + openFileUploadSession?(): Promise + getTempDir?(): Promise + writeFile(filePath: string, content: string): Promise + writeTerminalArtifact?( + filePath: string, + content: string, + options: TerminalArtifactAccessOptions + ): Promise + writeFileBase64(filePath: string, contentBase64: string): Promise + writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise + stat(filePath: string): Promise + lstat?(filePath: string): Promise + deletePath(targetPath: string, recursive?: boolean): Promise + createFile(filePath: string): Promise + createDir(dirPath: string): Promise + createDirNoClobber(dirPath: string): Promise + rename(oldPath: string, newPath: string): Promise + renameNoClobber(oldPath: string, newPath: string): Promise + copy(source: string, destination: string): Promise + realpath(filePath: string): Promise + search(opts: SearchOptions): Promise + listFiles( + rootPath: string, + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } + ): Promise + scanWorkspaceSpace?( + rootPath: string, + options?: { signal?: AbortSignal } + ): Promise + watch( + rootPath: string, + callback: (events: FsChangeEvent[]) => void, + options?: { signal?: AbortSignal; onTerminalError?: (error: Error) => void } + ): Promise<() => void> + closeWatch?(rootPath: string): Promise +} + +export type FileUploadSession = { + uploadFile( + sourcePath: string, + destinationPath: string, + options?: { exclusive?: boolean } + ): Promise + close(): void +} + +export type TerminalArtifactAccessOptions = { + expectedRealPath: string + expectedStatIdentity: string | null + maxBytes: number +} diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts new file mode 100644 index 00000000000..c204b9d9016 --- /dev/null +++ b/src/main/providers/git-provider-contract.ts @@ -0,0 +1,106 @@ +import type { + GitStatusResult, + GitDiffResult, + GitBranchCompareResult, + GitCommitCompareResult, + GitConflictOperation, + GitForkSyncExpectedUpstream, + GitForkSyncResult, + GitPushTarget, + GitStagingArea, + GitUpstreamStatus, + GitWorktreeInfo, + RemoveWorktreeResult +} from '../../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' +import type { GitProviderStatusOptions } from './git-provider-status-options' + +export type { GitProviderStatusOptions } from './git-provider-status-options' + +export type IGitProvider = { + getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise + getSubmoduleStatus( + worktreePath: string, + submodulePath: string, + area?: GitStagingArea + ): Promise + checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise + getHistory(worktreePath: string, options?: GitHistoryOptions): Promise + commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> + getStagedCommitContext(worktreePath: string): Promise + getDiff( + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead?: boolean + ): Promise + stageFile(worktreePath: string, filePath: string): Promise + unstageFile(worktreePath: string, filePath: string): Promise + bulkStageFiles(worktreePath: string, filePaths: string[]): Promise + bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise + discardChanges(worktreePath: string, filePath: string): Promise + bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise + detectConflictOperation(worktreePath: string): Promise + abortMerge(worktreePath: string): Promise + abortRebase(worktreePath: string): Promise + checkoutBranch(worktreePath: string, branch: string): Promise + listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }> + getBranchCompare(worktreePath: string, baseRef: string): Promise + getCommitCompare(worktreePath: string, commitId: string): Promise + getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise + pushBranch( + worktreePath: string, + publish?: boolean, + pushTarget?: GitPushTarget, + options?: { forceWithLease?: boolean } + ): Promise + pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise + fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise + rebaseFromBase(worktreePath: string, baseRef: string): Promise + fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise + syncForkDefaultBranch( + worktreePath: string, + expectedUpstream: GitForkSyncExpectedUpstream + ): Promise + getBranchDiff( + worktreePath: string, + baseRef: string, + options?: { includePatch?: boolean; filePath?: string; oldPath?: string } + ): Promise + getCommitDiff( + worktreePath: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise + listWorktrees(repoPath: string, options?: { signal?: AbortSignal }): Promise + addWorktree( + repoPath: string, + branchName: string, + targetDir: string, + options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } + ): Promise + removeWorktree( + worktreePath: string, + force?: boolean, + options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } + ): Promise + renameCurrentBranch?(worktreePath: string, newBranch: string): Promise + forceDeletePreservedBranch?( + repoPath: string, + branchName: string, + expectedHead: string + ): Promise + isGitRepo(path: string): boolean + isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> + exec( + args: string[], + cwd: string, + options?: { signal?: AbortSignal; timeoutMs?: number } + ): Promise<{ stdout: string; stderr: string }> + getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise + getRemoteCommitUrl(worktreePath: string, sha: string): Promise + worktreeIsClean( + worktreePath: string, + options?: { includeUntracked?: boolean } + ): Promise<{ clean: boolean; stdout?: string }> +} diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts new file mode 100644 index 00000000000..f305f1e85b7 --- /dev/null +++ b/src/main/providers/pty-provider-contract.ts @@ -0,0 +1,194 @@ +import type { TuiAgent } from '../../shared/types' +import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events' +import type { PtySpawnResult } from './pty-spawn-result' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' +import type { PtyProcessInfo } from './pty-process-info' + +export type { + PtyBackgroundStreamEvent, + PtyDataEvent, + PtyTransientFact +} from './pty-provider-events' + +export type PtyProviderBufferSnapshot = { + data: string + /** Authoritative normal buffer captured beside an alternate-screen frame. */ + scrollbackAnsi?: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq: number + source: 'headless' + oscLinks?: TerminalOscLinkRange[] + alternateScreen?: boolean + pendingEscapeTailAnsi?: string +} + +export type PtySpawnOptions = { + cols: number + rows: number + cwd?: string + env?: Record + envToDelete?: string[] + /** Main-validated home provenance for an automatic Codex session resume. */ + codexHomePathOverride?: { value: string | null } + command?: string + commandDelivery?: 'renderer' | 'provider' + startupCommandDelivery?: StartupCommandDelivery + /** Minimal allowlisted launch ownership preserved by daemon reattach. */ + launchAgent?: TuiAgent + /** Orca worktree identity. When present, the local provider scopes shell + * history to this worktree so ArrowUp only surfaces local commands. */ + worktreeId?: string + /** Stable terminal pane identity. Remote providers use this as PTY metadata + * even when it must not be exported into the spawned shell environment. */ + paneKey?: string + /** Stable terminal tab identity used as a coarser attach guard when a pane + * identity is unavailable. */ + tabId?: string + /** Daemon session ID. A caller-provided ID is treated as an attach request; + * daemon hosts also pass minted IDs for fresh sessions that need stable + * per-PTY state before provider.spawn returns. */ + sessionId?: string + /** True when the caller minted this daemon session for a fresh terminal. + * Existing-session attach paths must stay false so recovery checks do not + * replace the daemon out from under a still-live PTY. */ + isNewSession?: boolean + /** Why: allows the renderer to request a specific shell for a single new + * terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without + * changing the user's persistent default shell setting. Only consulted on + * Windows; ignored on macOS/Linux where shell selection is not exposed. */ + shellOverride?: string + /** Preferred WSL distro for generic `wsl.exe` launches. Worktree/session + * distro still wins when the cwd already identifies a WSL distro. */ + terminalWindowsWslDistro?: string | null + /** Why: PowerShell is the top-level shell family in product terms, but on + * Windows we may need to choose between inbox Windows PowerShell 5.1 and + * pwsh.exe at spawn time. Threading the persisted implementation choice + * through spawn options keeps local PTY and daemon PTY semantics aligned + * without promoting pwsh into a separate shell family. */ + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + /** Fresh-spawn-only source authority installed before any PTY output is released. */ + startupIngress?: PtyStartupIngressIntent + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } + /** Host-scoped structured-create identity used only for lower-owner replay. */ + agentSessionCreateOperationId?: string + /** Signals that the native process exists even if later publication fails. */ + onPtySpawnCommitted?: () => void + /** Cancels only before physical dispatch; operation identity fences later ambiguity. */ + signal?: AbortSignal +} + +export type { PtyProcessInfo, PtySpawnResult } + +type PtyProbeOptions = { signal?: AbortSignal } + +export type IPtyProvider = { + spawn(opts: PtySpawnOptions): Promise + /** Whether this spawn target can append the Git guard after its final env merge. */ + supportsGitCredentialGuardHost?: (sessionId?: string) => boolean + /** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */ + supportsAgentSessionClaims?: (options?: PtyProbeOptions) => boolean | Promise + /** Whether missing claim metadata in this PTY's process listing proves absence. */ + providesAgentSessionOwnerListings?: (ptyId: string) => boolean + /** Whether fresh structured creates can replay one spawn across a lost relay response. */ + supportsAgentSessionCreateOperations?: (options?: PtyProbeOptions) => boolean | Promise + attach(id: string): Promise + hasPty?: (id: string) => boolean + /** Exact provider readback: false only when the provider answered that the PTY is absent. */ + probePtyLiveness?: (id: string) => Promise + write(id: string, data: string): void + resize(id: string, cols: number, rows: number): void + /** + * Producer-side flow control: stop/restart reading the underlying PTY so a + * flooding child blocks on write (kernel backpressure) instead of growing + * main-process buffers. Best-effort and optional — providers that cannot + * pause (SSH relay, legacy daemon protocols) omit these or no-op silently, + * and callers must keep functioning without them (the pending-output cap + * still bounds memory when pause is unavailable). + */ + pauseProducer?: (id: string) => void + resumeProducer?: (id: string) => void + /** + * Hidden-delivery hint: the renderer has no visible view for this PTY, so + * the provider's transport may keep-tail thin this PTY's monitoring stream + * under backlog (bytes nobody is watching must not bury a visible pane's + * echo). Best-effort and optional, like pauseProducer. + */ + setPtyBackgrounded?: (id: string, background: boolean) => void + /** + * Facts a thinning transport interleaves with onData, in byte order: + * scan-authority handoff markers, keep-tail gaps, and the transient facts + * (bell/command-finished/pr-link/2031) it detected in bytes it was allowed + * to drop. Only transports that thin implement it. + */ + onBackgroundStreamEvent?: (callback: (payload: PtyBackgroundStreamEvent) => void) => () => void + /** + * The provider's write endpoint died wholesale (a daemon crash takes down + * every session at once), so all of its PTYs need a remount + re-attach — + * not just the one whose write happened to detect it (STA-2373). Optional: + * only respawnable endpoints like the daemon adapter can signal it. + */ + onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void + /** Authoritative provider-owned model snapshot. Daemon providers expose this + * after their monitoring stream gaps; other providers may omit it. */ + getBufferSnapshot?: ( + id: string, + opts?: { scrollbackRows?: number } + ) => Promise + /** Whether this exact PTY can return a sequence-safe provider snapshot. */ + canProvideAuthoritativeBufferSnapshot?: (id: string) => boolean + /** + * The size the PTY has ACTUALLY applied, not the last size requested. + * resize() is fire-and-forget for remote providers (daemon/SSH `notify`), + * so a resize can be silently dropped (session not yet alive, dead handle, + * cold-restore snapshot-cols coercion) while the caller still believes it + * landed. This is the readback the renderer's resume drift-check compares + * against to detect — and re-assert past — such drops. Returns null when the + * provider cannot confirm the applied size (unknown id, relay unreachable); + * callers treat null as "cannot confirm" and re-forward once. Optional so + * providers without an authoritative size source can omit it. + */ + getAppliedSize?: (id: string) => Promise<{ cols: number; rows: number } | null> + + // Why: deadlineMs (absolute epoch ms) bounds the underlying RPCs so destructive + // teardown fails fast inside its sweep budget instead of tripping the outer sweep + // deadline; each RPC leaf converts to a relative timeout when it actually issues. + shutdown( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise + sendSignal(id: string, signal: string): Promise + getCwd(id: string): Promise + getInitialCwd(id: string): Promise + clearBuffer(id: string): Promise + /** Ordered handoff from startup source authority to the live/hidden view authority. */ + closeStartupQueryAuthority?: (id: string) => Promise | number + acknowledgeDataEvent(id: string, charCount: number): void + hasChildProcesses(id: string): Promise + getForegroundProcess(id: string): Promise + /** Strong process evidence captured after the caller's command boundary. */ + confirmForegroundProcess?: (id: string) => Promise + serialize(ids: string[]): Promise + revive(state: string): Promise + // Why: deadlineMs bounds the underlying RPC exactly like shutdown's deadlineMs. + listProcesses(opts?: { deadlineMs?: number }): Promise + getDefaultShell(): Promise + getProfiles(): Promise<{ name: string; path: string }[]> + onData(callback: (payload: PtyDataEvent) => void): () => void + onReplay(callback: (payload: { id: string; data: string }) => void): () => void + onExit( + callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void + ): () => void +} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 18dee7c164c..a36b222f5e4 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -1,385 +1,36 @@ -import type { - DirEntry, - FsChangeEvent, - GitStatusResult, - GitDiffResult, - GitBranchCompareResult, - GitCommitCompareResult, - GitConflictOperation, - GitForkSyncExpectedUpstream, - GitForkSyncResult, - GitPushTarget, - GitStagingArea, - GitUpstreamStatus, - GitWorktreeInfo, - TuiAgent, - RemoveWorktreeResult, - SearchOptions, - SearchResult -} from '../../shared/types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' -import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' -import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' -import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' -import type { GitProviderStatusOptions } from './git-provider-status-options' -import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events' -import type { PtySpawnResult } from './pty-spawn-result' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' -import type { - AgentSessionExecutionClaim, - AgentSessionSurfaceBinding -} from '../../shared/agent-session-host-authority' -import type { PtyProcessInfo } from './pty-process-info' - -export type { - PtyBackgroundStreamEvent, - PtyDataEvent, - PtyTransientFact -} from './pty-provider-events' +// Aggregate provider contract surface. The three per-domain contracts live in +// their own files; this barrel keeps every historical `providers/types` import +// path working and owns the registry that ties them together. +import type { IPtyProvider } from './pty-provider-contract' +import type { IFilesystemProvider } from './filesystem-provider-contract' +import type { IGitProvider } from './git-provider-contract' // ─── PTY Provider ─────────────────────────────────────────────────── -export type PtyProviderBufferSnapshot = { - data: string - /** Authoritative normal buffer captured beside an alternate-screen frame. */ - scrollbackAnsi?: string - cols: number - rows: number - cwd?: string | null - lastTitle?: string - seq: number - source: 'headless' - oscLinks?: TerminalOscLinkRange[] - alternateScreen?: boolean - pendingEscapeTailAnsi?: string -} - -export type PtySpawnOptions = { - cols: number - rows: number - cwd?: string - env?: Record - envToDelete?: string[] - /** Main-validated home provenance for an automatic Codex session resume. */ - codexHomePathOverride?: { value: string | null } - command?: string - commandDelivery?: 'renderer' | 'provider' - startupCommandDelivery?: StartupCommandDelivery - /** Minimal allowlisted launch ownership preserved by daemon reattach. */ - launchAgent?: TuiAgent - /** Orca worktree identity. When present, the local provider scopes shell - * history to this worktree so ArrowUp only surfaces local commands. */ - worktreeId?: string - /** Stable terminal pane identity. Remote providers use this as PTY metadata - * even when it must not be exported into the spawned shell environment. */ - paneKey?: string - /** Stable terminal tab identity used as a coarser attach guard when a pane - * identity is unavailable. */ - tabId?: string - /** Daemon session ID. A caller-provided ID is treated as an attach request; - * daemon hosts also pass minted IDs for fresh sessions that need stable - * per-PTY state before provider.spawn returns. */ - sessionId?: string - /** True when the caller minted this daemon session for a fresh terminal. - * Existing-session attach paths must stay false so recovery checks do not - * replace the daemon out from under a still-live PTY. */ - isNewSession?: boolean - /** Why: allows the renderer to request a specific shell for a single new - * terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without - * changing the user's persistent default shell setting. Only consulted on - * Windows; ignored on macOS/Linux where shell selection is not exposed. */ - shellOverride?: string - /** Preferred WSL distro for generic `wsl.exe` launches. Worktree/session - * distro still wins when the cwd already identifies a WSL distro. */ - terminalWindowsWslDistro?: string | null - /** Why: PowerShell is the top-level shell family in product terms, but on - * Windows we may need to choose between inbox Windows PowerShell 5.1 and - * pwsh.exe at spawn time. Threading the persisted implementation choice - * through spawn options keeps local PTY and daemon PTY semantics aligned - * without promoting pwsh into a separate shell family. */ - terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' - /** Fresh-spawn-only source authority installed before any PTY output is released. */ - startupIngress?: PtyStartupIngressIntent - agentSessionEnsure?: { - claim: AgentSessionExecutionClaim - surface: AgentSessionSurfaceBinding - } - /** Host-scoped structured-create identity used only for lower-owner replay. */ - agentSessionCreateOperationId?: string - /** Signals that the native process exists even if later publication fails. */ - onPtySpawnCommitted?: () => void - /** Cancels only before physical dispatch; operation identity fences later ambiguity. */ - signal?: AbortSignal -} - -export type { PtyProcessInfo, PtySpawnResult } - -type PtyProbeOptions = { signal?: AbortSignal } - -export type IPtyProvider = { - spawn(opts: PtySpawnOptions): Promise - /** Whether this spawn target can append the Git guard after its final env merge. */ - supportsGitCredentialGuardHost?: (sessionId?: string) => boolean - /** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */ - supportsAgentSessionClaims?: (options?: PtyProbeOptions) => boolean | Promise - /** Whether missing claim metadata in this PTY's process listing proves absence. */ - providesAgentSessionOwnerListings?: (ptyId: string) => boolean - /** Whether fresh structured creates can replay one spawn across a lost relay response. */ - supportsAgentSessionCreateOperations?: (options?: PtyProbeOptions) => boolean | Promise - attach(id: string): Promise - hasPty?: (id: string) => boolean - /** Exact provider readback: false only when the provider answered that the PTY is absent. */ - probePtyLiveness?: (id: string) => Promise - write(id: string, data: string): void - resize(id: string, cols: number, rows: number): void - /** - * Producer-side flow control: stop/restart reading the underlying PTY so a - * flooding child blocks on write (kernel backpressure) instead of growing - * main-process buffers. Best-effort and optional — providers that cannot - * pause (SSH relay, legacy daemon protocols) omit these or no-op silently, - * and callers must keep functioning without them (the pending-output cap - * still bounds memory when pause is unavailable). - */ - pauseProducer?: (id: string) => void - resumeProducer?: (id: string) => void - /** - * Hidden-delivery hint: the renderer has no visible view for this PTY, so - * the provider's transport may keep-tail thin this PTY's monitoring stream - * under backlog (bytes nobody is watching must not bury a visible pane's - * echo). Best-effort and optional, like pauseProducer. - */ - setPtyBackgrounded?: (id: string, background: boolean) => void - /** - * Facts a thinning transport interleaves with onData, in byte order: - * scan-authority handoff markers, keep-tail gaps, and the transient facts - * (bell/command-finished/pr-link/2031) it detected in bytes it was allowed - * to drop. Only transports that thin implement it. - */ - onBackgroundStreamEvent?: (callback: (payload: PtyBackgroundStreamEvent) => void) => () => void - /** Authoritative provider-owned model snapshot. Daemon providers expose this - * after their monitoring stream gaps; other providers may omit it. */ - getBufferSnapshot?: ( - id: string, - opts?: { scrollbackRows?: number } - ) => Promise - /** Whether this exact PTY can return a sequence-safe provider snapshot. */ - canProvideAuthoritativeBufferSnapshot?: (id: string) => boolean - /** - * The size the PTY has ACTUALLY applied, not the last size requested. - * resize() is fire-and-forget for remote providers (daemon/SSH `notify`), - * so a resize can be silently dropped (session not yet alive, dead handle, - * cold-restore snapshot-cols coercion) while the caller still believes it - * landed. This is the readback the renderer's resume drift-check compares - * against to detect — and re-assert past — such drops. Returns null when the - * provider cannot confirm the applied size (unknown id, relay unreachable); - * callers treat null as "cannot confirm" and re-forward once. Optional so - * providers without an authoritative size source can omit it. - */ - getAppliedSize?: (id: string) => Promise<{ cols: number; rows: number } | null> - - // Why: deadlineMs (absolute epoch ms) bounds the underlying RPCs so destructive - // teardown fails fast inside its sweep budget instead of tripping the outer sweep - // deadline; each RPC leaf converts to a relative timeout when it actually issues. - shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise - sendSignal(id: string, signal: string): Promise - getCwd(id: string): Promise - getInitialCwd(id: string): Promise - clearBuffer(id: string): Promise - /** Ordered handoff from startup source authority to the live/hidden view authority. */ - closeStartupQueryAuthority?: (id: string) => Promise | number - acknowledgeDataEvent(id: string, charCount: number): void - hasChildProcesses(id: string): Promise - getForegroundProcess(id: string): Promise - /** Strong process evidence captured after the caller's command boundary. */ - confirmForegroundProcess?: (id: string) => Promise - serialize(ids: string[]): Promise - revive(state: string): Promise - // Why: deadlineMs bounds the underlying RPC exactly like shutdown's deadlineMs. - listProcesses(opts?: { deadlineMs?: number }): Promise - getDefaultShell(): Promise - getProfiles(): Promise<{ name: string; path: string }[]> - onData(callback: (payload: PtyDataEvent) => void): () => void - onReplay(callback: (payload: { id: string; data: string }) => void): () => void - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void -} +export type { + IPtyProvider, + PtyBackgroundStreamEvent, + PtyDataEvent, + PtyProcessInfo, + PtyProviderBufferSnapshot, + PtySpawnOptions, + PtySpawnResult, + PtyTransientFact +} from './pty-provider-contract' // ─── Filesystem Provider ──────────────────────────────────────────── -export type FileStat = { - size: number - type: 'file' | 'directory' | 'symlink' - mtime: number - mtimeMs?: number - dev?: number - ino?: number - nlink?: number -} - -export type FileReadResult = { - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string -} - -export type IFilesystemProvider = { - readDir(dirPath: string): Promise - readFile(filePath: string): Promise - readTerminalArtifact?( - filePath: string, - options: TerminalArtifactAccessOptions - ): Promise - downloadFile?(sourcePath: string, destinationPath: string): Promise - downloadFolder?: (src: string, dest: string, options?: { signal?: AbortSignal }) => Promise - openFileUploadSession?(): Promise - getTempDir?(): Promise - writeFile(filePath: string, content: string): Promise - writeTerminalArtifact?( - filePath: string, - content: string, - options: TerminalArtifactAccessOptions - ): Promise - writeFileBase64(filePath: string, contentBase64: string): Promise - writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise - stat(filePath: string): Promise - lstat?(filePath: string): Promise - deletePath(targetPath: string, recursive?: boolean): Promise - createFile(filePath: string): Promise - createDir(dirPath: string): Promise - createDirNoClobber(dirPath: string): Promise - rename(oldPath: string, newPath: string): Promise - renameNoClobber(oldPath: string, newPath: string): Promise - copy(source: string, destination: string): Promise - realpath(filePath: string): Promise - search(opts: SearchOptions): Promise - listFiles( - rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } - ): Promise - scanWorkspaceSpace?( - rootPath: string, - options?: { signal?: AbortSignal } - ): Promise - watch( - rootPath: string, - callback: (events: FsChangeEvent[]) => void, - options?: { signal?: AbortSignal; onTerminalError?: (error: Error) => void } - ): Promise<() => void> - closeWatch?(rootPath: string): Promise -} - -export type FileUploadSession = { - uploadFile( - sourcePath: string, - destinationPath: string, - options?: { exclusive?: boolean } - ): Promise - close(): void -} - -export type TerminalArtifactAccessOptions = { - expectedRealPath: string - expectedStatIdentity: string | null - maxBytes: number -} +export type { + FileReadResult, + FileStat, + FileUploadSession, + IFilesystemProvider, + TerminalArtifactAccessOptions +} from './filesystem-provider-contract' // ─── Git Provider ─────────────────────────────────────────────────── -export type { GitProviderStatusOptions } from './git-provider-status-options' - -export type IGitProvider = { - getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise - getSubmoduleStatus( - worktreePath: string, - submodulePath: string, - area?: GitStagingArea - ): Promise - checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise - getHistory(worktreePath: string, options?: GitHistoryOptions): Promise - commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> - getStagedCommitContext(worktreePath: string): Promise - getDiff( - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead?: boolean - ): Promise - stageFile(worktreePath: string, filePath: string): Promise - unstageFile(worktreePath: string, filePath: string): Promise - bulkStageFiles(worktreePath: string, filePaths: string[]): Promise - bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise - discardChanges(worktreePath: string, filePath: string): Promise - bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise - detectConflictOperation(worktreePath: string): Promise - abortMerge(worktreePath: string): Promise - abortRebase(worktreePath: string): Promise - checkoutBranch(worktreePath: string, branch: string): Promise - listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }> - getBranchCompare(worktreePath: string, baseRef: string): Promise - getCommitCompare(worktreePath: string, commitId: string): Promise - getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise - pushBranch( - worktreePath: string, - publish?: boolean, - pushTarget?: GitPushTarget, - options?: { forceWithLease?: boolean } - ): Promise - pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise - fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise - rebaseFromBase(worktreePath: string, baseRef: string): Promise - fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise - syncForkDefaultBranch( - worktreePath: string, - expectedUpstream: GitForkSyncExpectedUpstream - ): Promise - getBranchDiff( - worktreePath: string, - baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string } - ): Promise - getCommitDiff( - worktreePath: string, - args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } - ): Promise - listWorktrees(repoPath: string, options?: { signal?: AbortSignal }): Promise - addWorktree( - repoPath: string, - branchName: string, - targetDir: string, - options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } - ): Promise - removeWorktree( - worktreePath: string, - force?: boolean, - options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } - ): Promise - renameCurrentBranch?(worktreePath: string, newBranch: string): Promise - forceDeletePreservedBranch?( - repoPath: string, - branchName: string, - expectedHead: string - ): Promise - isGitRepo(path: string): boolean - isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> - exec( - args: string[], - cwd: string, - options?: { signal?: AbortSignal; timeoutMs?: number } - ): Promise<{ stdout: string; stderr: string }> - getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise - getRemoteCommitUrl(worktreePath: string, sha: string): Promise - worktreeIsClean( - worktreePath: string, - options?: { includeUntracked?: boolean } - ): Promise<{ clean: boolean; stdout?: string }> -} +export type { GitProviderStatusOptions, IGitProvider } from './git-provider-contract' // ─── Provider Registry ────────────────────────────────────────────── diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2fd94df751f..6c137a4dcf0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8468,6 +8468,81 @@ describe('OrcaRuntimeService', () => { expect(events).toHaveLength(1) }) + it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => { + const runtime = new OrcaRuntimeService(store) + const desktopEvents: RuntimeClientEvent[] = [] + const mobileEvents: RuntimeClientEvent[] = [] + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => desktopEvents.push(event)) + runtime.onClientEvent((event) => mobileEvents.push(event), { + consumesTerminalSideEffects: false + }) + + runtime.onPtyData('pty-remote', '\x1b]0;Codex working\x07', 100) + runtime.notifyBranchRenamed(TEST_REPO_ID) + + expect(desktopEvents.map((event) => event.type)).toEqual([ + 'terminalSideEffects', + 'worktreesChanged' + ]) + expect(mobileEvents.map((event) => event.type)).toEqual(['worktreesChanged']) + }) + + it('keeps a phone-only host producing title state without emitting batches to it', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a` + const runtime = new OrcaRuntimeService(store) + const mobileEvents: RuntimeClientEvent[] = [] + const trackerEntries = ( + runtime as unknown as { + ptyTitleTrackersByPtyId: Map + } + ).ptyTitleTrackersByPtyId + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => mobileEvents.push(event), { + consumesTerminalSideEffects: false + }) + const unsubscribeDesktop = runtime.onClientEvent(() => {}) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + // The phone is still subscribed: disposing trackers on this edge would cancel + // its armed stale-working-title timer and strand a 'working' spinner (#1437). + unsubscribeDesktop() + + await vi.advanceTimersByTimeAsync(3_000) + + expect(trackerEntries.has(ptyId)).toBe(true) + expect(trackerEntries.get(ptyId)?.commandCodeDetector).toBeNull() + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ title: 'Codex' }) + expect(mobileEvents.some((event) => event.type === 'terminalSideEffects')).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('skips a listener unsubscribed mid-fan-out even with mobile exclusions active', () => { + const runtime = new OrcaRuntimeService(store) + const lateEvents: RuntimeClientEvent[] = [] + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.onClientEvent(() => {}, { consumesTerminalSideEffects: false }) + runtime.onClientEvent(() => { + unsubscribeLate() + }) + const unsubscribeLate = runtime.onClientEvent((event) => lateEvents.push(event)) + + runtime.onPtyData('pty-remote', '\x1b]0;Codex working\x07', 100) + + expect(lateEvents).toEqual([]) + }) + it('emits one batched event per chunk with facts in byte order and attribution', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -8504,17 +8579,23 @@ describe('OrcaRuntimeService', () => { expect(batches[0].seq).toBeLessThan(batches[1].seq) }) - it('emits nothing for chunks without derived facts', () => { + it('emits only agent-status facts for status-only chunks', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) - // Plain output, a BEL-terminated non-title OSC split across chunks, and an Orca status payload — none is a title/bell/agent fact. + // Plain output and a BEL-terminated non-title OSC stay fact-free. runtime.onPtyData('pty-1', 'plain output\r\n', 100) runtime.onPtyData('pty-1', '\x1b]7;file://host', 101) runtime.onPtyData('pty-1', '/tmp\x07', 102) runtime.onPtyData('pty-1', '\x1b]9999;{"state":"working","agentType":"codex"}\x07', 103) - expect(batches).toEqual([]) + expect(batches).toHaveLength(1) + expect(batches[0].facts).toEqual([ + { + kind: 'agent-status', + payload: expect.objectContaining({ state: 'working', agentType: 'codex' }) + } + ]) }) it('emits the stale-working-title rewrite as between-chunk fact batches', async () => { @@ -24951,6 +25032,117 @@ describe('OrcaRuntimeService', () => { ]) }) + it('keeps a live headed runtime-owned tab until its explicit close', async () => { + const ptyId = 'local-runtime-owned-pty' + const splitPtyId = 'local-runtime-owned-split-pty' + const tabId = 'runtime-session-tab' + const leafId = HEADLESS_LEAF_ID + const kill = vi.fn(() => true) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeRepoId: TEST_REPO_ID, + activeWorktreeId: TEST_WORKTREE_ID, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: tabId, + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: makeHeadlessTerminalLayout({ [leafId]: undefined }) + } + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValueOnce({ id: ptyId }).mockResolvedValueOnce({ id: splitPtyId }), + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: ptyId, + cwd: TEST_WORKTREE_PATH, + title: 'Codex', + worktreeId: TEST_WORKTREE_ID + }, + { + id: splitPtyId, + cwd: TEST_WORKTREE_PATH, + title: 'Codex', + worktreeId: TEST_WORKTREE_ID + } + ] + }) + const publishRendererOmission = (snapshotVersion: number): void => { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headed-runtime', + snapshotVersion, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + } + runtime.attachWindow(1) + publishRendererOmission(1) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + presentation: 'background', + persistHostSessionBinding: true, + tabId, + leafId, + launchAgent: 'codex' + }) + const split = await runtime.splitTerminal(created.handle, { direction: 'vertical' }) + publishRendererOmission(2) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: `${tabId}::${leafId}`, + parentTabId: tabId, + ptyId, + status: 'ready', + terminal: created.handle + }), + expect.objectContaining({ + parentTabId: tabId, + ptyId: splitPtyId, + status: 'ready', + terminal: split.handle + }) + ]) + ) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toHaveLength(2) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, tabId, { reason: 'user' }) + publishRendererOmission(3) + + expect(kill).toHaveBeenCalledWith(ptyId) + expect(kill).toHaveBeenCalledWith(splitPtyId) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) + }) + it('publishes laptop-created remote runtime terminals to phone session tabs', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) const runtime = new OrcaRuntimeService(store) @@ -29214,7 +29406,7 @@ describe('OrcaRuntimeService', () => { { tabId: 'tab-renderer', worktreeId: TEST_WORKTREE_ID, - leafId: 'pane:1', + leafId: HEADLESS_LEAF_ID, paneRuntimeId: 1, ptyId: 'pty-renderer', paneTitle: null @@ -29231,10 +29423,12 @@ describe('OrcaRuntimeService', () => { tabs: [ { type: 'terminal', - id: 'tab-renderer::pane:1', + id: `tab-renderer::${HEADLESS_LEAF_ID}`, parentTabId: 'tab-renderer', - leafId: 'pane:1', + leafId: HEADLESS_LEAF_ID, + ptyId: 'pty-renderer', title: 'Terminal', + viewMode: 'chat', isActive: false } ] @@ -29275,6 +29469,86 @@ describe('OrcaRuntimeService', () => { ) expect(focusTerminal).not.toHaveBeenCalled() expect(result.tab).toMatchObject({ parentTabId: 'tab-renderer', isActive: false }) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-renderer', + paneTitle: null + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ parentTabId: 'tab-renderer', ptyId: 'pty-renderer' }) + ]) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_SECOND_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-renderer', + paneTitle: null + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 3, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_SECOND_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-renderer', + paneTitle: null + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 4, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) }) it('dedupes concurrent mobile terminal creates that share a clientMutationId', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b972e482cc9..d6bc500632f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1216,6 +1216,7 @@ type RuntimePtyWorktreeRecord = { incarnationId: PtyIncarnationId | null worktreeId: string connectionId: string | null + runtimeSessionOwned: boolean // Why: a Windows host can own both native and WSL panes; preamble command // selection must follow the pane that executes it, not process.platform. isWsl: boolean | null @@ -1428,8 +1429,8 @@ type RuntimePtyTitleTrackerEntry = { lastMobileTitleGateKey: string | null chunkTouchedSessionTabs: boolean // Why: facts observed while applying a chunk are batched into one - // pty:sideEffect emission per chunk, preserving byte order (titles in - // sequence, then bell). Timer-fired facts emit immediately between chunks. + // pty:sideEffect emission per chunk, preserving status/title/bell order. + // Timer-fired facts emit immediately between chunks. pendingFacts: TerminalSideEffectFact[] // Why: Command Code lacks hooks, so its working/done state is scraped from // TUI output. Null when no side-effect consumer exists (headless serve) — @@ -2725,6 +2726,10 @@ export class OrcaRuntimeService { private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + // Why: mobile subscribers discard terminalSideEffects; exclude them from batch delivery and production. + private terminalSideEffectExcludedClientEventListeners = new Set< + (event: RuntimeClientEvent) => void + >() private nativeChatLaunchDraftResolutionByTabId = new Map< string, NativeChatLaunchDraftResolutionTombstone @@ -4681,15 +4686,26 @@ export class OrcaRuntimeService { } } - onClientEvent(listener: (event: RuntimeClientEvent) => void): () => void { + onClientEvent( + listener: (event: RuntimeClientEvent) => void, + options?: { consumesTerminalSideEffects?: boolean } + ): () => void { this.clientEventListeners.add(listener) + if (options?.consumesTerminalSideEffects === false) { + this.terminalSideEffectExcludedClientEventListeners.add(listener) + } this.refreshTerminalSideEffectConsumerAvailability() return () => { this.clientEventListeners.delete(listener) + this.terminalSideEffectExcludedClientEventListeners.delete(listener) this.refreshTerminalSideEffectConsumerAvailability() } } + private countTerminalSideEffectConsumingClientEventListeners(): number { + return this.clientEventListeners.size - this.terminalSideEffectExcludedClientEventListeners.size + } + getTerminalSleepClientEventSnapshot(): RuntimeClientEvent[] { const events: RuntimeClientEvent[] = [] const sleepStates = [...this.terminalSleepStateByWorktreeId.values()].sort((a, b) => @@ -4747,9 +4763,25 @@ export class OrcaRuntimeService { } private emitClientEvent(event: RuntimeClientEvent): void { + // Why: mobile streams discard terminalSideEffects; skip excluded listeners so + // paired phones never receive the per-OSC batch frames over the relay. Filtered + // inside the delivery callback to keep live-Set iteration (a listener that + // unsubscribes mid-fan-out must not be delivered to) and stay allocation-free. + const skipExcluded = + event.type === 'terminalSideEffects' && + this.terminalSideEffectExcludedClientEventListeners.size > 0 // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. - notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event') + notifyRuntimeListeners( + this.clientEventListeners, + (listener) => { + if (skipExcluded && this.terminalSideEffectExcludedClientEventListeners.has(listener)) { + return + } + listener(event) + }, + 'client-event' + ) } notifyNativeChatLaunchDraftResolved( @@ -5951,6 +5983,35 @@ export class OrcaRuntimeService { return boundPtyIds.some((ptyId) => persistedPtyIds.has(ptyId)) } + private hasLiveRuntimeSessionOwnedPtyBinding( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) + return pty?.connected === true && pty.runtimeSessionOwned + } + + private clearRuntimeSessionOwnershipForMobileTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + parentTabId: string + ): void { + for (const tab of snapshot.tabs) { + if (tab.type !== 'terminal' || tab.parentTabId !== parentTabId) { + continue + } + const ptyIds = [tab.ptyId, ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})].filter( + (ptyId): ptyId is string => typeof ptyId === 'string' + ) + for (const ptyId of ptyIds) { + const pty = this.ptysById.get(ptyId) + if (pty?.worktreeId === worktreeId && pty.tabId === parentTabId) { + pty.runtimeSessionOwned = false + } + } + } + } + // Why: a tab needs authoritative runtime teardown (kill + de-persist + prune) // only when the renderer can't durably tear it down: either it's serve/SSH // (preserved + re-hydrated, would resurrect) or the renderer graph never @@ -7409,6 +7470,7 @@ export class OrcaRuntimeService { this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() } + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId) return { closed: true } } // Why: notifier implementations without the acknowledged relay may expose @@ -7436,6 +7498,7 @@ export class OrcaRuntimeService { // parent tab id. Closing that parent should close the desktop tab, not // just whichever leaf happened to be first in the session snapshot. this.notifier?.closeTerminal(tab.parentTabId) + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId) } } else if (tab.type === 'browser' && this.offscreenBrowserBackend) { // Why: headless browser tabs are offscreen WebContents with no renderer to @@ -7580,6 +7643,7 @@ export class OrcaRuntimeService { options: { killPtys?: boolean } = {} ): void { const closedParentTabId = tab.parentTabId + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) // Why: local provider ids can be reused after restart, so a dormant // persisted id is not kill authority. SSH relay ids remain durable exact @@ -8751,6 +8815,9 @@ export class OrcaRuntimeService { this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId, + ...(binding && this.pendingMobileTerminalCreatesByKey.has(`${worktreeId}::${binding.tabId}`) + ? { runtimeSessionOwned: true } + : {}), ...(isWsl !== undefined ? { isWsl } : {}), ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) @@ -9131,6 +9198,9 @@ export class OrcaRuntimeService { titleTrackerEntry.chunkTouchedSessionTabs = false let retainedAgentStatusChanged = false try { + for (const payload of agentStatusChunk.payloads) { + titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload }) + } titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { titleScanData: titleInput }) @@ -9417,7 +9487,7 @@ export class OrcaRuntimeService { console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) } } - if (this.clientEventListeners.size > 0) { + if (this.countTerminalSideEffectConsumingClientEventListeners() > 0) { this.emitClientEvent({ type: 'terminalSideEffects', batch }) } } @@ -9585,33 +9655,23 @@ export class OrcaRuntimeService { this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, - // Why: headless serve still scans command completion to retire agent - // launch authority; other transient facts remain desktop-only. - ...(this.terminalSideEffectConsumerAvailable - ? { - onBell: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) - }, - onPrLink: (link: TerminalGitHubPRLink) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) - }, - // Why: hidden-delivery-gated views never see the bytes, so main - // surfaces DECSET 2031 subscribes as facts; the theme reply is - // still sent by the renderer (query authority stays with the view). - onMode2031Subscribe: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) - }, - // Why: the gated view never sees the withdrawal bytes either, so the - // subscription registry it keeps for theme flips needs this fact to - // stay truthful. - onMode2031Unsubscribe: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) - } - } - : {}) + onBell: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + }, + onPrLink: (link: TerminalGitHubPRLink) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) + }, + // Why: hidden-delivery-gated views never see 2031 bytes; facts keep their theme registry truthful. + onMode2031Subscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + }, + onMode2031Unsubscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) + } }, initialTitle !== null ? { initialTitle } : {} ) + tracker.setTransientSideEffectScanningEnabled(this.terminalSideEffectConsumerAvailable) const entry: RuntimePtyTitleTrackerEntry = { tracker, applyingChunk: false, @@ -9624,15 +9684,7 @@ export class OrcaRuntimeService { // self-arms on the Command Code banner; the spawn command (when main // saw one) mirrors the renderer detector's startupCommand fast-arm. commandCodeDetector: this.terminalSideEffectConsumerAvailable - ? createCommandCodeOutputStatusDetector({ - startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, - onWorking: (prompt) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) - }, - onDone: (prompt) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) - } - }) + ? this.createTerminalSideEffectCommandCodeDetector(ptyId) : null } this.ptyTitleTrackersByPtyId.set(ptyId, entry) @@ -9752,18 +9804,34 @@ export class OrcaRuntimeService { private refreshTerminalSideEffectConsumerAvailability(): void { const nextAvailable = - this.terminalSideEffectLocalConsumerAvailable || this.clientEventListeners.size > 0 + this.terminalSideEffectLocalConsumerAvailable || + this.countTerminalSideEffectConsumingClientEventListeners() > 0 if (nextAvailable === this.terminalSideEffectConsumerAvailable) { return } this.terminalSideEffectConsumerAvailable = nextAvailable - // Why: optional bell/command/link scanners are selected when a tracker is - // created. Rebuild at the window boundary so pure headless output stays cheap. - for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) { - this.disposePtyTitleTracker(ptyId) + for (const [ptyId, entry] of this.ptyTitleTrackersByPtyId) { + entry.tracker.setTransientSideEffectScanningEnabled(nextAvailable) + entry.commandCodeDetector = nextAvailable + ? this.createTerminalSideEffectCommandCodeDetector(ptyId) + : null } } + private createTerminalSideEffectCommandCodeDetector( + ptyId: string + ): NonNullable { + return createCommandCodeOutputStatusDetector({ + startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, + onWorking: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) + }, + onDone: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) + } + }) + } + private extractLastOsc7CwdForPty( ptyId: string, data: string @@ -12261,10 +12329,35 @@ export class OrcaRuntimeService { return this.requireAccountServices().claudeAccounts.removeAccount(accountId) } + // Why: register a managed Claude account from a CLAUDE_CONFIG_DIR the caller + // already logged into. Lets the `orca account add` CLI drive `claude login` in + // the user's terminal on a headless host, then capture the credentials here — + // the desktop GUI's interactive add flow is unreachable over a remote runtime. + addClaudeAccountFromConfigDir( + configDir: string, + options?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + previousLegacyCredentialsSha256?: string | null + } + ): Promise { + return this.requireAccountServices().claudeAccounts.addAccountFromConfigDir(configDir, options) + } + removeCodexAccount(accountId: string): Promise { return this.requireAccountServices().codexAccounts.removeAccount(accountId) } + // Why: Codex counterpart of addClaudeAccountFromConfigDir — register a managed + // Codex account from a CODEX_HOME the caller already logged into, so headless + // hosts can add accounts via `orca account add --agent codex`. + addCodexAccountFromHome( + sourceHome: string, + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } + ): Promise { + return this.requireAccountServices().codexAccounts.addAccountFromHome(sourceHome, target) + } + // Why: rate-limit polling fires every 5 minutes and on account switch. // Mobile clients subscribe to receive a fresh AccountsSnapshot whenever // RateLimitService pushes new usage data, mirroring the existing @@ -12752,6 +12845,7 @@ export class OrcaRuntimeService { this.agentDetector?.onExit(ptyId) if (pty) { pty.connected = false + pty.runtimeSessionOwned = false pty.disconnectedAt = Date.now() pty.lastExitCode = exitCode this.resolvePtyExitWaiters(pty, ptyId) @@ -24139,6 +24233,9 @@ export class OrcaRuntimeService { }) const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { + if (launchOpts.persistHostSessionBinding) { + pty.runtimeSessionOwned = true + } if (launchOpts.title) { const observedAt = this.nextTitleObservationSequence() pty.title = launchOpts.title @@ -25047,6 +25144,10 @@ export class OrcaRuntimeService { return null } const existing = this.findMobileTerminalSurface(worktreeId, tabId) + const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) + if (pty) { + pty.runtimeSessionOwned = true + } if ( existing && this.isReadyMobileTerminalSurface(existing) && @@ -25055,7 +25156,6 @@ export class OrcaRuntimeService { // Why: the renderer's ready publication already landed with the intended mode; only a pending shell needs the main-side rescue. return existing } - const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined if (!pty || !leafId) { return existing @@ -25500,6 +25600,7 @@ export class OrcaRuntimeService { if (createdPty) { createdPty.tabId = parentTabId createdPty.paneKey = paneKey + createdPty.runtimeSessionOwned = pty.runtimeSessionOwned } try { @@ -27374,6 +27475,7 @@ export class OrcaRuntimeService { | 'paneKey' | 'title' | 'connectionId' + | 'runtimeSessionOwned' | 'isWsl' | 'wslDistro' | 'incarnationId' @@ -27398,6 +27500,7 @@ export class OrcaRuntimeService { incarnationId: state.incarnationId ?? null, worktreeId, connectionId, + runtimeSessionOwned: state.runtimeSessionOwned ?? false, isWsl: state.isWsl ?? null, wslDistro, tabId: state.tabId ?? null, @@ -27455,6 +27558,9 @@ export class OrcaRuntimeService { this.wslDistroByPtyId.delete(ptyId) } } + if (state.runtimeSessionOwned !== undefined) { + pty.runtimeSessionOwned = state.runtimeSessionOwned + } if (state.isWsl !== undefined) { pty.isWsl = state.isWsl } @@ -28249,6 +28355,7 @@ export class OrcaRuntimeService { // are runtime-owned and preservable. return ( this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || + this.hasLiveRuntimeSessionOwnedPtyBinding(snapshot.worktree, tab) || this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(snapshot.worktree, tab) ) } diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index a88e4a8b073..dc09f93fc22 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { OrcaRuntimeService } from '../../orca-runtime' import { isStreamingMethod } from '../core' import { ACCOUNT_METHODS } from './accounts' @@ -12,6 +14,63 @@ function method(name: string) { } describe('account RPC methods', () => { + it.each([ + { + methodName: 'accounts.addClaudeFromConfigDir', + params: { + configDir: join(tmpdir(), 'claude-login'), + previousLegacyCredentialsSha256: 'a'.repeat(64) + }, + runtimeMethod: 'addClaudeAccountFromConfigDir', + expectedSource: join(tmpdir(), 'claude-login'), + expectedOptions: { + runtime: undefined, + wslDistro: null, + previousLegacyCredentialsSha256: 'a'.repeat(64) + } + }, + { + methodName: 'accounts.addCodexFromHome', + params: { sourceHome: join(tmpdir(), 'codex-login') }, + runtimeMethod: 'addCodexAccountFromHome', + expectedSource: join(tmpdir(), 'codex-login'), + expectedOptions: { runtime: undefined, wslDistro: null } + } + ])('allows local-socket $methodName calls', async (testCase) => { + const add = vi.fn().mockResolvedValue({ accounts: [] }) + const runtime = { [testCase.runtimeMethod]: add } as unknown as OrcaRuntimeService + const addMethod = method(testCase.methodName) + if (isStreamingMethod(addMethod)) { + throw new Error(`${testCase.methodName} must be a request method`) + } + + await addMethod.handler(testCase.params, { runtime }) + + expect(add).toHaveBeenCalledWith(testCase.expectedSource, testCase.expectedOptions) + }) + + it.each([ + ['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }], + ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }] + ])('rejects paired-device calls to %s', async (methodName, params) => { + const runtime = { + addClaudeAccountFromConfigDir: vi.fn(), + addCodexAccountFromHome: vi.fn() + } as unknown as OrcaRuntimeService + const addMethod = method(methodName) + if (isStreamingMethod(addMethod)) { + throw new Error(`${methodName} must be a request method`) + } + + for (const clientKind of ['mobile', 'runtime'] as const) { + await expect(addMethod.handler(params, { runtime, clientKind })).rejects.toThrow( + /only available on the Orca host runtime/ + ) + } + expect(runtime.addClaudeAccountFromConfigDir).not.toHaveBeenCalled() + expect(runtime.addCodexAccountFromHome).not.toHaveBeenCalled() + }) + it('keeps explicit account-list refreshes on the forced refresh lane', async () => { const snapshot = { claude: null, codex: null } const runtime = { @@ -23,10 +82,28 @@ describe('account RPC methods', () => { throw new Error('accounts.list must be a request method') } - await expect(list.handler(undefined, { runtime })).resolves.toBe(snapshot) + // Why: clients that send no params (mobile, web) must keep the forced lane. + await expect(list.handler(list.params?.parse({}), { runtime })).resolves.toBe(snapshot) expect(runtime.refreshAccountsForMobile).toHaveBeenCalledOnce() }) + it('skips the forced provider refresh when the caller opts out', async () => { + const snapshot = { claude: null, codex: null } + const runtime = { + refreshAccountsForMobile: vi.fn().mockResolvedValue(undefined), + getAccountsSnapshot: vi.fn(() => snapshot) + } as unknown as OrcaRuntimeService + const list = method('accounts.list') + if (isStreamingMethod(list)) { + throw new Error('accounts.list must be a request method') + } + + await expect( + list.handler(list.params?.parse({ refreshUsage: false }), { runtime }) + ).resolves.toBe(snapshot) + expect(runtime.refreshAccountsForMobile).not.toHaveBeenCalled() + }) + it('forwards a client idempotency key when consuming a Codex reset credit', async () => { const idempotencyKey = '11111111-1111-4111-8111-111111111111' const expectedScope = { diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 89dab471a75..41d82965d00 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -56,6 +56,30 @@ const ConsumeCodexResetCreditParams = z }) .strict() +const AddClaudeFromConfigDirParams = z.object({ + configDir: z.string().min(1, 'Missing configDir'), + runtime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullish(), + previousLegacyCredentialsSha256: z + .string() + .regex(/^[a-f0-9]{64}$/, 'Invalid legacy credential digest') + .nullable() + .optional() +}) + +const AddCodexFromHomeParams = z.object({ + sourceHome: z.string().min(1, 'Missing sourceHome'), + runtime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullish() +}) + +// Why: `orca account list` prints only emails and the active ids, so it opts out +// of the forced all-provider usage refresh below — that lane bypasses the poll +// throttle and Retry-After gate and costs one serial round-trip per account. +const ListAccountsParams = z.object({ + refreshUsage: z.boolean().default(true) +}) + const AccountsUnsubscribeParams = z.object({ subscriptionId: z .unknown() @@ -64,20 +88,25 @@ const AccountsUnsubscribeParams = z.object({ }) // Why: bridges the desktop ClaudeAccountService / CodexAccountService / -// RateLimitService into the mobile WebSocket RPC. Read + switch + remove -// only — interactive add/re-auth flows spawn `claude login` / `codex login` -// PTYs that need a desktop browser, so they intentionally remain -// desktop-only. See plan in spec doc for issue #1438. +// RateLimitService into the WebSocket / local-socket RPC. Read + switch + +// remove for all clients; interactive add/re-auth flows spawn `claude login` +// / `codex login` PTYs that need a desktop browser, so they intentionally +// remain desktop-only. `accounts.addClaudeFromConfigDir` is the exception: it +// captures an already-authenticated CLAUDE_CONFIG_DIR (no PTY) so the local +// `orca account add` CLI can register accounts on a headless host; it is gated +// to the local runtime connection, never a mobile device token. See #1438. export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ defineMethod({ name: 'accounts.list', - params: null, - handler: async (_params, { runtime }) => { + params: ListAccountsParams, + handler: async (params, { runtime }) => { // Why: ensure the snapshot reflects the latest provider state before // returning. Desktop polling pauses when the window is unfocused and // inactive-account caches only fill on AccountsPane open, so without // this the mobile UI would render stale nulls / zeroes. - await runtime.refreshAccountsForMobile() + if (params.refreshUsage) { + await runtime.refreshAccountsForMobile() + } return runtime.getAccountsSnapshot() } }), @@ -115,6 +144,35 @@ export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ params: RemoveAccountParams, handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId) }), + defineMethod({ + name: 'accounts.addClaudeFromConfigDir', + params: AddClaudeFromConfigDirParams, + handler: async (params, { runtime, clientKind }) => { + // Why: capturing a host filesystem path is local-socket-only; paired + // mobile and remote-runtime tokens must never read host credential paths. + if (clientKind !== undefined) { + throw new Error('Adding Claude accounts is only available on the Orca host runtime.') + } + return runtime.addClaudeAccountFromConfigDir(params.configDir, { + runtime: params.runtime, + wslDistro: params.wslDistro ?? null, + previousLegacyCredentialsSha256: params.previousLegacyCredentialsSha256 + }) + } + }), + defineMethod({ + name: 'accounts.addCodexFromHome', + params: AddCodexFromHomeParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Adding Codex accounts is only available on the Orca host runtime.') + } + return runtime.addCodexAccountFromHome(params.sourceHome, { + runtime: params.runtime, + wslDistro: params.wslDistro ?? null + }) + } + }), // Why: streaming counterpart so mobile usage bars refresh in place when the // desktop's 5-minute rate-limit poll completes or when the user switches // accounts on either side. Mirrors the notifications.subscribe pattern. diff --git a/src/main/runtime/rpc/methods/client-events.test.ts b/src/main/runtime/rpc/methods/client-events.test.ts new file mode 100644 index 00000000000..bf5026c819e --- /dev/null +++ b/src/main/runtime/rpc/methods/client-events.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { isStreamingMethod, type RpcContext, type RpcStreamingMethod } from '../core' +// Why: importing client-events directly trips its module-init cycle through ipc/ssh; the index resolves it. +import { ALL_RPC_METHODS } from './index' + +const subscribeMethod = ALL_RPC_METHODS.find( + (method) => method.name === 'runtime.clientEvents.subscribe' && isStreamingMethod(method) +) as RpcStreamingMethod + +function makeRuntime(): { + runtime: OrcaRuntimeService + onClientEvent: ReturnType + cleanups: (() => void)[] +} { + const cleanups: (() => void)[] = [] + const onClientEvent = vi.fn( + ( + _listener: (event: RuntimeClientEvent) => void, + _options?: { consumesTerminalSideEffects?: boolean } + ) => + () => {} + ) + const runtime = { + onClientEvent, + registerSubscriptionCleanup: (_id: string, cleanup: () => void) => { + cleanups.push(cleanup) + } + } as unknown as OrcaRuntimeService + return { runtime, onClientEvent, cleanups } +} + +describe('runtime.clientEvents.subscribe', () => { + it('registers mobile subscriptions as non-consumers of terminal side effects', async () => { + const { runtime, onClientEvent, cleanups } = makeRuntime() + + const done = subscribeMethod.handler( + undefined, + { runtime, connectionId: 'conn-1', clientKind: 'mobile' } as RpcContext, + () => {} + ) + + expect(onClientEvent).toHaveBeenCalledWith(expect.any(Function), { + consumesTerminalSideEffects: false + }) + cleanups.forEach((cleanup) => cleanup()) + await done + }) + + it('keeps non-mobile subscriptions consuming terminal side effects', async () => { + const { runtime, onClientEvent, cleanups } = makeRuntime() + + const done = subscribeMethod.handler( + undefined, + { runtime, connectionId: 'conn-1' } as RpcContext, + () => {} + ) + + expect(onClientEvent).toHaveBeenCalledWith(expect.any(Function), { + consumesTerminalSideEffects: true + }) + cleanups.forEach((cleanup) => cleanup()) + await done + }) +}) diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 956594dd5e3..81ee32d1988 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -16,11 +16,16 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ defineStreamingMethod({ name: 'runtime.clientEvents.subscribe', params: null, - handler: async (_params, { runtime, connectionId }, emit) => { + handler: async (_params, { runtime, connectionId, clientKind }, emit) => { await new Promise((resolve) => { - const unsubscribe = runtime.onClientEvent((event) => { - emit(event) - }) + // Why: mobile discards terminalSideEffects; excluding it stops the + // per-OSC batch frames from crossing the relay. + const unsubscribe = runtime.onClientEvent( + (event) => { + emit(event) + }, + { consumesTerminalSideEffects: clientKind !== 'mobile' } + ) const seq = ++clientEventSubscriptionSeq const subscriptionId = `runtime-client-events-${connectionId ?? 'inproc'}-${seq}` diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 21a6fa8f47c..2e38b4848c0 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -250,6 +250,7 @@ const UiUpdateFields = z lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), dismissedUpdateNudgeId: NullableString.optional(), + releaseChannelOverride: z.enum(['stable', 'rc', 'hourly']).nullable().optional(), notificationPermissionRequested: z.boolean().optional(), updateReassuranceSeen: z.boolean().optional(), osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4ca479ea5cc..450437f58be 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -130,6 +130,8 @@ type TerminalMultiplexStream = { sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null ackInFlightBytes: number ackWindowBytes: number + supportsOutputPause: boolean + outputPaused: boolean supportsDesktopViewportClaims: boolean desktopClaimTail: Promise // Whether THIS stream registered the width driver, so detach won't release a peer stream's floor. @@ -1027,7 +1029,8 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ .object({ ackOutput: z.literal(1).optional(), ackOutputSourceRanges: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional() + desktopViewportClaims: z.literal(1).optional(), + outputPause: z.literal(1).optional() }) .optional() }) @@ -1721,7 +1724,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk ): void => { - if (closed || streams.get(stream.streamId) !== stream) { + if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { return } if ( @@ -1738,6 +1741,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if ( closed || streams.get(stream.streamId) !== stream || + stream.outputPaused || stream.ackRecoverySnapshotInFlight ) { return @@ -1746,7 +1750,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null try { const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) - if (closed || streams.get(stream.streamId) !== stream) { + if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { return } if (!serialized) { @@ -1862,6 +1866,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream: TerminalMultiplexStream, maxChunks = Number.POSITIVE_INFINITY ): number => { + if (stream.outputPaused) { + return 0 + } if (stream.ackPendingOutputOverflowed) { void sendAckRecoverySnapshot(stream) return 0 @@ -2112,6 +2119,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) return } + if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) { + const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload) + if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) { + return + } + stream.outputPaused = payload.paused + if (stream.outputPaused) { + stream.outputBatcher.flush() + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + } + return + } if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( frame.payload @@ -2430,6 +2451,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ sourceRangeReplacement: null, ackInFlightBytes: 0, ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, + supportsOutputPause: request.capabilities?.outputPause === 1, + outputPaused: false, supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, desktopClaimTail: Promise.resolve(true), registeredRemoteDesktopDriver: false, @@ -2476,6 +2499,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (closed || streams.get(request.streamId) !== stream) { return } + if (stream.outputPaused) { + return + } if (stream.buffering) { appendPendingMultiplexOutput(stream, data, meta) return @@ -2554,12 +2580,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows: serialized?.rows ?? size?.rows, displayMode, seq: layoutSeq, - ...(stream.ackOutputSourceRanges - ? { - streamGeneration: stream.streamGeneration, - capabilities: { ackOutputSourceRanges: 1 as const } - } - : {}), + ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { + capabilities: { + ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), + ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) + } + }), + ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), // Why: retained-tail truncation loses history, not the authoritative latest-screen fallback. truncated: initialOutputOverflowed }) diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index bbdece031d0..a1ccf86c254 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -11,6 +11,7 @@ type MainOwnedUIState = | 'trayMinimizeNoticeShown' | 'dashboardPopoutBounds' | '_expandedWorktreeCardPropertiesDefaulted' + | '_jiraIssueWorktreeCardPropertyDefaulted' | 'starNagBaselineAgents' | 'starNagAppVersion' | 'starNagNextThreshold' diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 17e31eb58aa..b1584f0c604 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -21,6 +21,8 @@ import { } from '../../../shared/terminal-multiplex-flow-control' import { SshPtyOutputIntake, type SshPtyOutputDataEvent } from '../../ipc/ssh-pty-output-intake' +const SET_OUTPUT_PAUSED_OPCODE = 16 as TerminalStreamOpcode + function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { const serializeAuthoritativeTerminalBuffer = overrides.serializeAuthoritativeTerminalBuffer ?? @@ -1318,6 +1320,170 @@ describe('terminal multiplex RPC', () => { } }) + it('withholds sustained output from multiple paused desktop streams', async () => { + const listeners: ((data: string, meta?: RuntimeTerminalDataMeta) => void)[] = [] + const harness = startDesktopMultiplexSubscribe({ + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + listeners.push(listener) + return vi.fn() + }), + serializeAuthoritativeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'authoritative hidden snapshot', + cols: 120, + rows: 40, + seq: 7, + source: 'headless' + }) + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + + for (const streamId of [1, 2, 3]) { + harness.handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: streamId, + payload: encodeTerminalStreamJson({ + streamId, + terminal: `terminal-${streamId}`, + client: { id: `desktop-${streamId}`, type: 'desktop' }, + capabilities: { ackOutput: 1, outputPause: 1 } + }) + }) + )! + ) + } + await vi.waitFor(() => + expect( + harness.messages.filter((message) => JSON.parse(message).result?.type === 'subscribed') + ).toHaveLength(3) + ) + const pauseCapable = harness.messages + .map((message) => JSON.parse(message).result) + .filter((event) => event?.type === 'subscribed') + .every((event) => event.capabilities?.outputPause === 1) + if (pauseCapable) { + for (const streamId of [1, 2, 3]) { + harness.handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: SET_OUTPUT_PAUSED_OPCODE, + streamId, + seq: 10, + payload: encodeTerminalStreamJson({ paused: true }) + }) + )! + ) + } + } + harness.binaryFrames.splice(0) + const chunk = 'x'.repeat(64 * 1024) + for (let turn = 0; turn < 8; turn += 1) { + for (const listener of listeners) { + listener(chunk, { seq: (turn + 1) * chunk.length, rawLength: chunk.length }) + } + } + expect( + harness.binaryFrames.some( + (bytes) => decodeTerminalStreamFrame(bytes)?.opcode === TerminalStreamOpcode.Output + ) + ).toBe(false) + expect(pauseCapable).toBe(true) + + harness.handlers.get(1)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: SET_OUTPUT_PAUSED_OPCODE, + streamId: 1, + seq: 11, + payload: encodeTerminalStreamJson({ paused: false }) + }) + )! + ) + listeners[0]?.('VISIBLE_MARKER', { seq: 8 * chunk.length + 14, rawLength: 14 }) + listeners[0]?.('y'.repeat(64 * 1024), { + seq: 9 * chunk.length + 14, + rawLength: 64 * 1024 + }) + expect( + harness.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('VISIBLE_MARKER') + + harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.() + await harness.dispatchPromise + expect(harness.handlers.size).toBe(0) + }) + + it('keeps output flowing when an older client does not negotiate pause', async () => { + const listeners: ((data: string, meta?: RuntimeTerminalDataMeta) => void)[] = [] + const harness = startDesktopMultiplexSubscribe({ + subscribeToTerminalData: vi.fn((_ptyId, nextListener) => { + listeners.push(nextListener) + return vi.fn() + }) + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + + harness.handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 1, + terminal: 'terminal-legacy-client', + client: { id: 'desktop-legacy', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect( + harness.messages + .map((message) => JSON.parse(message).result) + .find((event) => event?.type === 'subscribed' && event.streamId === 1) + ).toMatchObject({ type: 'subscribed', streamId: 1 }) + ) + const subscribed = harness.messages + .map((message) => JSON.parse(message).result) + .find((event) => event?.type === 'subscribed' && event.streamId === 1) + expect(subscribed.capabilities?.outputPause).toBeUndefined() + + harness.handlers.get(1)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: SET_OUTPUT_PAUSED_OPCODE, + streamId: 1, + seq: 2, + payload: encodeTerminalStreamJson({ paused: true }) + }) + )! + ) + harness.binaryFrames.splice(0) + listeners[0]?.('LEGACY_VISIBLE'.padEnd(64 * 1024, 'x'), { + seq: 64 * 1024, + rawLength: 64 * 1024 + }) + + expect( + harness.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('LEGACY_VISIBLE') + + harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.() + await harness.dispatchPromise + }) + it('applies a viewer resize parked during a snapshot-request buffering window', async () => { vi.useFakeTimers() try { diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 55f33f9d085..eac1820a047 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -633,6 +633,7 @@ describe('killAllProcessesForWorktree', () => { // DaemonClient method so the shared connect+RPC budget path is exercised. ensureConnectedWithin: async () => {}, isConnected: () => true, + getDaemonIdentity: () => null, disconnect: () => {}, notify: () => {}, request: ( diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts index 3aeca4bdd59..2b20055e646 100644 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -248,6 +248,32 @@ describe('read-only skill freshness inventory', () => { expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') }) + it('reads up to date after the OS drops a sidecar into an untouched install', async () => { + const test = await fixture() + const directory = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.currentMarkdown + ) + // What one Finder visit leaves behind. Sorts before SKILL.md, which is what made the + // index-aligned snapshot comparison miss and report the copy as modified. + await writeFile(join(directory, '.DS_Store'), Buffer.from([0, 1, 2, 3])) + // No lock trust available here: the recorded hash is a different tree, so this proves + // the identity fix alone carries it rather than falling through to newer-known. + await writeSkillLockHash(test.homeDir, 'a'.repeat(40)) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual(['current']) + // The whole point: no amber, and nothing offered to "fix" a copy that is already right. + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + it('still flags canonical bytes that do not match what the lock says was installed', async () => { const test = await fixture() const editedMarkdown = `${test.currentMarkdown}\nLocal edit the updater never wrote.\n` diff --git a/src/main/skills/skill-package-identity.test.ts b/src/main/skills/skill-package-identity.test.ts index 648b628e82c..ca9beb9fd3d 100644 --- a/src/main/skills/skill-package-identity.test.ts +++ b/src/main/skills/skill-package-identity.test.ts @@ -80,6 +80,85 @@ describe('skill package identity', () => { ).rejects.toThrow('skill-package-entry-limit') }) + it('ignores OS-authored sidecars so a browsed folder still matches its snapshot', async () => { + const pristine = await temporarySkill() + await writeFile(join(pristine, 'SKILL.md'), 'skill\n') + const official = await observeSkillPackage(pristine) + + const browsed = await temporarySkill() + await writeFile(join(browsed, 'SKILL.md'), 'skill\n') + // Every name the OS writes on its own, including one that sorts BEFORE SKILL.md — + // the index-aligned comparison in matchingKnownSnapshot misaligns on a leading entry, + // so a trailing-name-only fixture would pass while the reported bug survived. + await writeFile(join(browsed, '.DS_Store'), Buffer.from([0, 1, 2, 3])) + await writeFile(join(browsed, '._SKILL.md'), Buffer.from([0, 5])) + await writeFile(join(browsed, 'Thumbs.db'), Buffer.from([9])) + await writeFile(join(browsed, 'desktop.ini'), '[.ShellClassInfo]\n') + + const observed = await observeSkillPackage(browsed) + + expect(observed.files.map((file) => file.path)).toEqual(['SKILL.md']) + // The lock-trust path compares this against the updater's recorded source tree, so it + // has to come out clean too, not just the digest. + expect(observed.observedGitTreeSha).toBe(official.observedGitTreeSha) + expect( + matchingKnownSnapshot(observed, [ + { + releaseRevision: 1, + packageDigest: official.observedDigest, + gitTreeSha: official.observedGitTreeSha, + files: official.files + } + ])?.releaseRevision + ).toBe(1) + }) + + it('keeps guarding a directory or link that only wears an OS metadata name', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'skill\n') + // The OS writes these names as plain files only, so a subtree behind one is real content: + // dropping it on the name alone would hide it from identity and read as pristine. + await mkdir(join(root, '._scripts')) + await writeFile(join(root, '._scripts', 'payload.sh'), '#!/bin/sh\n') + + expect((await observeSkillPackage(root)).files.map((file) => file.path)).toEqual([ + '._scripts/payload.sh', + 'SKILL.md' + ]) + + if (process.platform !== 'win32') { + await rm(join(root, '._scripts'), { recursive: true }) + await symlink(join(root, 'SKILL.md'), join(root, '._DS_Store')) + await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-link') + } + }) + + it('still reports a genuinely modified skill as unmatched', async () => { + const pristine = await temporarySkill() + await writeFile(join(pristine, 'SKILL.md'), 'skill\n') + const official = await observeSkillPackage(pristine) + + const edited = await temporarySkill() + await writeFile(join(edited, 'SKILL.md'), 'skill\nlocal tweak\n') + await writeFile(join(edited, '.DS_Store'), Buffer.from([0, 1])) + // An unexpected file that is NOT OS metadata must keep failing closed; tolerating + // extras in general would let an injected payload ride along beside a clean SKILL.md. + const withPayload = await temporarySkill() + await writeFile(join(withPayload, 'SKILL.md'), 'skill\n') + await writeFile(join(withPayload, 'payload.sh'), '#!/bin/sh\n') + + const snapshot = [ + { + releaseRevision: 1, + packageDigest: official.observedDigest, + gitTreeSha: official.observedGitTreeSha, + files: official.files + } + ] + expect(matchingKnownSnapshot(await observeSkillPackage(edited), snapshot)).toBeNull() + expect(matchingKnownSnapshot(await observeSkillPackage(withPayload), snapshot)).toBeNull() + }) + it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => { const root = await temporarySkill() await mkdir(join(root, 'scripts')) diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index d518bd05d43..4c883a2d6e6 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -18,6 +18,27 @@ export type ObservedSkillPackage = { observedGitTreeSha: string } +// Why: package identity compares a live user directory against a tree the generator read +// from a clean checkout, so anything the OS deposits on its own counts as drift the user +// never caused. One Finder visit writes .DS_Store, and that alone made the copy +// 'unrecognized' — reported as "may be modified", left out of the update, and unfixable by +// running it, since the updater compares its lock to the source and never reads disk. +// +// Only OS-authored names belong here. Tolerating unexpected files in general would let a +// modified skill pass: the entry is safe precisely because an official SKILL.md never +// references these, so no agent can be routed into one. Mirrored in +// config/scripts/generate-skill-bundle-manifest.mjs so neither side of the comparison can +// bake one in. Deliberately NOT extended to mode bits — that would weaken identity for +// real scripts. +const OS_METADATA_FILE_NAMES = new Set(['.ds_store', 'thumbs.db', 'ehthumbs.db', 'desktop.ini']) + +export function isOsMetadataSkillEntryName(name: string): boolean { + const folded = name.toLocaleLowerCase('en-US') + // AppleDouble sidecars ('._SKILL.md') appear whenever a skill is copied through a + // filesystem that cannot hold macOS metadata inline. + return OS_METADATA_FILE_NAMES.has(folded) || folded.startsWith('._') +} + export const SKILL_PACKAGE_OBSERVATION_LIMITS = { maximumDepth: 16, maximumEntries: 2_048, @@ -171,6 +192,17 @@ export async function observeSkillPackage( entries.sort((left, right) => compareCodeUnits(left.name, right.name)) for (const entry of entries) { const absolutePath = join(directory, entry.name) + // Only a plain file is OS-authored, so the type decides and not the name alone: a + // directory or link wearing the name would otherwise hide a subtree from identity and + // slip past the link and special-file guards below. Decided before the case-fold map + // so two spellings of one sidecar cannot collide, and tolerant of a vanished entry so + // an unreadable sidecar cannot fail the whole package. + if (isOsMetadataSkillEntryName(entry.name)) { + const sidecarStat = await lstat(absolutePath).catch(() => null) + if (!sidecarStat || sidecarStat.isFile()) { + continue + } + } const relativePath = relative(packageRoot, absolutePath) if ( isAbsolute(relativePath) || diff --git a/src/main/ssh/ssh-auth-resolution.ts b/src/main/ssh/ssh-auth-resolution.ts index a222ff2ee2d..025c8904870 100644 --- a/src/main/ssh/ssh-auth-resolution.ts +++ b/src/main/ssh/ssh-auth-resolution.ts @@ -4,6 +4,7 @@ import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { createIdentityFilteredAgent } from './ssh-agent-identity-filter' import { resolveSshConfigHomePath } from './ssh-config-path-expansion' +import { isOpenSshConfigBackedTarget } from './system-ssh-args' // Why: ssh2 only tries keys that are explicitly provided. Users with keys in // standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would @@ -62,12 +63,12 @@ function resolveDefaultAgentSocket(): string | undefined { } export function resolveAgentSocket( - target: Pick, + target: Pick, resolved: Pick | null ): string | undefined { // Why: imported config-host targets may contain raw OpenSSH tokens like %d. // ssh -G resolves those tokens, so its value must win when available. - const configuredIdentityAgent = target.configHost + const configuredIdentityAgent = isOpenSshConfigBackedTarget(target) ? (resolved?.identityAgent ?? target.identityAgent) : (target.identityAgent ?? resolved?.identityAgent) if (configuredIdentityAgent != null) { @@ -80,17 +81,30 @@ export function resolveAgentSocket( return resolveDefaultAgentSocket() } -function resolveExplicitPrivateKeyPath( +function resolveExplicitPrivateKeyPaths( target: SshTarget, resolved: SshResolvedConfig | null -): string | undefined { - const resolvedIdentity = resolved?.identityFile?.[0] - return ( - target.identityFile || - (resolvedIdentity && !EXPANDED_DEFAULT_KEY_PATHS.includes(resolvedIdentity) - ? resolvedIdentity - : undefined) +): string[] { + const resolvedIdentities = (resolved?.identityFile ?? []).filter( + (identityFile) => !EXPANDED_DEFAULT_KEY_PATHS.includes(identityFile) ) + if (isOpenSshConfigBackedTarget(target) && resolved) { + return resolvedIdentities + } + if (target.identityFile) { + return [target.identityFile] + } + return resolvedIdentities +} + +function resolvePrivateKeyPaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] { + if (isOpenSshConfigBackedTarget(target) && resolved) { + return resolved.identityFile + } + if (target.identityFile) { + return [target.identityFile] + } + return resolved?.identityFile ?? [] } function readPrivateKey(keyPath: string): PrivateKeyFile | undefined { @@ -102,25 +116,34 @@ function readPrivateKey(keyPath: string): PrivateKeyFile | undefined { } } -function resolveExplicitPrivateKey( - target: SshTarget, - resolved: SshResolvedConfig | null -): PrivateKeyFile | undefined { - const explicitKey = resolveExplicitPrivateKeyPath(target, resolved) - if (explicitKey) { - return readPrivateKey(explicitKey) +function readPrivateKeys(keyPaths: string[]): PrivateKeyFile[] { + const keys: PrivateKeyFile[] = [] + for (const keyPath of keyPaths) { + const key = readPrivateKey(keyPath) + if (key) { + keys.push(key) + } } - return undefined + return keys } -export function resolvePrivateKey( +function resolveExplicitPrivateKeys( target: SshTarget, resolved: SshResolvedConfig | null -): PrivateKeyFile | undefined { - if (resolveExplicitPrivateKeyPath(target, resolved)) { - return resolveExplicitPrivateKey(target, resolved) +): PrivateKeyFile[] { + return readPrivateKeys(resolveExplicitPrivateKeyPaths(target, resolved)) +} + +export function resolvePrivateKeys( + target: SshTarget, + resolved: SshResolvedConfig | null +): PrivateKeyFile[] { + const keyPaths = resolvePrivateKeyPaths(target, resolved) + if (keyPaths.length > 0 || resolved || target.identityFile) { + return readPrivateKeys(keyPaths) } - return findDefaultKeyFile() + const defaultKey = findDefaultKeyFile() + return defaultKey ? [defaultKey] : [] } function isUnencryptedPrivateKey(contents: Buffer): boolean { @@ -132,19 +155,27 @@ function isUnencryptedPrivateKey(contents: Buffer): boolean { return keys.some((key) => key && typeof key.isPrivateKey === 'function' && key.isPrivateKey()) } -export function resolveUnencryptedExplicitPrivateKey( +export function resolveUnencryptedExplicitPrivateKeys( target: SshTarget, resolved: SshResolvedConfig | null -): PrivateKeyFile | undefined { - const key = resolveExplicitPrivateKey(target, resolved) - if (!key) { - return undefined +): PrivateKeyFile[] { + return resolveExplicitPrivateKeys(target, resolved).filter((key) => + isUnencryptedPrivateKey(key.contents) + ) +} + +export function findEncryptedPrivateKeyPath(keys: PrivateKeyFile[]): string | undefined { + for (const key of keys) { + const parsed = utils.parseKey(key.contents) as ParsedKey | ParsedKey[] | Error + if (parsed instanceof Error && /passphrase|encrypted key|bad decrypt/i.test(parsed.message)) { + return key.path + } } - return isUnencryptedPrivateKey(key.contents) ? key : undefined + return undefined } function resolveIdentityFilePaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] { - if (target.configHost && resolved?.identityFile?.length) { + if (isOpenSshConfigBackedTarget(target) && resolved) { return resolved.identityFile } if (target.identityFile) { diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index a9eda1f0248..e48b77baab9 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -197,6 +197,51 @@ Host disabled expect(hosts[1].gssapiAuthentication).toBe(false) }) + it('keeps the first value for repeated single-valued options like OpenSSH', () => { + const hosts = parseSshConfig(` +Host repeated + HostName first.example.com + HostName second.example.com + User first-user + User second-user + Port 2201 + Port 2202 + IdentityAgent ~/.ssh/first-agent.sock + IdentityAgent ~/.ssh/second-agent.sock + IdentitiesOnly yes + IdentitiesOnly no + ProxyCommand ssh -W %h:%p first-bastion + ProxyCommand ssh -W %h:%p second-bastion + ProxyUseFdpass yes + ProxyUseFdpass no + ProxyJump first-jump + ProxyJump second-jump + +Host repeated-disabled + IdentitiesOnly no + IdentitiesOnly yes + ProxyUseFdpass no + ProxyUseFdpass yes +`) + + expect(hosts[0]).toEqual({ + host: 'repeated', + hostname: 'first.example.com', + user: 'first-user', + port: 2201, + identityAgent: testHomePath('.ssh', 'first-agent.sock'), + identitiesOnly: true, + proxyCommand: 'ssh -W %h:%p first-bastion', + proxyUseFdpass: true, + proxyJump: 'first-jump' + }) + expect(hosts[1]).toEqual({ + host: 'repeated-disabled', + identitiesOnly: false, + proxyUseFdpass: false + }) + }) + it('parses ProxyCommand, ProxyUseFdpass, and ProxyJump', () => { const config = ` Host internal diff --git a/src/main/ssh/ssh-config-parser.ts b/src/main/ssh/ssh-config-parser.ts index 92cec9dac0e..1ac6d8ed4bf 100644 --- a/src/main/ssh/ssh-config-parser.ts +++ b/src/main/ssh/ssh-config-parser.ts @@ -74,20 +74,21 @@ export function parseSshConfig(content: string): SshConfigHost[] { const value = parseScalarConfigValue(rawValue) + // Why: OpenSSH uses the first obtained value for single-valued parameters. switch (key) { case 'hostname': for (const host of current) { - host.hostname = value + host.hostname ??= value } break case 'port': for (const host of current) { - host.port = Number.parseInt(value, 10) || 22 + host.port ??= Number.parseInt(value, 10) || 22 } break case 'user': for (const host of current) { - host.user = value + host.user ??= value } break case 'identityfile': @@ -97,17 +98,16 @@ export function parseSshConfig(content: string): SshConfigHost[] { break case 'identityagent': for (const host of current) { - host.identityAgent = resolveSshConfigHomePath(value) + host.identityAgent ??= resolveSshConfigHomePath(value) } break case 'identitiesonly': for (const host of current) { - host.identitiesOnly = value.toLowerCase() === 'yes' + host.identitiesOnly ??= value.toLowerCase() === 'yes' } break case 'gssapiauthentication': for (const host of current) { - // Why: OpenSSH uses the first obtained value for each parameter. host.gssapiAuthentication ??= value.toLowerCase() === 'yes' } break @@ -115,17 +115,17 @@ export function parseSshConfig(content: string): SshConfigHost[] { for (const host of current) { // Why: OpenSSH treats ProxyCommand as a shell snippet and preserves // the rest of the line, including quotes and `#` characters. - host.proxyCommand = rawValue.trim() + host.proxyCommand ??= rawValue.trim() } break case 'proxyusefdpass': for (const host of current) { - host.proxyUseFdpass = value.toLowerCase() === 'yes' + host.proxyUseFdpass ??= value.toLowerCase() === 'yes' } break case 'proxyjump': for (const host of current) { - host.proxyJump = value + host.proxyJump ??= value } break } diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 50f2eb66ff2..a5dd743fd43 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -438,6 +438,44 @@ describe('buildConnectConfig', () => { expect(config.port).toBe(2022) }) + it('uses fresh OpenSSH endpoint authority for imported config targets', () => { + const config = buildConnectConfig( + makeTarget({ + source: 'ssh-config', + configHost: 'workbox', + host: 'stale.example.com', + port: 2022, + username: 'stale-user' + }), + makeResolved({ + hostname: 'current.example.com', + port: 2202, + user: 'current-user' + }) + ) + + expect(config.host).toBe('current.example.com') + expect(config.port).toBe(2202) + expect(config.username).toBe('current-user') + }) + + it('keeps imported endpoint fields as the fallback when ssh -G is unavailable', () => { + const config = buildConnectConfig( + makeTarget({ + source: 'ssh-config', + configHost: 'workbox', + host: 'fallback.example.com', + port: 2022, + username: 'fallback-user' + }), + null + ) + + expect(config.host).toBe('fallback.example.com') + expect(config.port).toBe(2022) + expect(config.username).toBe('fallback-user') + }) + it('sets readyTimeout to CONNECT_TIMEOUT_MS', () => { const config = buildConnectConfig(makeTarget(), null) expect(config.readyTimeout).toBe(30_000) @@ -581,6 +619,22 @@ describe('buildConnectConfig', () => { } }) + it('uses fresh OpenSSH IdentityFile authority for imported config targets', () => { + mockReadFileSync.mockImplementation((path: unknown) => Buffer.from(String(path))) + const config = buildConnectConfig( + makeTarget({ + source: 'ssh-config', + configHost: 'workbox', + identityFile: '/home/user/.ssh/stale' + }), + makeResolved({ identityFile: ['/home/user/.ssh/current'] }), + { includeAgent: false, includePrivateKey: true } + ) + + expect(config.privateKey).toEqual(Buffer.from('/home/user/.ssh/current')) + expect(mockReadFileSync).toHaveBeenCalledWith('/home/user/.ssh/current') + }) + it('expands Windows-style target.identityFile before reading private key', () => { mockReadFileSync.mockReturnValue(Buffer.from('key')) const config = buildConnectConfig(makeTarget({ identityFile: '~\\.ssh\\custom' }), null, { @@ -664,6 +718,23 @@ describe('resolveEffectiveProxy', () => { }) }) + it('uses fresh OpenSSH proxy authority for imported config targets', () => { + const target = { + ...makeTarget(), + source: 'ssh-config' as const, + configHost: 'workbox', + proxyCommand: 'ssh -W %h:%p stale-bastion' + } + + expect(resolveEffectiveProxy(target, makeResolved())).toBeUndefined() + expect( + resolveEffectiveProxy(target, makeResolved({ proxyCommand: 'ssh -W %h:%p current-bastion' })) + ).toEqual({ + kind: 'proxy-command', + command: 'ssh -W %h:%p current-bastion' + }) + }) + it('falls back to resolved proxyCommand', () => { expect( resolveEffectiveProxy(makeTarget(), makeResolved({ proxyCommand: 'ssh -W %h:%p gw' })) diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index de9e0b6c63f..bce298039be 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -5,11 +5,14 @@ import type { ConnectConfig } from 'ssh2' import type { SshTarget, SshConnectionState } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { + findEncryptedPrivateKeyPath, resolveAgentConfigValue, resolveAgentSocket, - resolvePrivateKey, - resolveUnencryptedExplicitPrivateKey + resolvePrivateKeys, + resolveUnencryptedExplicitPrivateKeys } from './ssh-auth-resolution' +import { configurePrivateKeyAuthentication } from './ssh-private-key-authentication' +import { isOpenSshConfigBackedTarget } from './system-ssh-args' export { findDefaultKeyFile, resolveAgentSocket } from './ssh-auth-resolution' @@ -180,7 +183,10 @@ export function buildConnectConfig( ): ConnectConfig { const effectiveHost = resolveEffectiveHost(target, resolved) const effectivePort = resolveEffectivePort(target, resolved) - const effectiveUser = target.username || resolved?.user || '' + const effectiveUser = + isOpenSshConfigBackedTarget(target) && resolved + ? (resolved.user ?? target.username) + : target.username || resolved?.user || '' const config: Record = { host: effectiveHost, @@ -202,18 +208,23 @@ export function buildConnectConfig( config.agentForward = true } - const key = + const keys = (options.includePrivateKey ?? !agent) - ? resolvePrivateKey(target, resolved) - : resolveUnencryptedExplicitPrivateKey(target, resolved) - if (key) { - config.privateKey = key.contents - } + ? resolvePrivateKeys(target, resolved) + : resolveUnencryptedExplicitPrivateKeys(target, resolved) + configurePrivateKeyAuthentication( + config as ConnectConfig, + keys, + findEncryptedPrivateKeyPath(keys) + ) return config as ConnectConfig } function resolveEffectiveHost(target: SshTarget, resolved: SshResolvedConfig | null): string { + if (isOpenSshConfigBackedTarget(target) && resolved?.hostname) { + return resolved.hostname + } if (shouldUseResolvedEndpoint(target, resolved)) { return resolved!.hostname } @@ -221,6 +232,9 @@ function resolveEffectiveHost(target: SshTarget, resolved: SshResolvedConfig | n } function resolveEffectivePort(target: SshTarget, resolved: SshResolvedConfig | null): number { + if (isOpenSshConfigBackedTarget(target) && resolved) { + return resolved.port || target.port || 22 + } // Why: imported config aliases store 22 as the schema default even when an // included/wildcard OpenSSH rule later resolves a different effective Port. if (target.configHost && target.port === 22 && resolved?.port) { @@ -249,6 +263,12 @@ export function resolveEffectiveProxy( target: SshTarget, resolved: SshResolvedConfig | null ): EffectiveProxy | undefined { + if (isOpenSshConfigBackedTarget(target) && resolved) { + if (resolved.proxyCommand) { + return { kind: 'proxy-command', command: resolved.proxyCommand } + } + return resolved.proxyJump ? { kind: 'jump-host', jumpHost: resolved.proxyJump } : undefined + } if (target.proxyCommand) { return { kind: 'proxy-command', command: target.proxyCommand } } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 94c3f31838e..b73cc1e1db3 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -1165,6 +1165,29 @@ describe('SshConnection', () => { expect(conn.usesSystemSshTransport()).toBe(true) }) + it('uses fresh OpenSSH user for imported GitHub targets', async () => { + vi.mocked(resolveWithSshG).mockResolvedValueOnce( + createResolvedConfig({ hostname: 'github.com', user: 'git' }) + ) + spawnSystemSshCommandMock.mockImplementation(() => + createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') + ) + const conn = new SshConnection( + createTarget({ + source: 'ssh-config', + configHost: 'github.com', + host: 'github.com', + username: 'stale-user' + }), + createCallbacks() + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + }) + it('accepts GitHub restricted-shell SSH probes with resolved host and target username', async () => { vi.mocked(resolveWithSshG).mockResolvedValueOnce( createResolvedConfig({ hostname: 'github.com', user: undefined }) @@ -1530,6 +1553,26 @@ describe('SshConnection', () => { ) }) + it('ignores stale imported GSSAPI when fresh OpenSSH config disables it', async () => { + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: false }) + ) + const conn = new SshConnection( + createTarget({ + source: 'ssh-config', + configHost: 'krb-host', + gssapiAuthentication: true + }), + createCallbacks() + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(false) + expect(spawnSystemSshCommandMock).not.toHaveBeenCalled() + }) + it('falls back to system SSH after an ssh2 auth failure when resolved config enables GSSAPI', async () => { connectBehavior = 'error' connectErrorMessage = 'All configured authentication methods failed' @@ -2086,6 +2129,22 @@ describe('shouldUseSystemSshTransport', () => { it('allows an environment override for e2e coverage', () => { vi.stubEnv('ORCA_SSH_FORCE_SYSTEM_TRANSPORT', '1') expect(shouldUseSystemSshTransport(createTarget(), null)).toBe(true) + vi.unstubAllEnvs() + }) + + it('ignores stale imported proxy fields when fresh OpenSSH config has no proxy', () => { + expect( + shouldUseSystemSshTransport( + createTarget({ + source: 'ssh-config', + configHost: 'workbox', + proxyCommand: 'ssh -W %h:%p stale-bastion' + }), + { + proxyUseFdpass: false + } + ) + ).toBe(false) }) }) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 224f888eadb..c1b73bbbb2d 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -19,6 +19,7 @@ import { } from './ssh-system-fallback' import { resolveWithSshG, type SshResolvedConfig } from './ssh-config-parser' import { removeControlSocketPath } from './ssh-control-socket' +import { isOpenSshConfigBackedTarget } from './system-ssh-args' import { INITIAL_RETRY_ATTEMPTS, INITIAL_RETRY_DELAY_MS, @@ -39,6 +40,7 @@ import { type SshExecOptions, type SshConnectionCallbacks } from './ssh-connection-utils' +import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' import type { RemoteHostPlatform } from './ssh-remote-platform' import { resolveSftpTransferPathIfMapped, @@ -82,7 +84,11 @@ function isGitHubRestrictedShellProbeSuccess( return false } - const effectiveUser = (target.username?.trim() || resolvedConfig?.user?.trim())?.toLowerCase() + const effectiveUser = ( + isOpenSshConfigBackedTarget(target) && resolvedConfig + ? resolvedConfig.user?.trim() || target.username?.trim() + : target.username?.trim() || resolvedConfig?.user?.trim() + )?.toLowerCase() if (effectiveUser !== 'git') { return false } @@ -631,7 +637,11 @@ export class SshConnection { return } // Why: ssh2 lacks gssapi-with-mic; GSSAPIAuthentication hosts try Kerberos SSO via system OpenSSH first, then fall through to key/credential auth. - if (this.target.gssapiAuthentication === true) { + if ( + isOpenSshConfigBackedTarget(this.target) && resolved + ? resolved.gssapiAuthentication === true + : this.target.gssapiAuthentication === true + ) { try { await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true) return @@ -719,14 +729,19 @@ export class SshConnection { throw keyErr } authError = keyErr + const passphraseKeyPath = getPassphrasePrivateKeyPath(keyConfig) // Why: with GSSAPI enabled, let the reactive system-ssh probe try a Kerberos ticket before prompting for the passphrase; the prompt still runs if it fails. if ( - isPassphraseError(authError) && + (isPassphraseError(authError) || passphraseKeyPath) && !this.cachedPassphrase && !isGssapiSystemSshFallbackCandidate(authError, this.target, resolved) ) { passphrasePromptHandled = true - const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)' + const detail = + passphraseKeyPath || + this.target.identityFile || + resolved?.identityFile?.[0] || + '(unknown)' const val = await this.callbacks.onCredentialRequest?.( this.target.id, 'passphrase', @@ -770,8 +785,17 @@ export class SshConnection { } // Why: prompt for passphrase on encrypted-key error, then retry with a fresh proxy socket (ssh2 may have destroyed the original). - if (isPassphraseError(authError) && !this.cachedPassphrase && !passphrasePromptHandled) { - const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)' + const passphraseKeyPath = getPassphrasePrivateKeyPath(credentialRetryConfig) + if ( + (isPassphraseError(authError) || passphraseKeyPath) && + !this.cachedPassphrase && + !passphrasePromptHandled + ) { + const detail = + passphraseKeyPath || + this.target.identityFile || + resolved?.identityFile?.[0] || + '(unknown)' const val = await this.callbacks.onCredentialRequest(this.target.id, 'passphrase', detail) if (val) { this.cachedPassphrase = val @@ -1362,6 +1386,14 @@ export function shouldUseSystemSshTransport( target: SshTarget, resolved: Pick | null ): boolean { + if (isOpenSshConfigBackedTarget(target) && resolved) { + return ( + process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || + resolved.proxyUseFdpass === true || + resolved.proxyCommand != null || + resolved.proxyJump != null + ) + } return ( process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || target.proxyCommand != null || diff --git a/src/main/ssh/ssh-multi-key-authentication.test.ts b/src/main/ssh/ssh-multi-key-authentication.test.ts new file mode 100644 index 00000000000..693e754b661 --- /dev/null +++ b/src/main/ssh/ssh-multi-key-authentication.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + utils, + type AnyAuthMethod, + type AuthenticationType, + type AuthHandlerMiddleware, + type ConnectConfig, + type ParsedKey +} from 'ssh2' + +vi.mock('os', () => ({ + homedir: () => '/home/testuser', + tmpdir: () => '/tmp' +})) + +const mockExistsSync = vi.fn().mockReturnValue(false) +const mockReadFileSync = vi.fn() + +vi.mock('fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args) +})) + +import { buildConnectConfig } from './ssh-connection-utils' +import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' +import { buildSshArgs } from './system-ssh-args' +import type { SshTarget } from '../../shared/ssh-types' +import type { SshResolvedConfig } from './ssh-config-parser' + +function makeTarget(overrides: Partial = {}): SshTarget { + return { + id: 'target-1', + label: 'workbox', + source: 'ssh-config', + configHost: 'workbox', + host: 'stale.example.com', + port: 22, + username: 'stale-user', + identityFile: '/keys/stale-imported', + ...overrides + } +} + +function makeResolved(overrides: Partial = {}): SshResolvedConfig { + return { + hostname: 'current.example.com', + port: 2222, + user: 'current-user', + identityFile: ['/keys/unauthorized-first', '/keys/authorized-second'], + identitiesOnly: true, + forwardAgent: false, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no', + ...overrides + } +} + +function nextAuth( + config: ConnectConfig, + firstAttempt: boolean +): AuthenticationType | AnyAuthMethod | false { + let result: AuthenticationType | AnyAuthMethod | false | undefined + const handler = config.authHandler as AuthHandlerMiddleware + handler( + (firstAttempt ? null : ['publickey']) as unknown as AuthenticationType[], + false, + (attempt) => { + result = attempt + } + ) + return result ?? false +} + +describe('ordered SSH private-key authentication', () => { + beforeEach(() => { + vi.stubEnv('SSH_AUTH_SOCK', '') + mockExistsSync.mockReset() + mockExistsSync.mockReturnValue(false) + mockReadFileSync.mockReset() + mockReadFileSync.mockImplementation((path: unknown) => Buffer.from(String(path))) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('offers every fresh ssh -G IdentityFile in order and ignores the imported snapshot', () => { + const config = buildConnectConfig(makeTarget(), makeResolved(), { + includeAgent: false, + includePrivateKey: true + }) + + expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/unauthorized-first') + }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/authorized-second') + }) + expect(nextAuth(config, false)).toBe(false) + expect(mockReadFileSync).not.toHaveBeenCalledWith('/keys/stale-imported') + }) + + it('keeps explicit manual keys and unresolved imported keys as singular overrides', () => { + const manual = buildConnectConfig( + makeTarget({ + source: 'manual', + configHost: 'manual.example.com', + host: 'manual.example.com', + identityFile: '/keys/manual' + }), + makeResolved(), + { includeAgent: false, includePrivateKey: true } + ) + const unresolvedImport = buildConnectConfig(makeTarget(), null, { + includeAgent: false, + includePrivateKey: true + }) + + expect(manual.privateKey).toEqual(Buffer.from('/keys/manual')) + expect(manual.authHandler).toBeUndefined() + expect(unresolvedImport.privateKey).toEqual(Buffer.from('/keys/stale-imported')) + expect(unresolvedImport.authHandler).toBeUndefined() + }) + + it('resets ordered authentication for credential retries without extra key reads', () => { + const config = buildConnectConfig(makeTarget(), makeResolved(), { + includeAgent: false, + includePrivateKey: true + }) + const readsAfterResolution = mockReadFileSync.mock.calls.length + + expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) + config.password = 'retry-password' + expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'password', + password: 'retry-password' + }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/unauthorized-first') + }) + expect(mockReadFileSync).toHaveBeenCalledTimes(readsAfterResolution) + }) + + it('keeps encrypted-key prompts tied to the matching fresh identity path', () => { + vi.spyOn(utils, 'parseKey').mockImplementation((key) => { + if ( + Buffer.from(key as Buffer) + .toString() + .includes('encrypted-second') + ) { + return new Error('Encrypted private OpenSSH key detected, but no passphrase given') + } + return { isPrivateKey: () => true } as ParsedKey + }) + const config = buildConnectConfig( + makeTarget(), + makeResolved({ identityFile: ['/keys/first', '/keys/encrypted-second'] }), + { includeAgent: false, includePrivateKey: true } + ) + + expect(getPassphrasePrivateKeyPath(config)).toBe('/keys/encrypted-second') + }) + + it('leaves config-host key authority to system OpenSSH', () => { + const args = buildSshArgs(makeTarget(), { resolvedConfig: makeResolved() }) + + expect(args.at(-1)).toBe('workbox') + expect(args).not.toContain('-i') + expect(args).not.toContain('/keys/stale-imported') + expect(args).not.toContain('/keys/unauthorized-first') + expect(args).not.toContain('/keys/authorized-second') + }) +}) diff --git a/src/main/ssh/ssh-private-key-authentication.ts b/src/main/ssh/ssh-private-key-authentication.ts new file mode 100644 index 00000000000..a01a58bf9b6 --- /dev/null +++ b/src/main/ssh/ssh-private-key-authentication.ts @@ -0,0 +1,61 @@ +import type { AnyAuthMethod, AuthenticationType, ConnectConfig, NextAuthHandler } from 'ssh2' +import type { PrivateKeyFile } from './ssh-auth-resolution' + +const passphraseKeyPaths = new WeakMap() + +function buildAuthQueue( + config: ConnectConfig, + keys: PrivateKeyFile[] +): (AuthenticationType | AnyAuthMethod)[] { + const username = config.username ?? '' + const queue: (AuthenticationType | AnyAuthMethod)[] = [{ type: 'none', username }] + if (config.password != null) { + queue.push({ type: 'password', username, password: config.password }) + } + for (const key of keys) { + queue.push({ + type: 'publickey', + username, + key: key.contents, + passphrase: config.passphrase + }) + } + if (config.agent) { + queue.push({ type: 'agent', username, agent: config.agent }) + } + if (config.tryKeyboard) { + queue.push('keyboard-interactive') + } + return queue +} + +export function configurePrivateKeyAuthentication( + config: ConnectConfig, + keys: PrivateKeyFile[], + passphraseKeyPath?: string +): void { + const firstKey = keys[0] + if (!firstKey) { + return + } + config.privateKey = firstKey.contents + if (passphraseKeyPath) { + passphraseKeyPaths.set(config, passphraseKeyPath) + } + if (keys.length === 1) { + return + } + + let queue: (AuthenticationType | AnyAuthMethod)[] = [] + config.authHandler = (authsLeft, _partialSuccess, next) => { + if (authsLeft == null) { + queue = buildAuthQueue(config, keys) + } + const attempt = queue.shift() + next((attempt ?? false) as Parameters[0]) + } +} + +export function getPassphrasePrivateKeyPath(config: ConnectConfig): string | undefined { + return passphraseKeyPaths.get(config) +} diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 711f926e056..43354ebb90c 100644 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-races.test.ts @@ -166,6 +166,25 @@ describe('SshRelaySession recovery race fencing', () => { }) } + function pendingRecovery(recoveryEndSu: number) { + return { + status: 'pending', + clientGeneration: 2, + ownerGeneration: 2, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'new-token', + checkpointSourceEndSu: 4, + recoveryEndSu + } + } + + function completeRecovery(params: Record): void { + const complete = onNotificationByMethodMock.mock.calls.findLast( + ([method]) => method === 'pty.recoveryComplete' + )?.[1] as ((params: Record) => void) | undefined + complete?.(params) + } + async function prepareRecovery(targetId: string): Promise<{ session: SshRelaySession deps: ReturnType @@ -242,15 +261,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, + sourceRecovery: pendingRecovery(8), sourceActivationLease } }) @@ -320,15 +331,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, + sourceRecovery: pendingRecovery(8), sourceActivationLease } }) @@ -401,10 +404,7 @@ describe('SshRelaySession recovery race fencing', () => { const { session, deps } = await prepareRecovery(targetId) attachForReconnectMock.mockImplementation(async () => { queueMicrotask(() => { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ + completeRecovery({ id: 'pty-1', clientGeneration: 2, ownerGeneration: 2, @@ -416,15 +416,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - } + sourceRecovery: pendingRecovery(4) } }) await session.reconnect(deps.mockConn) @@ -463,10 +455,7 @@ describe('SshRelaySession recovery race fencing', () => { sourceStartSu: 5, sourceEndSu: 8 }) - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ + completeRecovery({ id: 'pty-1', clientGeneration: 2, ownerGeneration: 2, @@ -478,15 +467,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - } + sourceRecovery: pendingRecovery(8) } }) await session.reconnect(deps.mockConn) @@ -559,15 +540,7 @@ describe('SshRelaySession recovery race fencing', () => { } attachForReconnectMock.mockResolvedValue({ incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 12 - }, + sourceRecovery: pendingRecovery(12), sourceActivationLease }) @@ -651,15 +624,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, + sourceRecovery: pendingRecovery(8), sourceActivationLease: activationLease } }) @@ -704,6 +669,51 @@ describe('SshRelaySession recovery race fencing', () => { } ) + it('overwrites the migration checkpoint with the post-recovery one for the same pty', async () => { + const targetId = 'post-recovery-checkpoint-rekey' + const { session, deps } = await prepareRecovery(targetId) + attachForReconnectMock.mockImplementation(async () => { + queueMicrotask(() => { + emitSourceFrame({ + targetId, + token: 'new-token', + clientGeneration: 2, + ownerGeneration: 2, + sourceStartSu: 4, + sourceEndSu: 8 + }) + completeRecovery({ + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 2, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'new-token', + checkpointSourceEndSu: 4, + recoveryEndSu: 4 + }) + }) + return { incarnationId: 'incarnation-1', sourceRecovery: pendingRecovery(4) } + }) + await session.reconnect(deps.mockConn) + expect(acceptOutputDataMock).toHaveBeenCalledOnce() + + // The pre-migration identity is gone from the intake, so only the checkpoint + // recorded after recovery can answer the next reconnect. + vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) + attachForReconnectMock.mockResolvedValue({ + incarnationId: 'incarnation-1', + sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } + }) + await session.reconnect(deps.mockConn) + + expect(attachForReconnectMock).toHaveBeenCalledTimes(2) + expect(attachForReconnectMock.mock.calls.at(-1)?.[2]).toMatchObject({ + status: 'checkpoint', + deliveryToken: 'new-token', + acceptedSourceEndSu: 8 + }) + }) + it('keeps a stale overlapping recovery from canceling or mutating its replacement', async () => { const targetId = 'overlapping-recovery' const { session, deps } = await prepareRecovery(targetId) @@ -723,10 +733,7 @@ describe('SshRelaySession recovery race fencing', () => { const ownerGeneration = openConsumerSessionMock.mock.calls.length if (ownerGeneration === 3) { queueMicrotask(() => { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ + completeRecovery({ id: 'pty-1', clientGeneration: 3, ownerGeneration: 3, diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index aef993f8eef..d5dba94c80b 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2119,6 +2119,8 @@ export class SshRelaySession { } const checkpoints = recovery?.checkpointsByAppPtyId const relayPtyId = toRelaySshPtyId(this.targetId, appPtyId) + // Why: every checkpoint writer records app-id keys now, so the relay-id + // lookup (and its paired delete below) is a legacy guard only. const checkpoint = checkpoints?.get(appPtyId) ?? checkpoints?.get(relayPtyId) if (!checkpoint) { return Object.freeze({ status: 'checkpointUnavailable' }) @@ -2273,10 +2275,12 @@ export class SshRelaySession { (endSu, payload) => Math.max(endSu, payload.source?.sourceEndSu ?? endSu), acceptedRecovery.recoveryEndSu ) + // Why: checkpoints are app-id keyed; a relay-id entry here would be shadowed + // by a staler app-id entry on the next sourceRecoveryRequest lookup. ptyConsumerRecoveryByTarget.get(this.targetId)?.checkpointsByAppPtyId.set( - relayPtyId, + appPtyId, Object.freeze({ - id: relayPtyId, + id: appPtyId, providerGeneration: this.activePtyProviderGeneration!, clientGeneration: acceptedRecovery.clientGeneration, ownerGeneration: acceptedRecovery.ownerGeneration, diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index f64a9d7bc26..f9bd781bd0a 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -583,6 +583,69 @@ describe('runRemoteOrcaCli', () => { expect(spawn).not.toHaveBeenCalled() }) + it('rejects interactive account add but still bridges account list', async () => { + const { runtime } = createRuntime() + const spawn = vi.fn(() => createFakeChild()) + + const addResult = await runRemoteOrcaCli( + runtime, + { argv: ['account', 'add'], cwd: '/home/alice', env: {} }, + { ...LEGACY_FALLBACK_OPTIONS, spawn: spawn as never } + ) + + expect(addResult.exitCode).toBe(1) + expect(addResult.stderr).toContain('interactive agent login') + expect(spawn).not.toHaveBeenCalled() + + const child = createFakeChild() + spawn.mockReturnValueOnce(child) + const listPromise = runRemoteOrcaCli( + runtime, + { argv: ['account', 'list'], cwd: '/home/alice', env: {} }, + { + ...LEGACY_FALLBACK_OPTIONS, + entryExists: () => true, + spawn: spawn as never + } + ) + await Promise.resolve() + child.stdout.emit('data', Buffer.from('Managed Claude accounts\n')) + child.emit('close', 0) + + await expect(listPromise).resolves.toEqual({ + stdout: 'Managed Claude accounts\n', + stderr: '', + exitCode: 0 + }) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('bridges account add help because it does not start an interactive login', async () => { + const { runtime } = createRuntime() + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runRemoteOrcaCli( + runtime, + { argv: ['account', 'add', '--help'], cwd: '/home/alice', env: {} }, + { + ...LEGACY_FALLBACK_OPTIONS, + entryExists: () => true, + spawn: spawn as never + } + ) + await Promise.resolve() + child.stdout.emit('data', Buffer.from('Usage: orca account add\n')) + child.emit('close', 0) + + await expect(resultPromise).resolves.toEqual({ + stdout: 'Usage: orca account add\n', + stderr: '', + exitCode: 0 + }) + expect(spawn).toHaveBeenCalledOnce() + }) + it('reports host-interactive command errors as JSON envelopes with --json', async () => { const { runtime } = createRuntime() diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index acd0c94cfaf..709ec8655e3 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -40,7 +40,9 @@ const HOST_INTERACTIVE_COMMANDS: Record = { 'claude-teams': 'orca claude-teams starts an interactive Claude Code session and cannot run through the SSH relay bridge. Run it in a terminal on the Orca host machine.', 'agent-teams-tmux': - 'orca agent-teams-tmux is a tmux pane shim for the Orca host machine and cannot run through the SSH relay bridge.' + 'orca agent-teams-tmux is a tmux pane shim for the Orca host machine and cannot run through the SSH relay bridge.', + 'account add': + 'orca account add runs an interactive agent login and cannot run through the buffered SSH relay bridge. Run it directly in a terminal on the Orca host machine.' } export async function runRemoteOrcaCli( @@ -52,8 +54,9 @@ export async function runRemoteOrcaCli( const json = parsed.flags.has('json') const command = parsed.commandPath.join(' ') - const interactiveMessage = HOST_INTERACTIVE_COMMANDS[parsed.commandPath[0] ?? ''] - if (interactiveMessage) { + const interactiveMessage = + HOST_INTERACTIVE_COMMANDS[command] ?? HOST_INTERACTIVE_COMMANDS[parsed.commandPath[0] ?? ''] + if (interactiveMessage && !parsed.flags.has('help')) { if (json) { return { stdout: `${JSON.stringify(buildRemoteCliError(interactiveMessage, 'unsupported_over_ssh'), null, 2)}\n`, diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 71f94204d58..36312762d08 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -276,7 +276,8 @@ describe('spawnSystemSsh', () => { }) ) - expect(args).toContain('deploy@fdpass-host') + expect(args).toContain('fdpass-host') + expect(args).not.toContain('deploy@fdpass-host') expect(args).not.toContain('resolved.example.com') expect(args).not.toContain('-p') expect(args).not.toContain('-i') @@ -335,7 +336,7 @@ describe('spawnSystemSsh', () => { const standaloneControlIdx = args.indexOf('-S') expect(standaloneControlIdx).toBeGreaterThan(-1) expect(args[standaloneControlIdx + 1]).toBe('none') - expect(args.at(-2)).toBe('deploy@krb-host; touch /tmp/not-run') + expect(args.at(-2)).toBe('krb-host; touch /tmp/not-run') expect(args.at(-1)).toBe('echo ready') }) @@ -345,7 +346,7 @@ describe('spawnSystemSsh', () => { ) expect(args).not.toContain('GSSAPIAuthentication=yes') - expect(args).toContain('deploy@krb-host') + expect(args).toContain('krb-host') }) it('does not inject Orca ControlMaster flags when ssh config already owns muxing', () => { @@ -359,7 +360,7 @@ describe('spawnSystemSsh', () => { expectNoOrcaControlMasterArgs(args) expect(args).not.toContain('-S') - expect(args).toContain('deploy@workbox') + expect(args).toContain('workbox') }) it('injects Orca ControlMaster flags when ssh config only sets ControlPersist', () => { @@ -402,7 +403,7 @@ describe('spawnSystemSsh', () => { expectNoOrcaControlMasterArgs(args) expect(args).not.toContain('-S') - expect(args).toContain('deploy@workbox') + expect(args).toContain('workbox') }) it('does not inject Orca ControlMaster flags for unresolved legacy config aliases', () => { @@ -410,7 +411,7 @@ describe('spawnSystemSsh', () => { expectNoOrcaControlMasterArgs(args) expect(args).not.toContain('-S') - expect(args).toContain('deploy@workbox') + expect(args).toContain('workbox') }) it('can inject Orca ControlMaster flags for ssh-config targets with resolved config', () => { @@ -448,7 +449,7 @@ describe('spawnSystemSsh', () => { // split it before /bin/sh receives it. const args = spawnMock.mock.calls[0][1] as string[] expect(args).toContain('--') - expect(args).toContain('deploy@fdpass-host') + expect(args).toContain('fdpass-host') const wrapped = args.at(-1)! expect(wrapped).not.toContain('\n') expect(wrapped).toContain('printf %b "$@"') @@ -482,7 +483,7 @@ describe('spawnSystemSsh', () => { expect(standaloneControlIdx).toBe(-1) expectNoOrcaControlMasterArgs(args) expect(args).toContain('127.0.0.1:5173:127.0.0.1:3000') - expect(args[terminatorIdx + 1]).toBe('deploy@fdpass-host') + expect(args[terminatorIdx + 1]).toBe('fdpass-host') expect(spawnMock).toHaveBeenCalledWith( SYSTEM_SSH_PATH, expect.any(Array), @@ -497,7 +498,7 @@ describe('spawnSystemSsh', () => { expect(spawnMock).toHaveBeenCalledWith( SYSTEM_SSH_PATH, - expect.arrayContaining(['--', 'deploy@fdpass-host', 'echo hello']), + expect.arrayContaining(['--', 'fdpass-host', 'echo hello']), expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) ) }) diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index 33e5fb3c80d..be5ed622927 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -54,6 +54,8 @@ exec /bin/sh -c "$cmd" } function writeFakeRelay(dir: string): void { + writeFileSync(join(dir, 'relay-watcher.js'), '') + writeFileSync(join(dir, 'managed-hook-runtime.js'), '') writeFileSync( join(dir, 'relay.js'), ` @@ -72,7 +74,8 @@ function encode(msg) { return Buffer.concat([header, payload]); } -function serve(socket) { +function serve(socket, onResolved) { + socket.on('error', () => {}); socket.write(sentinel); let buffer = Buffer.alloc(0); socket.on('data', (chunk) => { @@ -86,7 +89,10 @@ function serve(socket) { if (type !== 1) continue; const message = JSON.parse(payload.toString('utf8')); if (message.method === 'session.resolveHome') { - socket.write(encode({ jsonrpc: '2.0', id: message.id, result: process.env.HOME })); + socket.write( + encode({ jsonrpc: '2.0', id: message.id, result: process.env.HOME }), + onResolved + ); } } }); @@ -94,27 +100,18 @@ function serve(socket) { if (process.argv.includes('--detached')) { try { fs.unlinkSync(sockPath); } catch {} - const server = net.createServer(serve); - server.listen(sockPath); - setTimeout(() => process.exit(0), 20000).unref(); -} else if (process.argv.includes('--connect')) { - process.stdout.write(sentinel); - let buffer = Buffer.alloc(0); - process.stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - while (buffer.length >= 13) { - const type = buffer[0]; - const length = buffer.readUInt32BE(9); - if (buffer.length < 13 + length) return; - const payload = buffer.subarray(13, 13 + length); - buffer = buffer.subarray(13 + length); - if (type !== 1) continue; - const message = JSON.parse(payload.toString('utf8')); - if (message.method === 'session.resolveHome') { - process.stdout.write(encode({ jsonrpc: '2.0', id: message.id, result: process.env.HOME })); - } - } + const server = net.createServer((socket) => { + serve(socket, () => server.close()); }); + server.listen(sockPath); +} else if (process.argv.includes('--connect')) { + const socket = net.createConnection(sockPath); + socket.on('error', (error) => { + process.stderr.write(error.message); + process.exit(1); + }); + process.stdin.pipe(socket); + socket.pipe(process.stdout); } ` ) @@ -137,12 +134,15 @@ function createRelayTree(root: string, remoteHome: string): void { } const remoteDir = join(remoteHome, '.orca-remote', `relay-${RELAY_VERSION}`) - mkdirSync(join(remoteDir, 'node_modules', 'node-pty'), { recursive: true }) + mkdirSync(join(remoteDir, 'node_modules', 'node-pty', 'lib'), { recursive: true }) mkdirSync(join(remoteDir, 'node_modules', '@parcel', 'watcher'), { recursive: true }) writeFileSync(join(remoteDir, 'node_modules', 'node-pty', 'index.js'), '') + writeFileSync( + join(remoteDir, 'node_modules', 'node-pty', 'lib', 'utils.js'), + 'exports.loadNativeModule = () => ({})\n' + ) writeFileSync(join(remoteDir, 'node_modules', '@parcel', 'watcher', 'index.js'), '') writeFileSync(join(remoteDir, '.install-complete'), '') - writeFileSync(join(remoteDir, 'managed-hook-runtime.js'), '') writeFakeRelay(remoteDir) } @@ -205,10 +205,14 @@ describe('system SSH transport integration', () => { 'deploys and speaks relay RPC over a system ssh process for ProxyUseFdpass targets', async () => { const conn = new SshConnection(makeTarget(), { onStateChange: vi.fn() }) + const onProgress = vi.fn() await conn.connect() expect(conn.usesSystemSshTransport()).toBe(true) - const result = await deployAndLaunchRelay(conn, undefined, 60, makeTarget().id) + const result = await deployAndLaunchRelay(conn, onProgress, 60, makeTarget().id) + expect(onProgress).toHaveBeenCalledWith('Starting relay...') + expect(onProgress).not.toHaveBeenCalledWith('Uploading relay...') + expect(onProgress).not.toHaveBeenCalledWith('Installing native dependencies...') const mux = new SshChannelMultiplexer(result.transport) try { await expect(mux.request('session.resolveHome', { path: '~' })).resolves.toBe( @@ -227,7 +231,7 @@ describe('system SSH transport integration', () => { async () => { delete process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT const conn = new SshConnection( - { ...makeTarget(), gssapiAuthentication: true }, + { ...makeTarget(), source: 'manual', gssapiAuthentication: true }, { onStateChange: vi.fn() } ) await conn.connect() diff --git a/src/main/ssh/system-ssh-args.ts b/src/main/ssh/system-ssh-args.ts index 62231666c08..2bdb91e20dc 100644 --- a/src/main/ssh/system-ssh-args.ts +++ b/src/main/ssh/system-ssh-args.ts @@ -77,7 +77,9 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti } const host = target.configHost || target.host - const userHost = target.username ? `${target.username}@${host}` : host + // Why: OpenSSH owns User for config-backed aliases; imported fallback values + // must not override a fresh wildcard, Include, or Match result. + const userHost = useConfigHost ? host : target.username ? `${target.username}@${host}` : host args.push('--', userHost) return args @@ -166,7 +168,9 @@ function shouldUseOpenSshConfigHost(target: SshTarget): boolean { return isOpenSshConfigBackedTarget(target) } -export function isOpenSshConfigBackedTarget(target: SshTarget): boolean { +export function isOpenSshConfigBackedTarget( + target: Pick +): boolean { if (target.source === 'ssh-config') { return true } diff --git a/src/main/ssh/system-ssh-forward-process.test.ts b/src/main/ssh/system-ssh-forward-process.test.ts index 4178d0e1796..bc63cf8dc1f 100644 --- a/src/main/ssh/system-ssh-forward-process.test.ts +++ b/src/main/ssh/system-ssh-forward-process.test.ts @@ -137,7 +137,7 @@ describe('system SSH forward process', () => { expect(standaloneControlIdx).toBe(-1) expectNoOrcaControlMasterArgs(args) expect(args).toContain('127.0.0.1:5173:127.0.0.1:3000') - expect(args[terminatorIdx + 1]).toBe('deploy@fdpass-host') + expect(args[terminatorIdx + 1]).toBe('fdpass-host') expect(spawnMock).toHaveBeenCalledWith( SYSTEM_SSH_PATH, expect.any(Array), @@ -192,7 +192,7 @@ describe('system SSH forward process', () => { const args = spawnMock.mock.calls[0][1] as string[] expect(args.indexOf('-S')).toBe(-1) expectNoOrcaControlMasterArgs(args) - expect(args).toContain('deploy@workbox') + expect(args).toContain('workbox') }) it('preserves manual target port and identity options in the forwarded ssh command', () => { diff --git a/src/main/startup/bootstrap-fatal-exit-guard.test.ts b/src/main/startup/bootstrap-fatal-exit-guard.test.ts new file mode 100644 index 00000000000..cff8b372578 --- /dev/null +++ b/src/main/startup/bootstrap-fatal-exit-guard.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + BOOTSTRAP_FATAL_EXIT_GUARD_KEY, + removeBootstrapFatalExitGuard +} from './bootstrap-fatal-exit-guard' + +type BootstrapFatalExitGlobal = typeof globalThis & { + [BOOTSTRAP_FATAL_EXIT_GUARD_KEY]?: () => void +} + +const bootstrapGlobal = globalThis as BootstrapFatalExitGlobal + +afterEach(() => { + delete bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY] +}) + +describe('bootstrap fatal exit guard', () => { + it('removes the generated pre-import guard', () => { + const removeGuard = vi.fn() + bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY] = removeGuard + + removeBootstrapFatalExitGuard() + + expect(removeGuard).toHaveBeenCalledOnce() + expect(bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY]).toBeUndefined() + }) +}) diff --git a/src/main/startup/bootstrap-fatal-exit-guard.ts b/src/main/startup/bootstrap-fatal-exit-guard.ts new file mode 100644 index 00000000000..89a38317f22 --- /dev/null +++ b/src/main/startup/bootstrap-fatal-exit-guard.ts @@ -0,0 +1,12 @@ +export const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__' + +type BootstrapFatalExitGlobal = typeof globalThis & { + [BOOTSTRAP_FATAL_EXIT_GUARD_KEY]?: () => void +} + +export function removeBootstrapFatalExitGuard(): void { + const bootstrapGlobal = globalThis as BootstrapFatalExitGlobal + const removeGuard = bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY] + delete bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY] + removeGuard?.() +} diff --git a/src/main/startup/gpu-fallback-switches.test.ts b/src/main/startup/gpu-fallback-switches.test.ts new file mode 100644 index 00000000000..b1ae28d0d5e --- /dev/null +++ b/src/main/startup/gpu-fallback-switches.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyGpuFallbackCommandLineSwitches } from './gpu-fallback-switches' + +describe('GPU fallback command-line switches', () => { + it('applies nothing outside Windows', () => { + for (const platform of ['darwin', 'linux'] as const) { + const appendSwitch = vi.fn() + expect(applyGpuFallbackCommandLineSwitches({ appendSwitch }, platform)).toEqual([]) + expect(appendSwitch).not.toHaveBeenCalled() + } + }) + + // Why: measured on Windows 11 / Electron 43.1.0 — `--disable-gpu` alone still reports + // GPU: 1 in app.getAppMetrics(); adding --in-process-gpu drops it to 0. + it('appends exactly the measured switch set to the Electron command line', () => { + const appendSwitch = vi.fn() + const appliedSwitches = applyGpuFallbackCommandLineSwitches({ appendSwitch }, 'win32') + expect(appliedSwitches).toEqual([ + 'disable-gpu', + 'disable-software-rasterizer', + 'in-process-gpu' + ]) + expect(appendSwitch.mock.calls.map(([name]) => name)).toEqual(appliedSwitches) + }) +}) diff --git a/src/main/startup/gpu-fallback-switches.ts b/src/main/startup/gpu-fallback-switches.ts new file mode 100644 index 00000000000..9c0b5ef8ff7 --- /dev/null +++ b/src/main/startup/gpu-fallback-switches.ts @@ -0,0 +1,35 @@ +/** Minimal surface of `app.commandLine` this module needs, so callers can be tested. */ +export type GpuFallbackCommandLine = { + appendSwitch(name: string, value?: string): void +} + +/** + * Why: `disableHardwareAcceleration()` + `--disable-gpu` still spawns a GPU child + * (measured on Windows 11 / Electron 43.1.0) — it only drops the backend to software + * GL, so a GPU process being killed by a bad driver or injected DLL keeps dying after + * the fallback engages. `--in-process-gpu` is the only switch that removes the child. + * SwiftShader is disabled because `--in-process-gpu` would otherwise move untrusted + * WebGL command parsing into Orca's privileged main process. Terminals safely fall + * back to the DOM renderer in this post-crash mode. + */ +const GPU_FALLBACK_COMMAND_LINE_SWITCHES = [ + 'disable-gpu', + 'disable-software-rasterizer', + 'in-process-gpu' +] as const + +/** Software rendering is a Windows-only post-crash fallback; every other platform gets nothing. */ +function resolveGpuFallbackSwitches(platform: NodeJS.Platform): readonly string[] { + return platform === 'win32' ? GPU_FALLBACK_COMMAND_LINE_SWITCHES : [] +} + +export function applyGpuFallbackCommandLineSwitches( + commandLine: GpuFallbackCommandLine, + platform: NodeJS.Platform +): readonly string[] { + const switches = resolveGpuFallbackSwitches(platform) + for (const name of switches) { + commandLine.appendSwitch(name) + } + return switches +} diff --git a/src/main/startup/main-process-error-guards.ts b/src/main/startup/main-process-error-guards.ts index fcf178c67be..82735361832 100644 --- a/src/main/startup/main-process-error-guards.ts +++ b/src/main/startup/main-process-error-guards.ts @@ -1,4 +1,5 @@ import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { removeBootstrapFatalExitGuard } from './bootstrap-fatal-exit-guard' type FatalMainProcessErrorKind = 'main_uncaught_exception' | 'main_unhandled_rejection' @@ -115,6 +116,7 @@ export function installUncaughtPipeErrorGuard(): void { } process.on('uncaughtException', onUncaughtException) + removeBootstrapFatalExitGuard() } /** Keep one failed background promise from silently killing the whole app. diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 411bb1a7184..1e3f7de3395 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -32,6 +32,7 @@ type UpdaterHandlerContext = { isQuitAndInstallHandoffActive: () => boolean hasInstallableDownloadedVersion: () => boolean isLocalBuildCheck: () => boolean + isPinnedBuildCheck: () => boolean shouldHandleUpdaterErrorEvent: () => boolean clearUpdateAvailableEventPending: (attemptId: number | null) => void isActiveUpdateCheckAttempt: (attemptId: number) => boolean @@ -74,6 +75,7 @@ export function registerAutoUpdaterHandlers({ isQuitAndInstallHandoffActive, hasInstallableDownloadedVersion, isLocalBuildCheck, + isPinnedBuildCheck, shouldHandleUpdaterErrorEvent, clearUpdateAvailableEventPending, isActiveUpdateCheckAttempt, @@ -162,8 +164,12 @@ export function registerAutoUpdaterHandlers({ const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() setUserInitiatedCheck(false) - // Release checks remain newer-only; validated local builds may intentionally downgrade. - if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) { + // Release checks remain newer-only; validated local builds and pinned dev jumps may intentionally downgrade. + if ( + !isLocalBuildCheck() && + !isPinnedBuildCheck() && + compareVersions(info.version, app.getVersion()) <= 0 + ) { clearAvailableUpdateContext() if (missingManifestFallback || publishingWindowLastGoodCheck) { // Why: a current-version fallback manifest means the primary is transiently missing; keep the short retry cadence. @@ -182,9 +188,10 @@ export function registerAutoUpdaterHandlers({ markUpdateAvailableEventPending(attemptId) void (async () => { try { - const changelog = isLocalBuildCheck() - ? null - : await fetchChangelog(info.version, app.getVersion()).catch(() => null) + const changelog = + isLocalBuildCheck() || isPinnedBuildCheck() + ? null + : await fetchChangelog(info.version, app.getVersion()).catch(() => null) // Why: async fetch may take seconds; bail if a newer event superseded this attempt to avoid a stale 'available' broadcast. if (!isActiveUpdateCheckAttempt(attemptId)) { @@ -197,7 +204,10 @@ export function registerAutoUpdaterHandlers({ // Why: side effects must run after the guard so a concurrent 'error' during the fetch can't leave orphaned state. setAvailableVersion(info.version) setAvailableReleaseUrl(null) - if (!isLocalBuildCheck()) { + // Why: a pinned dev jump is not a release check. Letting it call + // recordCompletedUpdateCheck() would persist lastUpdateCheckAt and + // suppress the next real background check for a full day. + if (!isLocalBuildCheck() && !isPinnedBuildCheck()) { if (missingManifestFallback || publishingWindowLastGoodCheck) { // Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes. scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) @@ -226,9 +236,12 @@ export function registerAutoUpdaterHandlers({ const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck() const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() const localBuildCheck = isLocalBuildCheck() + // Why: an unpinned outcome must hand the feed back, else the pin blocks every + // later background check for the process lifetime. + const pinnedBuildCheck = isPinnedBuildCheck() setUserInitiatedCheck(false) clearAvailableUpdateContext() - if (!localBuildCheck) { + if (!localBuildCheck && !pinnedBuildCheck) { if (missingManifestFallback || publishingWindowLastGoodCheck) { // Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h. scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) @@ -240,7 +253,7 @@ export function registerAutoUpdaterHandlers({ } } sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) - if (localBuildCheck) { + if (localBuildCheck || pinnedBuildCheck) { restoreReleaseUpdateSource() } }) @@ -256,8 +269,12 @@ export function registerAutoUpdaterHandlers({ autoUpdater.on('update-downloaded', (info) => { clearBackgroundCheckLaunchPending() - // Release downloads remain newer-only; the local source was validated before checking. - if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) { + // Release downloads remain newer-only; the local source was validated before checking, and a pinned jump is explicit. + if ( + !isLocalBuildCheck() && + !isPinnedBuildCheck() && + compareVersions(info.version, app.getVersion()) <= 0 + ) { clearAvailableUpdateContext() sendStatus({ state: 'not-available' }) return @@ -302,7 +319,7 @@ export function registerAutoUpdaterHandlers({ return } sendErrorStatus(message, wasUserInitiated || undefined) - if (isLocalBuildCheck()) { + if (isLocalBuildCheck() || isPinnedBuildCheck()) { restoreReleaseUpdateSource() } }) diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts new file mode 100644 index 00000000000..2ab08ae62cb --- /dev/null +++ b/src/main/updater-release-builds.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fetchMock = vi.fn() +vi.mock('electron', () => ({ net: { fetch: (...args: unknown[]) => fetchMock(...args) } })) + +const { listReleaseBuilds, resolveTargetBuild } = await import('./updater-release-builds') + +function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}) { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + json: () => Promise.resolve(body) + } +} + +const release = (tag: string, extra: Record = {}) => ({ + tag_name: tag, + draft: false, + published_at: '2026-07-28T14:00:00Z', + html_url: `https://github.com/stablyai/orca/releases/tag/${tag}`, + ...extra +}) + +describe('listReleaseBuilds', () => { + beforeEach(() => { + fetchMock.mockReset() + }) + + it('lists hourly builds from the dedicated repo, newest first', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.160-hourly.202607280900'), + release('v1.4.160-hourly.202607281400'), + release('v1.4.160-hourly.202607281000') + ]) + ) + + const builds = await listReleaseBuilds('hourly') + + expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-hourly') + expect(builds.map((build) => build.version)).toEqual([ + '1.4.160-hourly.202607281400', + '1.4.160-hourly.202607281000', + '1.4.160-hourly.202607280900' + ]) + }) + + // Why: the main repo serves stable and rc from one endpoint, so an unfiltered + // list would offer RC tags under the Stable channel. + it('separates stable from rc in the shared main repo', async () => { + fetchMock.mockResolvedValue( + jsonResponse([release('v1.4.160-rc.2'), release('v1.4.159'), release('v1.4.158')]) + ) + + await expect(listReleaseBuilds('stable').then((b) => b.map((x) => x.version))).resolves.toEqual( + ['1.4.159', '1.4.158'] + ) + + fetchMock.mockResolvedValue( + jsonResponse([release('v1.4.160-rc.2'), release('v1.4.159'), release('v1.4.158')]) + ) + await expect(listReleaseBuilds('rc').then((b) => b.map((x) => x.version))).resolves.toEqual([ + '1.4.160-rc.2' + ]) + }) + + // Why: a draft release has no downloadable assets; offering it makes the + // switch action fail with a 404 after the user commits to it. + it('skips drafts and unparseable tags', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.159'), + release('v1.4.158', { draft: true }), + release('not-a-version'), + { tag_name: 42 } + ]) + ) + + const builds = await listReleaseBuilds('stable') + expect(builds.map((build) => build.version)).toEqual(['1.4.159']) + }) + + it('surfaces a rate limit as an actionable message', async () => { + fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 403 })) + await expect(listReleaseBuilds('hourly')).rejects.toThrow(/rate limit/i) + }) + + it('reports a missing hourly repo distinctly', async () => { + fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 404 })) + await expect(listReleaseBuilds('hourly')).rejects.toThrow(/No releases repository/i) + }) +}) + +describe('resolveTargetBuild', () => { + it('pins an hourly tag at the hourly repo download path', () => { + expect(resolveTargetBuild('hourly', 'v1.4.160-hourly.202607281400')).toEqual({ + tag: 'v1.4.160-hourly.202607281400', + version: '1.4.160-hourly.202607281400', + feedUrl: + 'https://github.com/stablyai/orca-hourly/releases/download/v1.4.160-hourly.202607281400' + }) + }) + + it('pins a stable tag at the main repo download path', () => { + expect(resolveTargetBuild('stable', 'v1.4.159').feedUrl).toBe( + 'https://github.com/stablyai/orca/releases/download/v1.4.159' + ) + }) + + it('rejects a tag that is not a version', () => { + expect(() => resolveTargetBuild('stable', 'main')).toThrow(/not a valid release tag/) + }) +}) diff --git a/src/main/updater-release-builds.ts b/src/main/updater-release-builds.ts new file mode 100644 index 00000000000..a7ff6089baf --- /dev/null +++ b/src/main/updater-release-builds.ts @@ -0,0 +1,102 @@ +import { net } from 'electron' +import { + getReleaseRepoForChannel, + getVersionChannel, + normalizeTagToVersion, + sortReleaseBuildsNewestFirst, + type ReleaseBuild, + type ReleaseChannel +} from '../shared/release-channel' +import { isValidVersion } from './updater-fallback' + +const FETCH_TIMEOUT_MS = 8000 +const MAX_LISTED_BUILDS = 100 + +function getReleasesApiUrl(repo: string): string { + return `https://api.github.com/repos/${repo}/releases?per_page=${MAX_LISTED_BUILDS}` +} + +export function getReleaseDownloadUrlForRepo(repo: string, tag: string): string { + return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}` +} + +type GitHubReleaseEntry = { + tag_name?: unknown + draft?: unknown + published_at?: unknown + html_url?: unknown +} + +function parseReleaseEntry(entry: GitHubReleaseEntry, repo: string): ReleaseBuild | null { + if (typeof entry.tag_name !== 'string' || entry.draft === true) { + return null + } + const tag = entry.tag_name + const version = normalizeTagToVersion(tag) + const channel = getVersionChannel(version) + if (!isValidVersion(version) || !channel) { + return null + } + return { + tag, + version, + channel, + publishedAt: typeof entry.published_at === 'string' ? entry.published_at : null, + releaseUrl: + typeof entry.html_url === 'string' + ? entry.html_url + : `https://github.com/${repo}/releases/tag/${encodeURIComponent(tag)}` + } +} + +/** + * Lists published releases for a channel so the dev picker can offer an exact + * build — including older ones — to jump to. + * + * Why the REST API rather than the atom feed the routine update path uses: the + * feed caps at the 10 newest entries, which cannot express "jump back to + * yesterday's hourly". This runs only on explicit dev interaction, so its + * unauthenticated rate limit never touches background checks. + */ +export async function listReleaseBuilds(channel: ReleaseChannel): Promise { + const repo = getReleaseRepoForChannel(channel) + const res = await net.fetch(getReleasesApiUrl(repo), { + headers: { Accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + if (!res.ok) { + if (res.status === 404) { + throw new Error(`No releases repository found at ${repo}.`) + } + if (res.status === 403 || res.status === 429) { + throw new Error('GitHub rate limit reached. Try again in a few minutes.') + } + throw new Error(`Could not list ${channel} builds (HTTP ${res.status}).`) + } + const payload: unknown = await res.json() + if (!Array.isArray(payload)) { + throw new Error(`Could not read the ${channel} release list.`) + } + const builds = payload + .map((entry) => parseReleaseEntry(entry as GitHubReleaseEntry, repo)) + .filter((build): build is ReleaseBuild => build !== null) + // Why: the main repo serves both stable and rc, so filter to the asked-for channel. + .filter((build) => build.channel === channel) + return sortReleaseBuildsNewestFirst(builds) +} + +export type ResolvedTargetBuild = { + tag: string + version: string + feedUrl: string +} + +/** Resolves a tag the user picked into a pinned generic feed URL. */ +export function resolveTargetBuild(channel: ReleaseChannel, tag: string): ResolvedTargetBuild { + const version = normalizeTagToVersion(tag) + if (!isValidVersion(version)) { + throw new Error(`"${tag}" is not a valid release tag.`) + } + const repo = getReleaseRepoForChannel(channel) + return { tag, version, feedUrl: getReleaseDownloadUrlForRepo(repo, tag) } +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 3f9cb2a531c..1551720b72f 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -44,6 +44,12 @@ import { requestServeUpdateHandoff } from './serve-update-handoff' import type { LocalBuildFeed } from './local-builds/local-build-feed-server' +import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' +import { + isChannelSupportedOnPlatform, + type ReleaseBuild, + type ReleaseChannel +} from '../shared/release-channel' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } @@ -140,9 +146,16 @@ let downloadInFlight = false /** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ let quittingForUpdate = false let autoUpdater: ElectronAutoUpdater | null = null -let activeUpdateSource: 'release' | 'local' = 'release' +let activeUpdateSource: 'release' | 'local' | 'hourly' = 'release' let activeLocalBuildFeed: LocalBuildFeed | null = null let localBuildSelectionInProgress = false +// Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade +// like local builds — but off a real release feed, not a loopback server. +let pinnedBuildSelectionInProgress = false +// Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a +// deliberate downgrade, so newer-only gates must yield to it too. +let isPinnedBuildActive = false +let getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null function getAutoUpdater(): ElectronAutoUpdater { if (!autoUpdater) { @@ -167,9 +180,13 @@ function closeLocalBuildFeed(): void { function restoreReleaseUpdateSource(): void { closeLocalBuildFeed() activeUpdateSource = 'release' + isPinnedBuildActive = false if (autoUpdater) { autoUpdater.allowDowngrade = false autoUpdater.disableDifferentialDownload = false + // Why: a pinned jump forces allowPrerelease on; leaving it set would opt + // every later background check into the RC channel behind the user's back. + autoUpdater.allowPrerelease = includePrereleaseActive } } @@ -265,7 +282,7 @@ function sendStatus(status: UpdateStatus): void { } const sourcedStatus: UpdateStatus = - activeUpdateSource === 'local' ? { ...status, source: 'local' } : status + activeUpdateSource === 'release' ? status : { ...status, source: activeUpdateSource } const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus) if (isUpdateCheckResultState(status.state)) { @@ -318,6 +335,12 @@ function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant if (options?.includePrerelease) { return 'prerelease' } + // Why: a persisted 'rc' override makes every routine check follow the RC series + // without the user re-holding shift; 'hourly' needs an explicit tag, so it is + // not a routine-check variant. + if (getReleaseChannelOverride?.() === 'rc') { + return 'prerelease' + } return 'default' } @@ -573,7 +596,10 @@ function getKnownReleaseUrl(): string | undefined { function hasInstallableDownloadedVersion(): boolean { return ( availableVersion !== null && - (activeUpdateSource === 'local' || compareVersions(availableVersion, app.getVersion()) > 0) + // Why: local builds and pinned dev jumps may intentionally move backwards. + (activeUpdateSource !== 'release' || + isPinnedBuildActive || + compareVersions(availableVersion, app.getVersion()) > 0) ) } @@ -823,6 +849,14 @@ async function sendCheckFailureStatus( sendLocalBuildErrorAndRestore(message, userInitiated) return } + if (isPinnedBuildActive) { + // Why: a failed pinned jump must hand the feed back before surfacing the + // error, or the pin blocks background checks for the process lifetime. + clearAvailableUpdateContext() + restoreReleaseUpdateSource() + sendStatus({ state: 'error', message, userInitiated }) + return + } const failureKey = getCheckFailureKey(message, userInitiated) if ( source === 'promise' && @@ -1266,7 +1300,14 @@ function retryPrereleaseFallbackAfterMissingManifest( function runBackgroundUpdateCheck( nudgeId: string | null = getPersistedPendingUpdateNudgeId() ): boolean { - if (activeUpdateSource === 'local' || localBuildSelectionInProgress) { + // Why: a pinned dev jump owns the feed until it settles; a background check + // would repoint it mid-flight and download the wrong build. + if ( + activeUpdateSource !== 'release' || + isPinnedBuildActive || + localBuildSelectionInProgress || + pinnedBuildSelectionInProgress + ) { return false } if (backgroundCheckLaunchPending || currentStatus.state === 'checking') { @@ -1340,11 +1381,15 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { void checkForLocalBuildFromMenu() return } - if (localBuildSelectionInProgress) { + if (options?.targetTag && options.channel) { + void checkForPinnedBuild(options.channel, options.targetTag) + return + } + if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { return } if ( - activeUpdateSource === 'local' && + activeUpdateSource !== 'release' && (currentStatus.state === 'checking' || currentStatus.state === 'downloading') ) { return @@ -1466,12 +1511,93 @@ async function checkForLocalBuildFromMenu(): Promise { } } +export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return listReleaseBuilds(channel) +} + +/** + * Pins the updater at one exact release tag and checks it, so a dev can move to + * any published build on any channel — including an older one. + * + * Unlike a routine check this sets `allowDowngrade`, because "jump to yesterday's + * hourly" is a downgrade by semver. The pin is torn down as soon as the attempt + * settles so ordinary background checks never inherit it. + */ +async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { + if (!app.isPackaged || is.dev) { + sendStatus({ state: 'not-available', userInitiated: true }) + return + } + // Why here as well as in the picker: the renderer disables the option, but IPC + // is reachable regardless, and there is no artifact to install off-macOS. + if (!isChannelSupportedOnPlatform(channel, process.platform)) { + sendStatus({ + state: 'error', + message: 'Hourly builds are produced only for macOS.', + userInitiated: true + }) + return + } + if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { + return + } + if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { + return + } + pinnedBuildSelectionInProgress = true + try { + const target = resolveTargetBuild(channel, tag) + if (compareVersions(target.version, app.getVersion()) === 0) { + sendStatus({ state: 'not-available', userInitiated: true }) + return + } + closeLocalBuildFeed() + activeUpdateSource = channel === 'hourly' ? 'hourly' : 'release' + isPinnedBuildActive = true + clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() + clearAvailableUpdateContext() + activeUpdateNudgeId = null + userInitiatedCheck = true + sendStatus({ state: 'checking', userInitiated: true }) + + const updater = getAutoUpdater() + // Why: an intentional jump to an older tag must not be filtered out as "not newer". + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.allowPrerelease = true + console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) + updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) + availableReleaseUrl = target.feedUrl + const attemptId = beginUpdateCheckAttempt() + markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + userInitiatedCheck = false + clearAvailableUpdateContext() + restoreReleaseUpdateSource() + sendStatus({ + state: 'error', + message: String((error as Error)?.message ?? error), + userInitiated: true + }) + } finally { + pinnedBuildSelectionInProgress = false + } +} + export function isQuittingForUpdate(): boolean { return quittingForUpdate } export function quitAndInstall(): void { - if (localBuildSelectionInProgress || pendingQuitAndInstallTimer || quitAndInstallInProgress) { + if ( + localBuildSelectionInProgress || + pinnedBuildSelectionInProgress || + pendingQuitAndInstallTimer || + quitAndInstallInProgress + ) { return } @@ -1562,14 +1688,18 @@ export function dismissNudge(): void { } /** - * The user closed an offered update without taking it. For a local build that ends the session: - * nothing will consume the local feed now, so release checks must stop being deferred. + * The user closed an offered update without taking it. For a local build or a + * pinned dev jump that ends the session: nothing will consume that feed now, so + * release checks must stop being deferred. */ export function dismissAvailableUpdate(): void { - if (activeUpdateSource !== 'local' || localBuildSelectionInProgress) { + if (activeUpdateSource === 'release' && !isPinnedBuildActive) { return } - // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the local feed and allowDowngrade. + if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { + return + } + // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. if (currentStatus.state !== 'available') { return } @@ -1589,6 +1719,7 @@ export function setupAutoUpdater( getDismissedUpdateNudgeId?: () => string | null setPendingUpdateNudgeId?: (id: string | null) => void setDismissedUpdateNudgeId?: (id: string | null) => void + getReleaseChannelOverride?: () => ReleaseChannel | null installMode?: UpdateInstallMode } ): void { @@ -1600,6 +1731,7 @@ export function setupAutoUpdater( _getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null _setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null _setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null + getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null updateInstallMode = opts?.installMode ?? 'interactive' lastInstallDeferralVersion = { download: null, install: null } @@ -1669,6 +1801,9 @@ export function setupAutoUpdater( isQuitAndInstallHandoffActive, hasInstallableDownloadedVersion, isLocalBuildCheck: () => activeUpdateSource === 'local', + // Why: pinned jumps are deliberate, so update-available/-downloaded must not + // reject them for being older than the running version. + isPinnedBuildCheck: () => isPinnedBuildActive, shouldHandleUpdaterErrorEvent, performQuitAndInstall, clearUpdateAvailableEventPending, @@ -1733,7 +1868,7 @@ export function setupAutoUpdater( } export function downloadUpdate(): void { - if (localBuildSelectionInProgress || downloadInFlight) { + if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress || downloadInFlight) { return } // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index abf0b0964be..3f719e5ae34 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -119,17 +119,29 @@ describe('getSpawnArgsForWindows', () => { it('rejects unsafe args for .cmd scripts on win32', () => { withPlatform('win32', () => { - expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['hello & goodbye'])).toThrow( - 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - ) + for (const argument of ['hello & goodbye', 'close)', '(open']) { + expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', [argument])).toThrow( + 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + ) + } }) }) it('rejects unsafe command paths for .cmd scripts on win32', () => { withPlatform('win32', () => { - expect(() => getSpawnArgsForWindows('C:\\bad&path\\agent.cmd', ['login'])).toThrow( - 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - ) + for (const command of ['C:\\bad&path\\agent.cmd', 'C:\\bad(path\\agent.cmd']) { + expect(() => getSpawnArgsForWindows(command, ['login'])).toThrow( + 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + ) + } + }) + }) + + it('allows punctuation that is not a cmd command operator', () => { + withPlatform('win32', () => { + expect( + getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['package,name;version']).spawnArgs + ).toEqual(['/d', '/c', 'C:\\tools\\agent.cmd', 'package,name;version']) }) }) }) diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 4edc47b93a1..21f3a75853d 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -2,6 +2,14 @@ import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from ' import { delimiter, join, win32 } from 'node:path' import { existsSync } from 'node:fs' +export { + getCmdExePath, + getSpawnArgsForWindows, + isWindowsBatchScript, + WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, + UnsafeWindowsBatchArgumentsError +} from '../shared/windows-batch-spawn' + function execFileWithoutBlocking( command: string, args: string[], @@ -38,20 +46,6 @@ export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string { return win32.join(root, 'System32', 'reg.exe') } -/** - * Full path to cmd.exe, respecting the ComSpec convention used elsewhere in - * the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts). - * Falls back to SystemRoot-based path if ComSpec is unset. - */ -export function getCmdExePath(): string { - return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe` -} - -/** Whether a resolved command path points to a Windows batch script (.cmd/.bat). */ -export function isWindowsBatchScript(commandPath: string): boolean { - return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) -} - export function resolveWindowsCommand( command: string, env: NodeJS.ProcessEnv = process.env @@ -79,19 +73,6 @@ export function resolveWindowsCommand( return command } -export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - -export class UnsafeWindowsBatchArgumentsError extends Error { - constructor() { - super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) - this.name = 'UnsafeWindowsBatchArgumentsError' - } -} - -function hasUnsafeWindowsBatchSyntax(value: string): boolean { - return /[&|<>^"%!\r\n]/.test(value) -} - /** Check whether an error is a Windows permission error (EACCES or EPERM). */ export function isPermissionError(error: unknown): boolean { return ( @@ -235,37 +216,3 @@ export async function grantDirAclAsync(dirPath: string): Promise { } ) } - -/** - * Resolve spawn parameters for a command that may be a Windows batch script. - * - * Why: Node's spawn() cannot execute .cmd/.bat files directly without - * shell:true, but shell:true with an args array triggers DEP0190 because - * args are concatenated, not escaped. Routing through cmd.exe /c explicitly - * avoids the deprecation warning while passing args correctly. - * - * Why /d: disables per-machine/user AutoRun registry commands so a background - * spawn cannot inherit surprising side effects from the user's shell config. - * - * SAFETY: when the .cmd/.bat branch is taken, cmd.exe re-parses the command - * line. Args with cmd metacharacters are rejected instead of escaped because - * the agent prompt may contain arbitrary staged diff text. - */ -export function getSpawnArgsForWindows( - command: string, - args: string[] -): { spawnCmd: string; spawnArgs: string[] } { - if (isWindowsBatchScript(command)) { - for (const value of [command, ...args]) { - if (hasUnsafeWindowsBatchSyntax(value)) { - throw new UnsafeWindowsBatchArgumentsError() - } - } - - // Why: when Node passes a pre-quoted command line as one argv entry, - // cmd.exe sees literal escaped quotes on Windows and refuses to run .cmd - // shims. Separate argv entries let Node quote spaces without breaking cmd. - return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } - } - return { spawnCmd: command, spawnArgs: args } -} diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index ba51231c02a..5984fe715af 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -6,9 +6,11 @@ import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' import type { Store } from '../persistence' import type { CreateWorktreeResult, + ReleaseBuildListResult, UpdateCheckOptions, WorktreeStartupLaunch } from '../../shared/types' +import { RELEASE_CHANNELS, type ReleaseChannel } from '../../shared/release-channel' import { acknowledgePendingTccPromptNotice, consumePendingTccPromptNotice, @@ -38,6 +40,7 @@ import { setupAutoUpdater, dismissAvailableUpdate, dismissNudge, + listAvailableReleaseBuilds, type UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' @@ -175,6 +178,7 @@ export function attachMainWindowServices( setDismissedUpdateNudgeId: (id) => { store.updateUI({ dismissedUpdateNudgeId: id }) }, + getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null, installMode: options?.updateInstallMode }) logStartupMilestone('updater-setup-done') @@ -526,6 +530,7 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.removeHandler('updater:quitAndInstall') ipcMain.removeHandler('updater:dismissNudge') ipcMain.removeHandler('updater:dismissAvailableUpdate') + ipcMain.removeHandler('updater:listBuilds') ipcMain.handle('updater:getStatus', () => getUpdateStatus()) ipcMain.handle('updater:getVersion', () => app.getVersion()) @@ -537,4 +542,19 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.handle('updater:quitAndInstall', () => quitAndInstall()) ipcMain.handle('updater:dismissNudge', () => dismissNudge()) ipcMain.handle('updater:dismissAvailableUpdate', () => dismissAvailableUpdate()) + ipcMain.handle( + 'updater:listBuilds', + async (_event, channel: ReleaseChannel): Promise => { + if (!RELEASE_CHANNELS.includes(channel)) { + return { ok: false, channel, message: `Unknown release channel "${channel}".` } + } + try { + return { ok: true, channel, builds: await listAvailableReleaseBuilds(channel) } + } catch (error) { + // Why: a network/rate-limit failure is expected here; return it as data so + // the picker can render the reason instead of rejecting the invoke. + return { ok: false, channel, message: String((error as Error)?.message ?? error) } + } + } + ) } diff --git a/src/main/wsl-distro-list-output.test.ts b/src/main/wsl-distro-list-output.test.ts new file mode 100644 index 00000000000..aa8e52fb9aa --- /dev/null +++ b/src/main/wsl-distro-list-output.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { filterUserWslDistros, parseWslDistros } from './wsl-distro-list-output' + +describe('WSL distro list output', () => { + it('normalizes NUL-padded output and the default-distro marker', () => { + expect(parseWslDistros('*\0 \0U\0b\0u\0n\0t\0u\0\r\0\n\0')).toEqual(['Ubuntu']) + }) + + it('excludes Docker-managed distros', () => { + expect(filterUserWslDistros(['docker-desktop', 'Ubuntu', 'docker-desktop-data'])).toEqual([ + 'Ubuntu' + ]) + }) +}) diff --git a/src/main/wsl-distro-list-output.ts b/src/main/wsl-distro-list-output.ts new file mode 100644 index 00000000000..53f243d2d1f --- /dev/null +++ b/src/main/wsl-distro-list-output.ts @@ -0,0 +1,11 @@ +export function filterUserWslDistros(distros: string[]): string[] { + return distros.filter((distro) => !distro.toLowerCase().startsWith('docker-desktop')) +} + +export function parseWslDistros(output: string): string[] { + return output + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim().replace(/^\*\s*/, '')) + .filter(Boolean) +} diff --git a/src/main/wsl-distro-retry.ts b/src/main/wsl-distro-retry.ts new file mode 100644 index 00000000000..f5a9922ac68 --- /dev/null +++ b/src/main/wsl-distro-retry.ts @@ -0,0 +1,4 @@ +export function wslDistroListRetryDelayMs(emptyStreak: number): number { + // Provisioning is one-time, so bounded backoff avoids polling docker-desktop-only hosts. + return Math.min(15_000 * 2 ** (emptyStreak - 1), 5 * 60_000) +} diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 597b1e795f4..0f618002b46 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -17,7 +17,12 @@ vi.mock('child_process', async (importOriginal) => { import { _resetWslCachesForTests, + _setWslCachesForTests, + getCachedWslAvailability, getCachedWslDistros, + hasCachedWslAvailability, + hasCachedWslDistros, + isWslAvailable, listWslDistros, listWslDistrosAsync, parseWslPath, @@ -98,6 +103,559 @@ describe('WSL distro discovery cache', () => { vi.useRealTimers() } }) + + // Why: `wsl --install` succeeds and reports zero distros while the distro is + // still being provisioned (or until the required reboot). Caching that empty + // success for the process lifetime is why users saw WSL offered during setup + // and then permanently absent from the terminal picker. + it('retries asynchronous discovery after wsl.exe reports no distros yet', async () => { + vi.useFakeTimers() + execFileMock + .mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, '') + }) + .mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'Ubuntu\n') + }) + + try { + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual([]) + vi.advanceTimersByTime(15_000) + await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + it('retries synchronous discovery after wsl.exe reports no distros yet', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty result must still not let every caller re-spawn a blocking + // wsl.exe; it reuses the same brief negative-cache window as a hard failure. + it('bounds the wsl.exe spawn rate while no distros are installed', () => { + execFileSyncMock.mockReturnValue('') + + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + it('bounds the asynchronous spawn rate while no distros are installed', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, '') + }) + + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual([]) + await expect(listWslDistrosAsync()).resolves.toEqual([]) + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + }) + + it('still caches a non-empty distro list for the process lifetime', () => { + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + withPlatform('win32', () => { + expect(listWslDistros()).toEqual(['Ubuntu']) + expect(listWslDistros()).toEqual(['Ubuntu']) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + // Why: docker-desktop entries filter to zero user distros, which is the same + // "nothing installed yet" state as an empty list — a distro installed later + // must still appear. The answer stays readable as [] so a missing distro is + // still visible to `isKnownMissingDistro`. + it('re-probes a docker-desktop-only machine once a user distro can appear', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('docker-desktop\ndocker-desktop-data\n') + execFileSyncMock.mockReturnValueOnce('docker-desktop\nUbuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(getCachedWslDistros()).toEqual([]) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a docker-desktop-only machine filters to empty on every probe, so a flat + // window would re-spawn wsl.exe every 15s for the whole session. + it('backs off while the list keeps coming back empty', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + // Second empty result doubles the window, so 15s more is not enough. + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(3) + }) + } finally { + vi.useRealTimers() + } + }) + + it('backs off after repeated distro-list failures', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + throw new Error('transient failure') + }) + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: listWslDistrosAsync has no in-flight dedupe, so two probes can resolve out of order. + // The late empty answer must not erase the list the newer probe already found. + it('does not let a late empty probe erase a list a newer probe found', async () => { + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + await withPlatformAsync('win32', async () => { + const stale = listWslDistrosAsync() + const fresh = listWslDistrosAsync() + expect(callbacks).toHaveLength(2) + + callbacks[1](null, 'Ubuntu\n') + callbacks[0](null, '') + + await expect(fresh).resolves.toEqual(['Ubuntu']) + await expect(stale).resolves.toEqual(['Ubuntu']) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + }) + + it('does not let an older non-empty probe overwrite a newer list', async () => { + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + await withPlatformAsync('win32', async () => { + const stale = listWslDistrosAsync() + const fresh = listWslDistrosAsync() + callbacks[1](null, 'Ubuntu\n') + callbacks[0](null, 'Debian\n') + + await expect(fresh).resolves.toEqual(['Ubuntu']) + await expect(stale).resolves.toEqual(['Ubuntu']) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + }) + + // Why: N startup callers each land with the same empty answer; counting each one + // would arm the 5min cap on the first round and hide a distro for that whole time. + it('counts one empty result per window when probes overlap', async () => { + vi.useFakeTimers() + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + const pending = [listWslDistrosAsync(), listWslDistrosAsync(), listWslDistrosAsync()] + expect(callbacks).toHaveLength(3) + for (const callback of callbacks) { + callback(null, '') + } + await Promise.all(pending) + + // One window elapsed, not three doublings, so the next probe runs at 15s. + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an overlapping failure and empty result must count as one retry window. + it('holds the base window when a failure and an empty result overlap', async () => { + vi.useFakeTimers() + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + const failing = listWslDistrosAsync() + const empty = listWslDistrosAsync() + expect(callbacks).toHaveLength(2) + callbacks[0](new Error('transient failure'), '') + callbacks[1](null, '') + await Promise.all([failing, empty]) + + vi.advanceTimersByTime(7_500) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).not.toHaveBeenCalled() + vi.advanceTimersByTime(7_500) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not shorten an empty-list backoff when an overlapping probe fails', async () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + + const empty = listWslDistrosAsync() + const failing = listWslDistrosAsync() + callbacks[0](null, '') + callbacks[1](new Error('transient failure'), '') + await Promise.all([empty, failing]) + + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the cap is the only bound on how long a distro installed mid-session stays + // invisible, so pin it rather than letting the doubling run away. + it('caps the empty-list backoff at five minutes', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + // Windows double to 15/30/60/120/240s, so the 6th would be 480s uncapped. + for (const delayMs of [0, 15_000, 30_000, 60_000, 120_000, 240_000]) { + vi.advanceTimersByTime(delayMs) + listWslDistros() + } + expect(execFileSyncMock).toHaveBeenCalledTimes(6) + vi.advanceTimersByTime(300_000) + listWslDistros() + expect(execFileSyncMock).toHaveBeenCalledTimes(7) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty list is a real probe result and must keep driving the + // `wsl-distro-missing` repair prompt even once stale. Going null instead fails + // open and silently spawns `wsl.exe -d ` for a distro Orca saw was absent. + it('keeps reporting an empty result after it goes stale', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(getCachedWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(getCachedWslDistros()).toEqual([]) + expect(hasCachedWslDistros()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a genuinely missing distro yields a non-empty list, which never expires, + // so repair-required still fires for the case that warrants it. + it('keeps reporting a known distro list indefinitely', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual(['Ubuntu']) + vi.advanceTimersByTime(600_000) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('WSL availability cache', () => { + afterEach(() => { + execFileMock.mockReset() + execFileSyncMock.mockReset() + _resetWslCachesForTests() + }) + + // Why: a cold WSL2 utility-VM boot on a just-installed or just-rebooted + // machine routinely exceeds the 5s probe timeout. Latching false for the + // process lifetime is what makes WSL vanish from the picker after setup. + it('retries availability after a probe timeout instead of latching false', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the probe blocks the main process for up to 5s, so a timeout must not + // let every caller re-spawn it immediately. + it('bounds the probe rate while the failure window is open', () => { + execFileSyncMock.mockImplementation(() => { + // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + it('caches a successful probe for the process lifetime', () => { + execFileSyncMock.mockReturnValueOnce('') + + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(true) + expect(isWslAvailable()).toBe(true) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + // Why: the resolver reads the cached getter, not the probe. Reporting the last + // observed answer keeps the `wsl-unavailable` repair prompt reachable; going null + // on staleness would let git and PTY silently resolve to a WSL that just failed. + it('keeps reporting the last observed answer after it goes stale', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(getCachedWslAvailability()).toBe(false) + expect(hasCachedWslAvailability()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an answer-shaped failure earns a long window, not a session-long latch — + // wsl.exe also exits non-zero while the WSL package is servicing or LxssManager is + // still starting, which is transient. + it.each([ + ['wsl.exe reports WSL unusable', { status: 1 }], + ['wsl.exe is not installed', { code: 'ENOENT' }] + ])('holds a definitive failure far longer than a timeout when %s', (_label, errorShape) => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('definitive failure'), errorShape) + }) + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(getCachedWslAvailability()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(10 * 60_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the probe blocks the main process for up to 5s, so a wedged wsl.exe must not + // be re-probed on every window boundary for the rest of the session. + it('backs off after repeated probe failures', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + // Second failure doubles the window, so the next boundary is not enough. + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a seeded retryable failure must re-probe once its window lapses, the same + // as one observed live — otherwise test setup can hide the latch this fixes. + it('re-probes a seeded retryable failure once its window lapses', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + _setWslCachesForTests({ available: false, availabilityRetryable: true }) + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the caches expire independently and `getWslRepairReason` checks availability + // first, so a definitive failure held for 10min would report `wsl-unavailable` over a + // WSL that just listed a distro. Finding a distro must drop the stale failure. + it.each([ + ['a definitive failure', { status: 1 }], + ['a timeout', { code: 'ETIMEDOUT', status: null, signal: 'SIGTERM' }] + ])('re-probes availability once a distro list succeeds after %s', (_label, errorShape) => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('probe failed'), errorShape) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + + // A distro turns up (WSL finished provisioning / was repaired mid-session). + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + expect(listWslDistros()).toEqual(['Ubuntu']) + + // Without dropping the stale failure this would stay false for 10min. + execFileSyncMock.mockReturnValueOnce('') + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty list re-probes on a 15s-to-5min schedule, so clearing the availability + // failure on every empty probe would re-spawn the blocking 5s probe far too often. + it('does not drop an availability failure for an empty distro list', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('probe failed'), { status: 1 }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + execFileSyncMock.mockReturnValueOnce('') + expect(listWslDistros()).toEqual([]) + expect(getCachedWslAvailability()).toBe(false) + // Still inside the definitive window, so no re-probe was paid. + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps reporting unavailable off Windows without probing', () => { + withPlatform('darwin', () => { + expect(isWslAvailable()).toBe(false) + expect(getCachedWslAvailability()).toBe(false) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + }) }) describe('wsl path helpers', () => { diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 504ac981010..b45c40fe9d3 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -1,5 +1,7 @@ import { execFile, execFileSync } from 'node:child_process' import { parseWslUncPath, toWindowsWslPath } from '../shared/wsl-paths' +import { filterUserWslDistros, parseWslDistros } from './wsl-distro-list-output' +import { wslDistroListRetryDelayMs } from './wsl-distro-retry' export { toWindowsWslPath } from '../shared/wsl-paths' @@ -99,26 +101,66 @@ let wslDistroCache: string[] | null = null // otherwise hide every distro until restart), but repeated failures cannot // re-spawn a blocking wsl.exe on every caller; brief negative caching bounds // the spawn rate on machines where WSL is absent or persistently broken. -const WSL_DISTRO_LIST_FAILURE_TTL_MS = 15_000 -let wslDistroListFailedUntilMs = 0 +// Empty and failed probes back off from 15 seconds to a five-minute cap. +let wslDistroListRetryAfterMs = 0 +let wslDistroListEmptyStreak = 0 +let wslDistroProbeSequence = 0 +let wslDistroCacheSequence = 0 +// Why: availability is a separate, blocking probe. Deliberately not a multiple of the +// renderer's 30s capability TTL, so repeated refreshes don't land on this boundary and +// re-probe every cycle. +const WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS = 45_000 -function normalizeWslListOutput(output: string): string[] { - // Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through - // some Windows shells; strip them before line parsing. - return output - .replaceAll(String.fromCharCode(0), '') - .split(/\r?\n/) - .map((line) => line.trim().replace(/^\*\s*/, '')) - .filter(Boolean) +function armWslDistroListRetry(): void { + const now = Date.now() + // Concurrent completions belong to the retry window already armed by the first result. + if (now < wslDistroListRetryAfterMs) { + return + } + wslDistroListEmptyStreak += 1 + wslDistroListRetryAfterMs = now + wslDistroListRetryDelayMs(wslDistroListEmptyStreak) } -function isUserWslDistro(distro: string): boolean { - return !distro.toLowerCase().startsWith('docker-desktop') +// Why: `wsl --install` reports zero distros while one is still provisioning, so an +// empty answer is transient and must stay retryable — caching it for the process +// lifetime is what made WSL vanish from the picker after setup. It is still cached +// for reads, so a missing distro stays visible to `isKnownMissingDistro`. +function cacheWslDistroList(rawDistros: string[], probeSequence: number): string[] { + const userDistros = filterUserWslDistros(rawDistros) + // An older positive result must not replace the newer lifetime-stable list. + if ( + probeSequence < wslDistroCacheSequence && + (userDistros.length === 0 || (wslDistroCache?.length ?? 0) > 0) + ) { + return wslDistroCache ?? [] + } + // Why: probes overlap and can resolve out of order — a slow pre-registration wsl.exe + // can land after a fast one that already found the distro. A late empty answer must + // not erase that list, or provisioning reverts to "no distros" and backs off again. + if (userDistros.length === 0 && wslDistroCache !== null && wslDistroCache.length > 0) { + return wslDistroCache + } + if (userDistros.length > 0) { + dropStaleWslAvailabilityFailure() + } + wslDistroCacheSequence = probeSequence + wslDistroCache = userDistros + if (wslDistroCache.length === 0) { + armWslDistroListRetry() + } + return wslDistroCache +} + +/** A non-empty list is stable; an empty one re-probes once the retry window elapses. */ +function shouldReuseCachedWslDistros(): boolean { + return ( + wslDistroCache !== null && (wslDistroCache.length > 0 || Date.now() < wslDistroListRetryAfterMs) + ) } export function listWslDistros(): string[] { - if (wslDistroCache) { - return wslDistroCache + if (shouldReuseCachedWslDistros()) { + return wslDistroCache ?? [] } if (process.platform !== 'win32') { @@ -126,27 +168,27 @@ export function listWslDistros(): string[] { return wslDistroCache } - if (Date.now() < wslDistroListFailedUntilMs) { - return [] + if (Date.now() < wslDistroListRetryAfterMs) { + return wslDistroCache ?? [] } try { + const probeSequence = ++wslDistroProbeSequence const output = execFileSync('wsl.exe', ['--list', '--quiet'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }) - wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) - return wslDistroCache + return cacheWslDistroList(parseWslDistros(output), probeSequence) } catch { - wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS - return [] + armWslDistroListRetry() + return wslDistroCache ?? [] } } export async function listWslDistrosAsync(): Promise { - if (wslDistroCache !== null) { - return wslDistroCache + if (shouldReuseCachedWslDistros()) { + return wslDistroCache ?? [] } if (process.platform !== 'win32') { @@ -154,17 +196,17 @@ export async function listWslDistrosAsync(): Promise { return wslDistroCache } - if (Date.now() < wslDistroListFailedUntilMs) { - return [] + if (Date.now() < wslDistroListRetryAfterMs) { + return wslDistroCache ?? [] } try { + const probeSequence = ++wslDistroProbeSequence const output = await execFileUtf8('wsl.exe', ['--list', '--quiet']) - wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) - return wslDistroCache + return cacheWslDistroList(parseWslDistros(output), probeSequence) } catch { - wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS - return [] + armWslDistroListRetry() + return wslDistroCache ?? [] } } @@ -172,6 +214,11 @@ export function hasCachedWslDistros(): boolean { return wslDistroCache !== null } +// Why: report the last observed answer even once it is stale. An empty list is a real +// probe result, so it must keep driving the `wsl-distro-missing` repair prompt; going +// null instead fails open and silently spawns `wsl.exe -d ` for a distro Orca +// last saw was absent. Staleness self-corrects — `listWslDistros` re-probes after the +// retry window and a distro installed since clears the prompt on its own. export function getCachedWslDistros(): string[] | null { return wslDistroCache } @@ -234,20 +281,82 @@ export async function getWslHomeAsync(distro: string): Promise { } } -// Cached WSL availability check — evaluated once per process lifetime -let wslAvailableCache: boolean | null = null +type WslAvailabilityCache = + | { available: true } + /** Not Windows — never re-probed. */ + | { available: false; unsupported: true } + | { available: false; cachedAt: number; retryable: boolean; failures: number } + +function isPermanentWslAvailabilityCache(cache: WslAvailabilityCache): boolean { + // Why: re-check the platform so a cache seeded off-Windows can't suppress a real probe. + return cache.available || ('unsupported' in cache && process.platform !== 'win32') +} + +let wslAvailableCache: WslAvailabilityCache | null = null + +// Why: even a definitive-looking non-zero exit can be transient — wsl.exe reports one +// while the WSL package is servicing or LxssManager is still starting — so nothing +// latches for the whole session; it just waits much longer before paying the probe again. +const WSL_AVAILABILITY_DEFINITIVE_TTL_MS = 10 * 60_000 +const WSL_AVAILABILITY_MAX_RETRY_DELAY_MS = 30 * 60_000 + +// Why: the probe blocks the main process for up to 5s, so a host with a wedged wsl.exe +// must not pay it every window; back off per consecutive failure. +function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: number }): number { + const base = cache.retryable + ? WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS + : WSL_AVAILABILITY_DEFINITIVE_TTL_MS + return Math.min(base * 2 ** (cache.failures - 1), WSL_AVAILABILITY_MAX_RETRY_DELAY_MS) +} + +// Why: a numeric `status` (wsl.exe ran and said no) or ENOENT (not installed) is +// answer-shaped, so it earns a long window rather than the short one a timeout gets. +// Same numeric-status rule as `wslUncDirectoryExists`; neither latches forever. +function isRetryableWslProbeFailure(error: unknown): boolean { + const failure = error as { status?: unknown; code?: unknown } | null + if (typeof failure?.status === 'number') { + return false + } + return failure?.code !== 'ENOENT' +} + +// Why: the two caches expire independently, and `getWslRepairReason` checks availability +// first — so a definitive failure held for 10-30min would report `wsl-unavailable` over a +// WSL that just listed a distro for us. A non-empty list proves wsl.exe ran, so drop the +// stale failure and let the next call re-probe. Non-empty lists are cached for the process +// lifetime, so this cannot re-spawn the blocking probe more than once. +function dropStaleWslAvailabilityFailure(): void { + if (wslAvailableCache && !wslAvailableCache.available && !('unsupported' in wslAvailableCache)) { + wslAvailableCache = null + } +} + +function isWslAvailabilityCacheFresh(cache: WslAvailabilityCache): boolean { + if (isPermanentWslAvailabilityCache(cache)) { + return true + } + if (!('cachedAt' in cache)) { + return false + } + return Date.now() - cache.cachedAt < wslAvailabilityRetryDelayMs(cache) +} /** * Check whether wsl.exe is available and functional on this Windows machine. - * Result is cached for the process lifetime. + * Success caches for the process lifetime; every failure is re-probed eventually, so + * a slow wsl.exe activation on a just-installed or just-rebooted machine cannot latch + * WSL off for the whole session. */ export function isWslAvailable(): boolean { - if (wslAvailableCache !== null) { - return wslAvailableCache + if (wslAvailableCache && isWslAvailabilityCacheFresh(wslAvailableCache)) { + return wslAvailableCache.available } + const previousFailures = + wslAvailableCache && 'failures' in wslAvailableCache ? wslAvailableCache.failures : 0 + if (process.platform !== 'win32') { - wslAvailableCache = false + wslAvailableCache = { available: false, unsupported: true } return false } @@ -256,35 +365,70 @@ export function isWslAvailable(): boolean { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }) - wslAvailableCache = true - } catch { - wslAvailableCache = false + wslAvailableCache = { available: true } + } catch (error) { + wslAvailableCache = { + available: false, + cachedAt: Date.now(), + retryable: isRetryableWslProbeFailure(error), + failures: previousFailures + 1 + } } - return wslAvailableCache + return wslAvailableCache.available } export function hasCachedWslAvailability(): boolean { return wslAvailableCache !== null } +// Why: same contract as the distro getter — report the last observed answer. Going +// null on staleness would drop the `wsl-unavailable` repair prompt and let git and +// PTY silently resolve to a WSL that last failed to respond. `isWslAvailable` is what +// clears it, by re-probing once the retry window lapses. export function getCachedWslAvailability(): boolean | null { - return wslAvailableCache + return wslAvailableCache?.available ?? null } export function _resetWslCachesForTests(): void { wslHomeCache.clear() wslDistroCache = null - wslDistroListFailedUntilMs = 0 + wslDistroListRetryAfterMs = 0 + wslDistroListEmptyStreak = 0 + wslDistroProbeSequence = 0 + wslDistroCacheSequence = 0 wslAvailableCache = null } +// Why: seeded state expires like real state — an `available: false` seed is re-probed +// once its window lapses, and a `distros` seed is filtered and arms the retry window. +// Tests that advance timers past either window must mock child_process. export function _setWslCachesForTests(args: { available?: boolean | null distros?: string[] | null + availabilityRetryable?: boolean }): void { - wslAvailableCache = args.available ?? null - wslDistroCache = args.distros ?? null + wslAvailableCache = + args.available === true + ? { available: true } + : args.available === false + ? { + available: false, + cachedAt: Date.now(), + retryable: args.availabilityRetryable ?? false, + failures: 1 + } + : null + // Why: seed through the real cache path so an empty seed arms the retry window + // too — otherwise a seeded [] lets the next call spawn a real 5s wsl.exe. + wslDistroListRetryAfterMs = 0 + wslDistroListEmptyStreak = 0 + wslDistroProbeSequence = 0 + wslDistroCacheSequence = 0 + wslDistroCache = null + if (args.distros) { + cacheWslDistroList(args.distros, ++wslDistroProbeSequence) + } } function execFileUtf8(command: string, args: string[]): Promise { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f52d07fd770..cdb5000d6cc 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,7 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { BrowserFindSource } from '../shared/browser-find-source' import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, @@ -27,6 +28,7 @@ import type { } from '../shared/local-log-tail-types' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' +import type { ReleaseChannel } from '../shared/release-channel' import type { HostQualifiedDetectedWorktreeResult, LegacyDetectedWorktreeRequest, @@ -254,6 +256,7 @@ import type { StatsSummary, MemorySnapshot, TuiAgent, + ReleaseBuildListResult, UpdateCheckOptions, UpdateStatus, Worktree, @@ -2671,6 +2674,7 @@ export type PreloadApi = { quitAndInstall: () => Promise dismissNudge: () => Promise dismissAvailableUpdate: () => Promise + listBuilds: (channel: ReleaseChannel) => Promise onStatus: (callback: (status: UpdateStatus) => void) => () => void onClearDismissal: (callback: () => void) => () => void } @@ -3113,7 +3117,7 @@ export type PreloadApi = { replyTabClose: (reply: { requestId: string; error?: string }) => void onNewTerminalTab: (callback: () => void) => () => void onFocusBrowserAddressBar: (callback: () => void) => () => void - onFindInBrowserPage: (callback: () => void) => () => void + onFindInBrowserPage: (source: BrowserFindSource, callback: () => void) => () => void onReloadBrowserPage: (callback: () => void) => () => void onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void diff --git a/src/preload/browser-find-subscriptions.test.ts b/src/preload/browser-find-subscriptions.test.ts new file mode 100644 index 00000000000..9e49f5cb4ca --- /dev/null +++ b/src/preload/browser-find-subscriptions.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { createBrowserFindSubscriptions } from './browser-find-subscriptions' + +const FIRST_SOURCE = { + browserPageId: 'page-1', + browserWorkspaceId: 'workspace-1' +} +const SAME_WORKSPACE_SOURCE = { + browserPageId: 'page-2', + browserWorkspaceId: FIRST_SOURCE.browserWorkspaceId +} +const SAME_PAGE_SOURCE = { + browserPageId: FIRST_SOURCE.browserPageId, + browserWorkspaceId: 'workspace-2' +} + +describe('browser Find subscriptions', () => { + it('dispatches only to the exact browser page and workspace owner', () => { + const subscriptions = createBrowserFindSubscriptions() + const firstCallback = vi.fn() + const sameWorkspaceCallback = vi.fn() + const samePageCallback = vi.fn() + subscriptions.subscribe(FIRST_SOURCE, firstCallback) + subscriptions.subscribe(SAME_WORKSPACE_SOURCE, sameWorkspaceCallback) + subscriptions.subscribe(SAME_PAGE_SOURCE, samePageCallback) + + subscriptions.dispatch(FIRST_SOURCE) + + expect(firstCallback).toHaveBeenCalledOnce() + expect(sameWorkspaceCallback).not.toHaveBeenCalled() + expect(samePageCallback).not.toHaveBeenCalled() + }) + + it('rejects malformed and partial source identities', () => { + const subscriptions = createBrowserFindSubscriptions() + const callback = vi.fn() + subscriptions.subscribe(FIRST_SOURCE, callback) + + subscriptions.dispatch(undefined) + subscriptions.dispatch({ browserPageId: FIRST_SOURCE.browserPageId }) + subscriptions.dispatch({ + browserPageId: FIRST_SOURCE.browserPageId, + browserWorkspaceId: '' + }) + + expect(callback).not.toHaveBeenCalled() + }) + + it('drops empty ownership entries when subscribers clean up', () => { + const subscriptions = createBrowserFindSubscriptions() + const callback = vi.fn() + const unsubscribe = subscriptions.subscribe(FIRST_SOURCE, callback) + + unsubscribe() + subscriptions.dispatch(FIRST_SOURCE) + + expect(callback).not.toHaveBeenCalled() + }) +}) diff --git a/src/preload/browser-find-subscriptions.ts b/src/preload/browser-find-subscriptions.ts new file mode 100644 index 00000000000..2a3a984bd72 --- /dev/null +++ b/src/preload/browser-find-subscriptions.ts @@ -0,0 +1,51 @@ +import { isBrowserFindSource, type BrowserFindSource } from '../shared/browser-find-source' + +type BrowserFindCallback = () => void + +export function createBrowserFindSubscriptions(): { + dispatch: (source: unknown) => void + subscribe: (source: BrowserFindSource, callback: BrowserFindCallback) => () => void +} { + const callbacksByWorkspace = new Map>>() + + return { + dispatch: (source) => { + if (!isBrowserFindSource(source)) { + return + } + const callbacks = callbacksByWorkspace + .get(source.browserWorkspaceId) + ?.get(source.browserPageId) + if (!callbacks) { + return + } + for (const callback of callbacks) { + callback() + } + }, + subscribe: (source, callback) => { + let callbacksByPage = callbacksByWorkspace.get(source.browserWorkspaceId) + if (!callbacksByPage) { + callbacksByPage = new Map() + callbacksByWorkspace.set(source.browserWorkspaceId, callbacksByPage) + } + let callbacks = callbacksByPage.get(source.browserPageId) + if (!callbacks) { + callbacks = new Set() + callbacksByPage.set(source.browserPageId, callbacks) + } + callbacks.add(callback) + + return () => { + callbacks.delete(callback) + if (callbacks.size > 0) { + return + } + callbacksByPage.delete(source.browserPageId) + if (callbacksByPage.size === 0) { + callbacksByWorkspace.delete(source.browserWorkspaceId) + } + } + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fa42ae400fe..9da862f485b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -211,6 +211,7 @@ import type { PreloadApi } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' +import { createBrowserFindSubscriptions } from './browser-find-subscriptions' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' import type { @@ -457,6 +458,11 @@ document.addEventListener( ) const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' +const browserFindSubscriptions = createBrowserFindSubscriptions() + +ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { + browserFindSubscriptions.dispatch(source) +}) // Custom APIs for renderer const api = { @@ -2946,6 +2952,7 @@ const api = { download: () => ipcRenderer.invoke('updater:download'), dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), + listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), quitAndInstall: async (): Promise => { await prepareRendererForAppRestart(window, { startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, @@ -3579,11 +3586,7 @@ const api = { ipcRenderer.on('ui:focusBrowserAddressBar', listener) return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener) }, - onFindInBrowserPage: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:findInBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:findInBrowserPage', listener) - }, + onFindInBrowserPage: browserFindSubscriptions.subscribe, onReloadBrowserPage: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:reloadBrowserPage', listener) diff --git a/src/relay/dispatcher-frame-guard-regressions.test.ts b/src/relay/dispatcher-frame-guard-regressions.test.ts new file mode 100644 index 00000000000..71859e05c2f --- /dev/null +++ b/src/relay/dispatcher-frame-guard-regressions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { RelayDispatcher } from './dispatcher' +import type { JsonRpcNotification } from './protocol' + +type DispatcherInternals = { + primaryClient: object + estimateFrameBytes: (msg: JsonRpcNotification) => number + enqueueFrame: (client: object, msg: JsonRpcNotification, lane: string) => boolean +} + +describe('RelayDispatcher frame guards', () => { + it('returns zero for invalid active-client limits without encoding', () => { + const dispatcher = new RelayDispatcher(() => true, { + writableHighWaterMark: () => 1024 * 1024, + writableLength: () => 0 + }) + try { + const spy = vi.spyOn(dispatcher as unknown as DispatcherInternals, 'estimateFrameBytes') + for (const limit of [0, -1, Number.NaN]) { + expect(dispatcher.maxLegacyPtyDataChars({ id: 'pty-1' }, 'hello', limit)).toBe(0) + } + expect(spy).not.toHaveBeenCalled() + } finally { + dispatcher.dispose() + } + }) + + it('does not estimate frames after disposal', () => { + const dispatcher = new RelayDispatcher(() => true) + const internals = dispatcher as unknown as DispatcherInternals + const spy = vi.spyOn(internals, 'estimateFrameBytes') + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { + data: { + toJSON: () => { + throw new Error('must not serialize') + } + } + } + } + dispatcher.dispose() + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index aaa083b7743..3ec57dcf899 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' +import { relayWriterControlReserve } from './dispatcher-writer-admission' import { encodeJsonRpcFrame, encodeKeepAliveFrame, @@ -716,4 +717,196 @@ describe('RelayDispatcher', () => { } }) }) + + describe('legacy PTY chunk sizing', () => { + type DispatcherInternals = { + primaryClient: object + estimateFrameBytes: (msg: JsonRpcNotification) => number + enqueueFrame: ( + client: object, + msg: JsonRpcNotification, + lane: string, + onSettled?: (result: SinkWriteSettlement) => void, + estimatedBytes?: number + ) => boolean + } + + // Pre-optimization sizing loop, kept verbatim for differential verification. + function referenceMaxChars( + capacities: number[], + params: Record, + data: string, + limit: number + ): number { + if (capacities.length === 0) { + return Math.min(data.length, limit) + } + let low = 0 + let high = Math.min(data.length, limit) + while (low < high) { + const mid = Math.ceil((low + high) / 2) + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { ...params, data: data.slice(0, mid) } + } + const bytes = encodeJsonRpcFrame(msg, 0, 0).length + if (capacities.every((capacity) => bytes <= capacity)) { + low = mid + } else { + high = mid - 1 + } + } + return low + } + + function makeDispatcher(highWaterMarks: number[]): { + sized: RelayDispatcher + capacities: number[] + } { + const [primaryHwm, ...rest] = highWaterMarks + const sized = new RelayDispatcher(() => true, { + writableHighWaterMark: () => primaryHwm, + writableLength: () => 0 + }) + for (const hwm of rest) { + sized.attachClient(() => true, { + writableHighWaterMark: () => hwm, + writableLength: () => 0 + }) + } + return { + sized, + capacities: highWaterMarks.map((hwm) => Math.max(0, hwm - relayWriterControlReserve(hwm))) + } + } + + function mulberry32(seed: number): () => number { + let a = seed + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } + + // Multi-byte UTF-8, astral pairs, lone surrogates, controls, quotes, and backslashes. + const alphabet = 'aZ9 "\\\n\r\té߀中𝄞😀𐀀�' + + it('matches the pre-optimization sizing loop across randomized inputs', () => { + const random = mulberry32(0xc0ffee) + const hwmChoices = [1030, 1100, 1250, 1400, 2048, 1024 * 1024] + for (let trial = 0; trial < 300; trial++) { + const length = Math.floor(random() * 240) + let data = '' + for (let i = 0; i < length; i++) { + data += alphabet[Math.floor(random() * alphabet.length)] + } + const clientCount = 1 + Math.floor(random() * 2) + const hwms = Array.from( + { length: clientCount }, + () => hwmChoices[Math.floor(random() * hwmChoices.length)] + ) + const params = { id: `pty-${trial}`, seq: trial } + const { sized, capacities } = makeDispatcher(hwms) + try { + for (const limit of [0, 1, Math.floor(length / 2), length, length + 17]) { + expect(sized.maxLegacyPtyDataChars(params, data, limit)).toBe( + referenceMaxChars(capacities, params, data, limit) + ) + } + expect(sized.maxLegacyPtyDataChars(params, data)).toBe( + referenceMaxChars(capacities, params, data, data.length) + ) + } finally { + sized.dispose() + } + } + }) + + it('sizes a chunk that fits every client with a single frame encode', () => { + const { sized } = makeDispatcher([1024 * 1024]) + try { + const spy = vi.spyOn(sized as unknown as DispatcherInternals, 'estimateFrameBytes') + const data = 'x'.repeat(16 * 1024) + expect(sized.maxLegacyPtyDataChars({ id: 'pty-1' }, data)).toBe(data.length) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + sized.dispose() + } + }) + + it('publishes PTY data with a single frame estimate', () => { + const frames: Buffer[] = [] + const publisher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + const spy = vi.spyOn(publisher as unknown as DispatcherInternals, 'estimateFrameBytes') + expect(publisher.tryNotifyPtyData({ id: 'pty-1', data: 'hello' })).toBe(true) + expect(frames).toHaveLength(1) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + publisher.dispose() + } + }) + + it('enqueueFrame with a caller-supplied estimate matches the computed default', () => { + const frames: Buffer[] = [] + const publisher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + const internals = publisher as unknown as DispatcherInternals + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-1', data: 'héllo "𝄞"\\\n\uD800' } + } + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(true) + expect( + internals.enqueueFrame( + internals.primaryClient, + msg, + 'ordinary', + undefined, + internals.estimateFrameBytes(msg) + ) + ).toBe(true) + expect(frames).toHaveLength(2) + expect( + decodeFirstFrame(frames[1]).payload.equals(decodeFirstFrame(frames[0]).payload) + ).toBe(true) + } finally { + publisher.dispose() + } + }) + + it('enqueueFrame rejects identically with and without a caller-supplied estimate', () => { + const { sized } = makeDispatcher([1030]) + try { + const internals = sized as unknown as DispatcherInternals + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-1', data: 'x'.repeat(512) } + } + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) + expect( + internals.enqueueFrame( + internals.primaryClient, + msg, + 'ordinary', + undefined, + internals.estimateFrameBytes(msg) + ) + ).toBe(false) + } finally { + sized.dispose() + } + }) + }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 833081c65fa..3809b9f5f80 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -193,20 +193,34 @@ export class RelayDispatcher { limit = data.length ): number { const clients = this.activeClients() + const max = Math.min(data.length, limit) if (clients.length === 0) { - return Math.min(data.length, limit) + return max } - let low = 0 - let high = Math.min(data.length, limit) - while (low < high) { - const mid = Math.ceil((low + high) / 2) - const msg: JsonRpcNotification = { + if (!(max > 0)) { + return 0 + } + const fitsAll = (bytes: number): boolean => + clients.every((client) => bytes <= client.writer.producerFrameCapacity) + const sizeFrame = (chunk: string): number => + this.estimateFrameBytes({ jsonrpc: '2.0', method: 'pty.data', - params: { ...params, data: data.slice(0, mid) } - } - const bytes = this.estimateFrameBytes(msg) - if (clients.every((client) => bytes <= client.writer.producerFrameCapacity)) { + params: { ...params, data: chunk } + }) + // Fast path: the whole chunk usually fits — one encode instead of log2(n). + if (fitsAll(sizeFrame(data.slice(0, max)))) { + return max + } + // Exact per-step size: only the escaped data string varies; its quotes are in baseBytes. + const baseBytes = sizeFrame('') + const bytesFor = (chars: number): number => + baseBytes + Buffer.byteLength(JSON.stringify(data.slice(0, chars))) - 2 + let low = 0 + let high = max + while (low < high) { + const mid = Math.ceil((low + high) / 2) + if (fitsAll(bytesFor(mid))) { low = mid } else { high = mid - 1 @@ -822,19 +836,21 @@ export class RelayDispatcher { client: RelayClient, msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification, lane: DispatcherWriterLane, - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + // Why: publish paths already sized the frame; avoid a redundant encode. + estimatedBytes?: number ): boolean { if (this.disposed || client.closed) { return false } - const estimatedBytes = this.estimateFrameBytes(msg) + const frameBytes = estimatedBytes ?? this.estimateFrameBytes(msg) return client.writer.enqueue( lane, () => { const seq = client.nextOutgoingSeq++ return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) }, - estimatedBytes, + frameBytes, onSettled ) } @@ -898,7 +914,7 @@ export class RelayDispatcher { return false } for (let index = 0; index < clients.length; index++) { - if (!this.enqueueLeasedFrame(clients[index], msg, lane, leases[index])) { + if (!this.enqueueLeasedFrame(clients[index], msg, lane, leases[index], bytes)) { if (this.disposed || clients[index].closed) { continue } @@ -949,7 +965,7 @@ export class RelayDispatcher { if (!leases) { return false } - return this.enqueueLeasedFrame(client, msg, lane, leases[0], onSettled) + return this.enqueueLeasedFrame(client, msg, lane, leases[0], bytes, onSettled) } private publishBulkWhenAvailable(client: RelayClient, msg: JsonRpcNotification): Promise { @@ -998,13 +1014,20 @@ export class RelayDispatcher { msg: JsonRpcNotification, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', lease: LegacyPublicationLease, + estimatedBytes: number, onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { - const accepted = this.enqueueFrame(client, msg, lane, (result) => { - lease.release() - onSettled(result) - this.notifyLegacyCapacityIfLow() - }) + const accepted = this.enqueueFrame( + client, + msg, + lane, + (result) => { + lease.release() + onSettled(result) + this.notifyLegacyCapacityIfLow() + }, + estimatedBytes + ) if (!accepted) { lease.release() this.notifyLegacyCapacityIfLow() diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 9b90692cd28..4f5cf7cac2b 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -72,9 +72,10 @@ describe('PluginOverlayManager', () => { it('installs Pi extension into the real agent extensions dir', () => { manager.setSources({ piExtensionSource: '// pi extension' }) - const dir = manager.materializePi('tab-2:0') - expect(dir).not.toBeNull() - const file = join(dir!, 'extensions', 'orca-agent-status.ts') + const result = manager.materializePi('tab-2:0') + expect(result?.sourceAgentDir).toBeDefined() + const file = join(result!.sourceAgentDir!, 'extensions', 'orca-agent-status.ts') + expect(result?.statusExtensionPath).toBe(file) expect(existsSync(file)).toBe(true) expect(readFileSync(file, 'utf8')).toContain('@orca-managed-pi-extension') }) @@ -96,17 +97,17 @@ describe('PluginOverlayManager', () => { ompExtensionSource: '// omp extension' }) - const piDir = manager.materializePi('tab-kind-pi:0', undefined, 'pi') - const ompDir = manager.materializePi('tab-kind-omp:0', undefined, 'omp') + const piResult = manager.materializePi('tab-kind-pi:0', undefined, 'pi') + const ompResult = manager.materializePi('tab-kind-omp:0', undefined, 'omp') - expect(piDir).not.toBeNull() - expect(ompDir).not.toBeNull() - expect(readFileSync(join(piDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toContain( - '// pi extension' - ) - expect(readFileSync(join(ompDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toContain( - '// omp extension' - ) + expect(piResult?.sourceAgentDir).toBeDefined() + expect(ompResult?.sourceAgentDir).toBeDefined() + expect( + readFileSync(join(piResult!.sourceAgentDir!, 'extensions', 'orca-agent-status.ts'), 'utf8') + ).toContain('// pi extension') + expect( + readFileSync(join(ompResult!.sourceAgentDir!, 'extensions', 'orca-agent-status.ts'), 'utf8') + ).toContain('// omp extension') }) it('installs Orca status extension into the remote default Pi agent dir', () => { @@ -129,9 +130,10 @@ describe('PluginOverlayManager', () => { ) manager.setSources({ piExtensionSource: '// pi extension' }) - const dir = manager.materializePi('tab-pi:0') + const result = manager.materializePi('tab-pi:0') + const dir = result?.sourceAgentDir - expect(dir).not.toBeNull() + expect(dir).toBeDefined() expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('secret token') expect(readFileSync(join(dir!, 'skills', 'my-skill', 'SKILL.md'), 'utf8')).toBe( 'critical user skill' @@ -171,9 +173,10 @@ describe('PluginOverlayManager', () => { writeFileSync(join(customAgentDir, 'extensions', 'custom.ts'), 'custom extension') manager.setSources({ piExtensionSource: '// pi extension' }) - const dir = manager.materializePi('tab-custom-pi:0', customAgentDir) + const result = manager.materializePi('tab-custom-pi:0', customAgentDir) + const dir = result?.sourceAgentDir - expect(dir).not.toBeNull() + expect(dir).toBeDefined() expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('custom token') expect(readFileSync(join(dir!, 'extensions', 'custom.ts'), 'utf8')).toBe('custom extension') expect(readFileSync(join(dir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toContain( @@ -184,10 +187,9 @@ describe('PluginOverlayManager', () => { it('leaves lazy OMP agent.db in the real remote home on the relay', () => { manager.setSources({ piExtensionSource: '// pi extension' }) const sourceDir = join(homeDir, '.omp', 'agent') - const firstDir = manager.materializePi('tab-relay-omp-sqlite:0', undefined, 'omp') + const first = manager.materializePi('tab-relay-omp-sqlite:0', undefined, 'omp') - expect(firstDir).not.toBeNull() - expect(firstDir).toBe(sourceDir) + expect(first?.sourceAgentDir).toBe(sourceDir) const sourcePath = join(sourceDir, 'agent.db') const content = 'agent.db relay credentials' @@ -198,10 +200,10 @@ describe('PluginOverlayManager', () => { expect(readFileSync(sourcePath, 'utf8')).toBe(content) - const secondDir = manager.materializePi('tab-relay-omp-sqlite:0', undefined, 'omp') + const second = manager.materializePi('tab-relay-omp-sqlite:0', undefined, 'omp') - expect(secondDir).toBe(firstDir) - expect(readFileSync(join(secondDir!, 'agent.db'), 'utf8')).toBe(content) + expect(second?.sourceAgentDir).toBe(first?.sourceAgentDir) + expect(readFileSync(join(second!.sourceAgentDir!, 'agent.db'), 'utf8')).toBe(content) }) // Why: per-agent source dir. The renderer picks Pi or OMP per @@ -222,9 +224,9 @@ describe('PluginOverlayManager', () => { seedAgentDir('.omp', 'omp') manager.setSources({ piExtensionSource: '// pi extension' }) - const dir = manager.materializePi('tab-relay-pi-both:0', undefined, 'pi') + const result = manager.materializePi('tab-relay-pi-both:0', undefined, 'pi') + const dir = result?.sourceAgentDir - expect(dir).not.toBeNull() expect(dir).toBe(join(homeDir, '.pi', 'agent')) expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('pi token') const extensions = readdirSync(join(dir!, 'extensions')).sort() @@ -238,9 +240,9 @@ describe('PluginOverlayManager', () => { seedAgentDir('.omp', 'omp') manager.setSources({ piExtensionSource: '// pi extension' }) - const dir = manager.materializePi('tab-relay-omp-both:0', undefined, 'omp') + const result = manager.materializePi('tab-relay-omp-both:0', undefined, 'omp') + const dir = result?.sourceAgentDir - expect(dir).not.toBeNull() expect(dir).toBe(join(homeDir, '.omp', 'agent')) // Even though ~/.pi/agent exists, the OMP launch MUST mirror OMP's // source dir. Cross-agent fallback would silently shadow the user's @@ -259,15 +261,42 @@ describe('PluginOverlayManager', () => { expect(existsSync(join(homeDir, '.omp'))).toBe(false) manager.setSources({ piExtensionSource: '// pi extension' }) - const dir = manager.materializePi('tab-relay-omp-empty:0', undefined, 'omp') + const result = manager.materializePi('tab-relay-omp-empty:0', undefined, 'omp') + const dir = result?.sourceAgentDir - expect(dir).not.toBeNull() expect(dir).toBe(join(homeDir, '.omp', 'agent')) // Pi-only home must NOT leak into the OMP home. expect(existsSync(join(dir!, 'auth.json'))).toBe(false) const extensions = readdirSync(join(dir!, 'extensions')).sort() expect(extensions).toEqual(['orca-agent-status.ts']) }) + + it('bare-shell prep does not create missing remote agent homes (#10196)', () => { + expect(existsSync(join(homeDir, '.pi'))).toBe(false) + expect(existsSync(join(homeDir, '.omp'))).toBe(false) + manager.setSources({ + piExtensionSource: '// pi extension', + ompExtensionSource: '// omp extension' + }) + + expect( + manager.materializePi('tab-bare-pi:0', undefined, 'pi', { + materializeDefaultHome: false + }) + ).toBeNull() + const bareOmp = manager.materializePi('tab-bare-omp:0', undefined, 'omp', { + materializeDefaultHome: false + }) + // Why: bare OMP keeps status via ~/.orca-relay/… without SOURCE_AGENT_DIR or ~/.omp. + expect(bareOmp?.sourceAgentDir).toBeUndefined() + expect(bareOmp?.statusExtensionPath).toEqual( + expect.stringContaining(join('.orca-relay', 'omp-managed-status-extension')) + ) + expect(existsSync(bareOmp!.statusExtensionPath!)).toBe(true) + expect(readFileSync(bareOmp!.statusExtensionPath!, 'utf8')).toContain('// omp extension') + expect(existsSync(join(homeDir, '.pi'))).toBe(false) + expect(existsSync(join(homeDir, '.omp'))).toBe(false) + }) }) it('does not override a missing preexisting Pi agent dir', () => { @@ -283,8 +312,8 @@ describe('PluginOverlayManager', () => { ompExtensionSource: 'omp' }) const opencodeDir = manager.materializeOpenCode('tab-3:0')! - const piDir = manager.materializePi('tab-3:0', undefined, 'pi')! - const ompDir = manager.materializePi('tab-3:0', undefined, 'omp')! + const piDir = manager.materializePi('tab-3:0', undefined, 'pi')!.sourceAgentDir! + const ompDir = manager.materializePi('tab-3:0', undefined, 'omp')!.sourceAgentDir! expect(piDir).not.toBe(ompDir) expect(existsSync(opencodeDir)).toBe(true) expect(existsSync(piDir)).toBe(true) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index ada88830bda..b5a0cb24963 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -41,6 +41,9 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' +// Why: bare-shell OMP still needs ORCA_OMP_STATUS_EXTENSION without mkdir ~/.omp. +// Mirror local userData/omp-managed-status-extension under the relay home root. +const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension' function withOrcaManagedPiExtensionMarker(source: string): string { @@ -79,8 +82,12 @@ export type PluginSources = { ompExtensionSource?: string } -export function getRelayPiStatusExtensionPath(agentDir: string): string { - return join(agentDir, 'extensions', PI_EXTENSION_FILE) +/** Result of installing Pi/OMP status into a real agent home or OMP fallback path. */ +export type MaterializePiResult = { + /** Real agent dir when extensions were installed there. Absent for OMP status-only fallback. */ + sourceAgentDir?: string + /** Absolute path to orca-agent-status.ts (real home or relay-managed fallback). */ + statusExtensionPath?: string } /** Presence of this file is what makes an overlay usable — a rebuild that failed @@ -236,10 +243,38 @@ export class PluginOverlayManager { } } - /** Install the Pi/OMP status extension into the remote real agent dir and - * return that directory. `kind` selects which Pi-compatible agent's default - * dir to use when `existingAgentDir` is not supplied. */ - materializePi(id: string, existingAgentDir?: string, kind: PiAgentKind = 'pi'): string | null { + private writeOmpManagedStatusExtension(extensionSource: string): string | null { + const fallbackDir = join(this.homeDir, RELAY_HOOKS_DIR, OMP_MANAGED_STATUS_EXTENSION_DIR) + try { + mkdirSync(fallbackDir, { recursive: true }) + const fallbackPath = join(fallbackDir, PI_EXTENSION_FILE) + if (!this.canOverwritePiExtension(fallbackPath)) { + return null + } + writeFileSync(fallbackPath, extensionSource) + return fallbackPath + } catch (err) { + process.stderr.write( + `[plugin-overlay] failed to write OMP managed status extension: ${err instanceof Error ? err.message : String(err)}\n` + ) + return null + } + } + + /** Install the Pi/OMP status extension into the remote real agent dir. + * `kind` selects which Pi-compatible agent's default dir to use when + * `existingAgentDir` is not supplied. + * + * When `materializeDefaultHome` is false (bare shells), missing default + * homes are left alone so unused agents do not recreate `~/.` (#10196). + * For OMP, a relay-owned status file is still written so bare shells can + * export ORCA_OMP_STATUS_EXTENSION without ORCA_OMP_SOURCE_AGENT_DIR. */ + materializePi( + id: string, + existingAgentDir?: string, + kind: PiAgentKind = 'pi', + options?: { materializeDefaultHome?: boolean } + ): MaterializePiResult | null { const extensionSource = this.getPiExtensionSource(kind) if (!extensionSource || !isUsableId(id)) { return null @@ -249,6 +284,16 @@ export class PluginOverlayManager { if (existingAgentDir && !existsSync(existingAgentDir)) { return null } + const materializeDefaultHome = options?.materializeDefaultHome !== false + if (!existingAgentDir && !existsSync(sourceAgentDir) && !materializeDefaultHome) { + // Why: match local titlebar-extension-service bare-shell OMP policy — + // status wrapper only, never mkdir ~/.omp for unused agents. + if (kind === 'omp') { + const statusExtensionPath = this.writeOmpManagedStatusExtension(extensionSource) + return statusExtensionPath ? { statusExtensionPath } : null + } + return null + } const extensionsDir = join(sourceAgentDir, 'extensions') mkdirSync(extensionsDir, { recursive: true }) const extensionPath = join(extensionsDir, PI_EXTENSION_FILE) @@ -256,7 +301,10 @@ export class PluginOverlayManager { return null } writeFileSync(extensionPath, extensionSource) - return sourceAgentDir + return { + sourceAgentDir, + statusExtensionPath: extensionPath + } } catch (err) { process.stderr.write( `[plugin-overlay] failed to install ${kind} extension: ${err instanceof Error ? err.message : String(err)}\n` diff --git a/src/relay/pty-handler-source-publication.test.ts b/src/relay/pty-handler-source-publication.test.ts index a99610671ec..282d9f536db 100644 --- a/src/relay/pty-handler-source-publication.test.ts +++ b/src/relay/pty-handler-source-publication.test.ts @@ -56,6 +56,11 @@ describe('PtyHandler negotiated source publication', () => { let heldResponseSettlements: ((result: SinkWriteSettlement) => void)[] let adapter: SshPtyConsumerSessionAdapter let pausePty: ReturnType + let exitCallback: ((event: { exitCode: number }) => void) | undefined + let destroyPty: ReturnType + let holdDataSettlements: boolean + let heldDataSettlements: ((result: SinkWriteSettlement) => void)[] + let highWaterMark: number | undefined beforeEach(async () => { vi.useFakeTimers() @@ -64,20 +69,28 @@ describe('PtyHandler negotiated source publication', () => { heldResponseId = null heldResponseSettlements = [] dataCallback = undefined + exitCallback = undefined + holdDataSettlements = false + heldDataSettlements = [] + highWaterMark = undefined pausePty = vi.fn() + destroyPty = vi.fn() mockPtySpawn.mockReset() mockPtySpawn.mockReturnValue({ pid: process.pid, onData: vi.fn((callback: (data: string) => void) => { dataCallback = callback }), - onExit: vi.fn(), + onExit: vi.fn((callback: (event: { exitCode: number }) => void) => { + exitCallback = callback + }), write: vi.fn(), resize: vi.fn(), kill: vi.fn(), clear: vi.fn(), pause: pausePty, - resume: vi.fn() + resume: vi.fn(), + destroy: destroyPty }) dispatcher = new RelayDispatcher( (data, settle) => { @@ -86,10 +99,24 @@ describe('PtyHandler negotiated source publication', () => { heldResponseSettlements.push(settle) return true } + const frame = notification(data) + if (holdDataSettlements && frame?.method === 'pty.data') { + heldDataSettlements.push(settle) + return true + } + if (frame?.method === 'pty.exit') { + // Why: real sockets never settle inside write(); a synchronous settle would re-enter + // the legacy capacity path before the pending exit is retired. + queueMicrotask(() => settle({ ok: true })) + return true + } settle({ ok: true }) return true }, - { supportsWriteCallback: true }, + { + supportsWriteCallback: true, + writableHighWaterMark: () => highWaterMark ?? 0 + }, endpointIdentity ) handler = new PtyHandler(dispatcher) @@ -132,6 +159,133 @@ describe('PtyHandler negotiated source publication', () => { .filter((frame): frame is Notification => frame?.method === 'pty.data') } + function exitFrames(): Notification[] { + return writes + .map(notification) + .filter((frame): frame is Notification => frame?.method === 'pty.exit') + } + + async function cancelSourceDelivery(spawnResult: Record): Promise { + const activation = spawnResult.sourceActivation as Record + dispatcher.feed( + requestFrame(9, 'pty.cancelDelivery', { + id: spawnResult.id, + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken + }) + ) + await vi.advanceTimersByTimeAsync(0) + } + + function stubPublication(overrides: Partial>): RelayPtySourcePublication { + return { + accepts: () => true, + exitPublicationSettled: () => false, + sealAndPublishExit: () => false, + publish: () => false, + onCreditAvailable: () => {}, + receivingActivation: () => undefined, + waitForPendingSend: async () => true, + activate: () => false, + getDebugSnapshot: () => ({}), + dispose: () => {}, + ...overrides + } as unknown as RelayPtySourcePublication + } + + it('publishes a single legacy exit after the owner cancels its delivery', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + dataCallback!('prompt') + await vi.advanceTimersByTimeAsync(8) + await cancelSourceDelivery(spawnResult) + expect(publication.accepts(String(spawnResult.id))).toBe(false) + + expect(() => exitCallback!({ exitCode: 0 })).not.toThrow() + + expect(exitFrames()).toHaveLength(1) + expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 0 }) + expect(destroyPty).toHaveBeenCalledOnce() + + handler.handleSourcePublicationCapacity(String(spawnResult.id)) + await vi.advanceTimersByTimeAsync(8) + expect(exitFrames()).toHaveLength(1) + }) + + it('drains buffered legacy output before the exit when capacity returns', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + await cancelSourceDelivery(spawnResult) + holdDataSettlements = true + + dataCallback!('first') + await vi.advanceTimersByTimeAsync(8) + expect(heldDataSettlements).toHaveLength(1) + // Why: shrinking the frame capacity makes the next chunk unpublishable, so it stays queued. + highWaterMark = 64 + dataCallback!('second') + await vi.advanceTimersByTimeAsync(8) + + expect(() => exitCallback!({ exitCode: 3 })).not.toThrow() + expect(exitFrames()).toHaveLength(0) + + highWaterMark = undefined + holdDataSettlements = false + heldDataSettlements[0]({ ok: true }) + await vi.advanceTimersByTimeAsync(8) + + const frames = writes + .map(notification) + .filter( + (frame): frame is Notification => + frame?.method === 'pty.data' || frame?.method === 'pty.exit' + ) + expect(frames.map((frame) => frame.method)).toEqual(['pty.data', 'pty.data', 'pty.exit']) + expect(frames.map((frame) => frame.params.data)).toEqual(['first', 'second', undefined]) + expect(frames.at(-1)!.params).toMatchObject({ id: spawnResult.id, code: 3 }) + }) + + it('contains a source publication fault and still delivers the legacy exit', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + handler.setSourcePublication( + stubPublication({ + sealAndPublishExit: () => { + throw new Error('ledger delivery vanished') + } + }) + ) + + expect(() => exitCallback!({ exitCode: 7 })).not.toThrow() + + expect(exitFrames()).toHaveLength(1) + expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 7 }) + expect(String(stderr.mock.calls.at(-1)?.[0])).toContain( + '[pty-handler] pty source exit publication failed' + ) + } finally { + stderr.mockRestore() + } + }) + + it('completes the exit from the settled state without re-sealing the delivery', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + const sealAndPublishExit = vi.fn(() => true) + handler.setSourcePublication( + stubPublication({ exitPublicationSettled: () => true, sealAndPublishExit }) + ) + + exitCallback!({ exitCode: 0 }) + handler.handleSourcePublicationCapacity(String(spawnResult.id)) + + expect(sealAndPublishExit).not.toHaveBeenCalled() + expect(exitFrames()).toHaveLength(0) + }) + it('fences the first source frame behind immutable spawn and attach activation metadata', async () => { heldResponseId = 2 await spawn({}) @@ -301,6 +455,34 @@ describe('PtyHandler negotiated source publication', () => { expect(adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 1 }) }) + it('republishes the identical memoized span after a failed projection', async () => { + await spawn({}) + const appendSpy = vi.spyOn(adapter, 'appendSource') + const projectSpy = vi + .spyOn(dispatcher, 'projectPtyDataToMatchingClients') + .mockReturnValueOnce(false) + // Why: a larger capacity result on the retry must not move the memoized span boundary. + const maxCharsSpy = vi.spyOn(dispatcher, 'maxLegacyPtyDataChars') + maxCharsSpy.mockReturnValueOnce(4).mockReturnValue(8) + + dataCallback!('abcdefgh') + await vi.advanceTimersByTimeAsync(8) + expect(projectSpy).toHaveBeenCalledTimes(1) + + handler.handleSourcePublicationCapacity('pty-1') + await vi.advanceTimersByTimeAsync(8) + + // Retried projection carries the byte-identical span params. + expect(projectSpy.mock.calls[1][1]).toEqual(projectSpy.mock.calls[0][1]) + const spans = appendSpy.mock.calls.map(([, input]) => input) + expect(spans.filter((span) => span.data === 'abcd')).toHaveLength(1) + expect(spans.map((span) => span.data).join('')).toBe('abcdefgh') + expect(spans.map((span) => [span.displayStart, span.displayEnd])).toEqual([ + [0, 4], + [4, 8] + ]) + }) + it('keeps the native PTY and V1 owner live when one subscriber saturates', async () => { await spawn({}) const detached: number[] = [] diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index ab853aed504..c81d2b5a6a1 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1340,6 +1340,123 @@ describe('PtyHandler', () => { ]) }) + describe('legacy flush retry with a memoized source chunk', () => { + let capacityListener: (() => void) | undefined + let hasCapacity: boolean + let maxChars: number + let admitted: Record[] + let dataCallback: ((data: string) => void) | undefined + + async function setupRetryHarness(spawnParams: Record = {}): Promise { + await handler.dispose({ waitForPhysicalExit: false }) + capacityListener = undefined + hasCapacity = false + admitted = [] + Object.assign(dispatcher, { + onLegacyPtyCapacity: vi.fn((listener: () => void) => { + capacityListener = listener + return vi.fn() + }), + tryNotifyPtyData: vi.fn((params: Record) => { + if (hasCapacity) { + admitted.push(params) + } + return hasCapacity + }), + tryNotifyPtyExit: vi.fn(() => true), + legacyRetentionBelowLowWater: true, + // Clamped like the real dispatcher: never more than min(data.length, limit). + maxLegacyPtyDataChars: vi.fn((_params: unknown, data: string, limit?: number) => + Math.min(maxChars, data.length, limit ?? data.length) + ) + }) + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + dataCallback = undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((callback: (data: string) => void) => { + dataCallback = callback + }), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', spawnParams) + expect(dataCallback).toBeDefined() + } + + it('resends the memo verbatim and keeps the coalesced tail when capacity grows', async () => { + maxChars = 5_000 + await setupRetryHarness() + dataCallback!('a'.repeat(20_000)) + await vi.advanceTimersByTimeAsync(8) + // Why before restoring capacity: the failed batch made zero writes so no flush is + // rescheduled; this enqueue re-arms the timer. + dataCallback!('b'.repeat(10)) + hasCapacity = true + maxChars = 16_384 + await vi.runAllTimersAsync() + + expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000) + 'b'.repeat(10)) + expect((admitted[0].data as string).length).toBe(5_000) + // Why: remainder frames must not leak transformed/rawLength/seq keys. + expect(admitted[1]).toStrictEqual({ id: 'pty-1', data: expect.any(String) }) + }) + + it('keeps a tail appended after a failed flush under constant capacity', async () => { + maxChars = 16_384 + await setupRetryHarness() + dataCallback!('a'.repeat(5_000)) + await vi.advanceTimersByTimeAsync(8) + dataCallback!('b'.repeat(10)) + hasCapacity = true + await vi.runAllTimersAsync() + + expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(5_000) + 'b'.repeat(10)) + expect(admitted[1].data).toBe('b'.repeat(10)) + }) + + it('does not duplicate memoized chars when capacity shrinks before the retry', async () => { + maxChars = 16_384 + await setupRetryHarness() + dataCallback!('a'.repeat(20_000)) + await vi.advanceTimersByTimeAsync(8) + maxChars = 5_000 + hasCapacity = true + // Why: nothing else re-arms the flush timer after a zero-write batch. + capacityListener!() + await vi.runAllTimersAsync() + + expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000)) + expect((admitted[0].data as string).length).toBe(16_384) + expect((admitted[1].data as string).length).toBe(3_616) + }) + + it('retains a coalesced transformed raw advance behind a memoized source-only chunk', async () => { + maxChars = 16_384 + await setupRetryHarness({ + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000 + } + }) + // First answered query: direct publish fails, entry queued without a memo. + dataCallback!('\x1b]10;?\x07') + // Capacity event while still blocked: the flush fails and memoizes the source-only chunk. + capacityListener!() + await vi.advanceTimersByTimeAsync(0) + // Second answered query coalesces into the memoized transformed head. + dataCallback!('\x1b]11;?\x07') + hasCapacity = true + capacityListener!() + await vi.runAllTimersAsync() + + expect(admitted).toEqual([ + { id: 'pty-1', data: '', rawLength: 7, seq: 7, transformed: true }, + { id: 'pty-1', data: '', rawLength: 7, seq: 14, transformed: true } + ]) + }) + }) + it('leaves startup queries untouched for an unsupported relay capability version', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) @@ -2271,8 +2388,13 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1') }) - it('passes the PTY id and renderer paneKey to env augmenters', async () => { - const seenContexts: { id: string; paneKey?: string; env: Record }[] = [] + it('passes PTY and explicit launch identity to env augmenters', async () => { + const seenContexts: { + id: string + paneKey?: string + launchAgent?: string + env: Record + }[] = [] handler.addEnvAugmenter((ctx) => { seenContexts.push(ctx) return { @@ -2281,7 +2403,8 @@ describe('PtyHandler', () => { }) await dispatcher.callRequest('pty.spawn', { - env: { ORCA_PANE_KEY: 'tab-context:0' } + env: { ORCA_PANE_KEY: 'tab-context:0' }, + launchAgent: 'pi' }) await dispatcher.callRequest('pty.spawn', {}) @@ -2290,6 +2413,7 @@ describe('PtyHandler', () => { expect(seenContexts[0]).toMatchObject({ id: 'pty-1', paneKey: 'tab-context:0', + launchAgent: 'pi', env: { ORCA_PANE_KEY: 'tab-context:0' } }) expect(seenContexts[1]).toMatchObject({ id: 'pty-2', paneKey: undefined }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 4bd805c153d..c54e1d13e89 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -39,6 +39,7 @@ import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { isTuiAgent } from '../shared/tui-agent-config' +import type { TuiAgent } from '../shared/types' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env' import { @@ -338,6 +339,7 @@ export type PtyEnvAugmenter = (ctx: { shell: string env: Record command?: string + launchAgent?: TuiAgent }) => Record export type RelayPtyWorktreeRemovalCoordinator = { @@ -507,7 +509,13 @@ export class PtyHandler { /** Build augmented spawn env; augmenter values win over process.env/renderer env. Shared by spawn()/revive() so precedence can't drift. */ private buildSpawnEnv( rendererEnv: Record | undefined, - ctx: { id: string; paneKey?: string; shell: string; command?: string }, + ctx: { + id: string + paneKey?: string + shell: string + command?: string + launchAgent?: TuiAgent + }, envToDelete: readonly string[] = [] ): Record { const baseEnv = mergeGitConfigEnvProtocol( @@ -886,10 +894,15 @@ export class PtyHandler { ...(pending.rawLength === undefined ? {} : { rawLength: pending.rawLength }), ...(pending.transformed ? { transformed: true } : {}) } - let chunkChars = pending.transformed - ? desiredChars - : (this.dispatcher.maxLegacyPtyDataChars?.(paramsWithoutData, pending.data, desiredChars) ?? - desiredChars) + // Why: a failed publish may already have reserved this exact span (source-ledger append, + // partial legacy fan-out), so a retry must resend it verbatim and slice the remainder at + // the memo boundary — capacity and coalesced data can both have changed since. The capacity + // search is skipped on retry: its result is discarded, and publish re-checks capacity. + let chunkChars = + pending.transformed || pending.sourceChunk + ? desiredChars + : (this.dispatcher.maxLegacyPtyDataChars?.(paramsWithoutData, pending.data, desiredChars) ?? + desiredChars) if ( chunkChars > 0 && chunkChars < pending.data.length && @@ -906,8 +919,8 @@ export class PtyHandler { this.pausePtyOutput(id) return false } - const chunk = pending.data.slice(0, chunkChars) - const remaining = pending.data.slice(chunkChars) + const chunk = pending.sourceChunk?.data ?? pending.data.slice(0, chunkChars) + const remaining = pending.data.slice(chunk.length) const chunkRawLength = pending.transformed ? pending.rawLength : pending.rawLength === undefined @@ -930,10 +943,16 @@ export class PtyHandler { this.pausePtyOutput(id) return false } - if (remaining) { + // rawLength fallback is defensive only: transformed memos always carry rawLength (ingress meta). + const publishedRawLength = sourceChunk.rawLength ?? sourceChunk.data.length + const remainingRawLength = pending.transformed + ? (pending.rawLength ?? 0) - publishedRawLength + : remaining.length + if (remaining || (pending.transformed && remainingRawLength > 0)) { queue[0] = { data: remaining, - ...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }), + ...(pending.transformed ? { transformed: true } : {}), + ...(pending.rawLength === undefined ? {} : { rawLength: remainingRawLength }), seq: pending.seq } } else { @@ -1011,14 +1030,33 @@ export class PtyHandler { return } if (this.sourcePublication?.accepts(id)) { - if ( - !this.sourcePublication.sealAndPublishExit(exit) || - !this.sourcePublication.exitPublicationSettled(id) - ) { + try { + // Why: after the exit settlement, re-entering sealAndPublishExit would pump a closed + // ledger delivery; the settled state alone decides completion. + if (this.sourcePublication.exitPublicationSettled(id)) { + this.pendingExitByPty.delete(id) + return + } + if (!this.sourcePublication.sealAndPublishExit(exit)) { + return + } + if ( + this.sourcePublication.accepts(id) && + !this.sourcePublication.exitPublicationSettled(id) + ) { + return + } + this.pendingExitByPty.delete(id) return + } catch (err) { + // Why: a source-publication fault must never escape onExit — it reaches + // uncaughtException and kills the whole relay daemon. Fall back to the legacy exit. + process.stderr.write( + `[pty-handler] pty source exit publication failed for ${id}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }\n` + ) } - this.pendingExitByPty.delete(id) - return } const published = this.dispatcher.tryNotifyPtyExit ? this.dispatcher.tryNotifyPtyExit(exit) @@ -1324,16 +1362,21 @@ export class PtyHandler { const terminalHandle = typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined const command = typeof params.command === 'string' ? params.command : undefined + const launchAgent = isTuiAgent(params.launchAgent) ? params.launchAgent : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined - const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }, envToDelete) + const spawnEnv = this.buildSpawnEnv( + env, + { id, paneKey, shell, command, launchAgent }, + envToDelete + ) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) // Why: SSH PTYs bypass main's host-env builder, so apply the guard after the relay merges its authoritative env. const gitCredentialPromptGuarded = applyTerminalGitCredentialPromptGuard(spawnEnv, { launchCommand: launchCommandHint, - isUnattended: isTuiAgent(params.launchAgent), + isUnattended: launchAgent !== undefined, platform: process.platform }) const shouldEmitShellReadyMarker = diff --git a/src/relay/pty-source-credit-availability-notice.ts b/src/relay/pty-source-credit-availability-notice.ts new file mode 100644 index 00000000000..392bab9c775 --- /dev/null +++ b/src/relay/pty-source-credit-availability-notice.ts @@ -0,0 +1,19 @@ +/** + * Guarded fan-out of "this delivery released credit" to the publication layer. + */ +export function notifyPtySourceCreditAvailable( + notify: ((id: string) => void) | undefined, + id: string +): void { + try { + notify?.(id) + } catch (err) { + // Why: this fires from request handlers, detach paths, and a bare grace setTimeout; a + // downstream throw in the timer would reach uncaughtException and kill the daemon. + process.stderr.write( + `[pty-source-credit] credit-available notification failed for ${id}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }\n` + ) + } +} diff --git a/src/relay/pty-source-credit-ledger.test.ts b/src/relay/pty-source-credit-ledger.test.ts index e34ff5b10e0..37ad35a8f1e 100644 --- a/src/relay/pty-source-credit-ledger.test.ts +++ b/src/relay/pty-source-credit-ledger.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' import { RelayPtySourceCreditLedger } from './pty-source-credit-ledger' +import { CLOSED_DELIVERY_TOMBSTONE_LIMIT } from './pty-source-credit-record' function identity( deliveryToken = 'token-1', @@ -501,6 +502,40 @@ describe('RelayPtySourceCreditLedger', () => { expect(ledger.snapshot(owners.at(-1)!)).toMatchObject({ state: 'closed' }) }) + it('probes live, closed, cancelled and evicted deliveries without throwing', () => { + const ledger = new RelayPtySourceCreditLedger() + const live = identity('token-live', { id: 'pty-live' }) + ledger.open(live, 8) + expect(ledger.snapshotIfKnown(live)).toMatchObject({ state: 'active', exitPublished: false }) + + const completed = identity('token-completed', { id: 'pty-completed' }) + ledger.open(completed, 8) + ledger.seal(completed) + ledger.settleExitPublication(completed, { ok: true }) + expect(ledger.snapshotIfKnown(completed)).toMatchObject({ + state: 'closed', + exitPublished: true + }) + + const canceled = identity('token-canceled', { id: 'pty-canceled' }) + ledger.open(canceled, 8) + ledger.cancel(canceled, 'client-request') + expect(ledger.snapshotIfKnown(canceled)).toMatchObject({ + state: 'closed', + exitPublished: false + }) + + expect(ledger.snapshotIfKnown(identity('token-unknown'))).toBeNull() + expect(() => ledger.snapshot(identity('token-unknown'))).toThrow('stale') + + for (let index = 0; index < CLOSED_DELIVERY_TOMBSTONE_LIMIT; index++) { + const evicting = identity(`token-evicting-${index}`, { id: `pty-evicting-${index}` }) + ledger.open(evicting, 8) + ledger.cancel(evicting, 'test') + } + expect(ledger.snapshotIfKnown(canceled)).toBeNull() + }) + it('rejects reopening a recently closed one-use token', () => { const ledger = new RelayPtySourceCreditLedger() const owner = identity() diff --git a/src/relay/pty-source-credit-ledger.ts b/src/relay/pty-source-credit-ledger.ts index 0a697ea30b0..be628672c91 100644 --- a/src/relay/pty-source-credit-ledger.ts +++ b/src/relay/pty-source-credit-ledger.ts @@ -25,6 +25,7 @@ import { createDeliveryCancellation, createDeliveryRecord, createReplacementDeliveryRecord, + matchingDeliverySnapshot, MAX_SOURCE_SPAN_DATA_BYTES, ptyOwnerKey, retainedDataBytesTotal, @@ -248,16 +249,18 @@ export class RelayPtySourceCreditLedger { return Object.freeze({ cancellation, recovery: Object.freeze(replacement.spans.slice()) }) } + // Why: callers that can legitimately race a close probe instead of throwing; an evicted + // tombstone reads as null and must be treated as closed. + snapshotIfKnown(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot | null { + return matchingDeliverySnapshot(this.deliveries, this.closedSnapshots, identity) + } + snapshot(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot { - const active = this.deliveries.get(ptySourceDeliveryKey(identity)) - if (active && samePtySourceDelivery(active.identity, identity)) { - return snapshotDeliveryRecord(active) + const snapshot = this.snapshotIfKnown(identity) + if (!snapshot) { + throw new Error('Unknown or stale PTY source delivery') } - const closed = this.closedSnapshots.get(ptySourceDeliveryKey(identity)) - if (closed && samePtySourceDelivery(closed, identity)) { - return closed - } - throw new Error('Unknown or stale PTY source delivery') + return snapshot } retainedSourceSu = (): number => retainedSourceTotal(this.deliveries.values()) diff --git a/src/relay/pty-source-credit-record.ts b/src/relay/pty-source-credit-record.ts index cbdfd497e2d..d2730d25dc7 100644 --- a/src/relay/pty-source-credit-record.ts +++ b/src/relay/pty-source-credit-record.ts @@ -5,7 +5,11 @@ import type { PtySourceSpan, PtySourceTransform } from '../shared/pty-source-credit-contract' -import { ptySourceSpanIsSplittable } from '../shared/pty-source-credit-contract' +import { + ptySourceDeliveryKey, + ptySourceSpanIsSplittable, + samePtySourceDelivery +} from '../shared/pty-source-credit-contract' import { assertNonNegativeSafeInteger, assertPositiveSafeInteger, @@ -169,6 +173,21 @@ export function snapshotDeliveryRecord(record: DeliveryRecord): PtySourceDeliver }) } +// Why: identity equality is re-checked because a delivery key outlives the token that made it. +export function matchingDeliverySnapshot( + deliveries: ReadonlyMap, + closedSnapshots: ReadonlyMap, + identity: PtySourceDeliveryIdentity +): PtySourceDeliverySnapshot | null { + const key = ptySourceDeliveryKey(identity) + const active = deliveries.get(key) + if (active && samePtySourceDelivery(active.identity, identity)) { + return snapshotDeliveryRecord(active) + } + const closed = closedSnapshots.get(key) + return closed && samePtySourceDelivery(closed, identity) ? closed : null +} + export function retainedSourceTotal(records: Iterable): number { let total = 0 for (const record of records) { diff --git a/src/relay/relay-pty-source-cancellation-exit.test.ts b/src/relay/relay-pty-source-cancellation-exit.test.ts new file mode 100644 index 00000000000..e59b909802b --- /dev/null +++ b/src/relay/relay-pty-source-cancellation-exit.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PTY_CONSUMER_OWNER_GRACE_MS } from '../shared/pty-consumer-session' +import { + RelayDispatcher, + type RelayClientSessionIdentity, + type SinkWriteSettlement +} from './dispatcher' +import { encodeJsonRpcFrame, MessageType } from './protocol' +import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const endpointIdentity: RelayClientSessionIdentity = { + principal: 'endpoint-principal', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' +} + +type Notification = { method: string; params: Record } + +function requestFrame(id: number, method: string, params: Record): Buffer { + return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) +} + +function decode(buffer: Buffer): Record | null { + if (buffer[0] !== MessageType.Regular) { + return null + } + const length = buffer.readUInt32BE(9) + return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) +} + +function notification(buffer: Buffer): Notification | null { + const message = decode(buffer) + return typeof message?.method === 'string' && message.id === undefined + ? (message as Notification) + : null +} + +function responseResult(buffer: Buffer): Record | null { + const message = decode(buffer) + return !message || message.id === undefined + ? null + : ((message.result as Record) ?? null) +} + +async function flushRequests(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('RelayPtySourcePublication cancellation and exit', () => { + let dispatcher: RelayDispatcher | null = null + + afterEach(() => { + dispatcher?.dispose() + dispatcher = null + }) + + async function createHarness(windowSu = 8, holdExitSettlement = false) { + const writes: Buffer[] = [] + const exitSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const capacityIds: string[] = [] + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + const frame = notification(data) + if (frame?.method === 'pty.exit') { + exitSettlements.push(onSettled) + if (holdExitSettlement) { + return true + } + } + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + let publication: RelayPtySourcePublication + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => + publication.onCreditAvailable(id) + ) + publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => capacityIds.push(id)) + dispatcher.feed( + requestFrame(1, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: windowSu } } + }) + ) + await flushRequests() + const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] + expect( + publication.activate('pty-1', 'incarnation-1', { + clientId: 1, + isStale: () => false, + sessionIdentity: endpointIdentity, + onResponseSettled: (callback) => activationSettlements.push(callback) + }) + ).toBe('opened') + activationSettlements[0]({ ok: true }) + return { adapter, publication, exitSettlements, capacityIds, writes } + } + + function cancelDeliveryFrame( + requestId: number, + activation: { clientGeneration: number; ownerGeneration: number; deliveryToken: string } + ): Buffer { + return requestFrame(requestId, 'pty.cancelDelivery', { + id: 'pty-1', + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken + }) + } + + function exitFrames(writes: Buffer[]): Notification[] { + return writes + .map(notification) + .filter((frame): frame is Notification => frame?.method === 'pty.exit') + } + + function ackFrame( + requestId: number, + activation: { clientGeneration: number; ownerGeneration: number; deliveryToken: string }, + creditedEndSu: number + ): Buffer { + return encodeJsonRpcFrame( + { + jsonrpc: '2.0', + method: 'pty.ackData', + params: { + acknowledgements: [ + { + id: 'pty-1', + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken, + creditedEndSu + } + ] + } + }, + requestId, + 0 + ) + } + + it('retires the record when a client cancels, so the exit never seals a dead ledger', async () => { + const harness = await createHarness() + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + + dispatcher!.feed(cancelDeliveryFrame(2, activation)) + await flushRequests() + + expect(harness.publication.accepts('pty-1')).toBe(false) + expect(harness.capacityIds).toContain('pty-1') + expect(() => + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(false) + ).not.toThrow() + }) + + it('retires the record when the reconnect grace expires', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + try { + const harness = await createHarness() + harness.publication.publish('pty-1', { data: 'data' }, false) + + dispatcher!.invalidateClient() + vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) + + expect(harness.publication.accepts('pty-1')).toBe(false) + expect(harness.capacityIds).toContain('pty-1') + expect(() => + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(false) + ).not.toThrow() + } finally { + vi.useRealTimers() + } + }) + + it('opens a fresh delivery when the same client re-attaches after a cancel', async () => { + const harness = await createHarness() + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + dispatcher!.feed(cancelDeliveryFrame(2, activation)) + await flushRequests() + + const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] + expect( + harness.publication.activate('pty-1', 'incarnation-1', { + clientId: 1, + isStale: () => false, + sessionIdentity: endpointIdentity, + onResponseSettled: (callback) => activationSettlements.push(callback) + }) + ).toBe('opened') + activationSettlements[0]({ ok: true }) + + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(exitFrames(harness.writes)).toHaveLength(1) + }) + + it('survives a cancel while the credit-mode exit frame is still in flight', async () => { + const harness = await createHarness(8, true) + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + dispatcher!.feed(ackFrame(2, activation, 4)) + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(harness.exitSettlements).toHaveLength(1) + + dispatcher!.feed(cancelDeliveryFrame(3, activation)) + await flushRequests() + expect( + harness.writes.map(responseResult).filter((result) => result?.canceled === true) + ).toHaveLength(1) + // Why: A' keeps the record while the frame is in flight so no duplicate legacy exit escapes. + expect(harness.publication.accepts('pty-1')).toBe(true) + + expect(() => harness.exitSettlements[0]({ ok: true })).not.toThrow() + + expect(exitFrames(harness.writes)).toHaveLength(1) + expect(harness.capacityIds).toContain('pty-1') + expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true) + expect(harness.publication.accepts('pty-1')).toBe(false) + }) + + it('retires the record after the in-flight exit frame fails on a canceled delivery', async () => { + const harness = await createHarness(8, true) + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + dispatcher!.feed(ackFrame(2, activation, 4)) + harness.publication.sealAndPublishExit({ id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) + dispatcher!.feed(cancelDeliveryFrame(3, activation)) + await flushRequests() + const capacityBefore = harness.capacityIds.length + + expect(() => + harness.exitSettlements[0]({ ok: false, error: new Error('socket write failed') }) + ).not.toThrow() + // Why: the delivery is gone, so no credit event will arrive — D's widened finally must retry. + expect(harness.capacityIds.length).toBeGreaterThan(capacityBefore) + + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(harness.publication.accepts('pty-1')).toBe(false) + expect(harness.publication.getDebugSnapshot()).toMatchObject({ + exitCommitted: 0, + exitRolledBack: 1 + }) + }) + + it('publishes exactly one exit for a healthy completion even when the retry re-runs', async () => { + const harness = await createHarness() + + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true) + expect(harness.publication.accepts('pty-1')).toBe(false) + + expect(() => + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(false) + ).not.toThrow() + expect(exitFrames(harness.writes)).toHaveLength(1) + }) +}) diff --git a/src/relay/relay-pty-source-exit-publication.test.ts b/src/relay/relay-pty-source-exit-publication.test.ts new file mode 100644 index 00000000000..619c7da8829 --- /dev/null +++ b/src/relay/relay-pty-source-exit-publication.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot +} from '../shared/pty-source-credit-contract' +import type { RelayDispatcher } from './dispatcher' +import { sealAndPublishPtySourceExit } from './relay-pty-source-exit-publication' +import type { + RelayPtySourceDeliveryRecord, + RelayPtySourcePublicationCounters, + RelayPtySourceSendScheduler +} from './relay-pty-source-send-scheduler' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const identity: PtySourceDeliveryIdentity = Object.freeze({ + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1' +}) + +const params = { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' } + +function deliveryRecord( + overrides: Partial = {} +): RelayPtySourceDeliveryRecord { + return { + clientId: 1, + identity, + sourceActivation: { + status: 'pending', + clientGeneration: identity.clientGeneration, + ownerGeneration: identity.ownerGeneration, + ptyIncarnation: identity.ptyIncarnation, + deliveryToken: identity.deliveryToken, + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }, + displayEnd: 0, + activating: false, + activationRecoveryRequest: null, + sealed: false, + legacyExitAccepted: false, + sourceExitState: 'idle', + sending: false, + turnFrames: 0, + turnSourceSu: 0, + turnScheduled: false, + sendWaiters: new Set(), + recoveryCheckpointSourceEndSu: null, + recoveryEndSu: null, + recoveryCompletionPending: false, + restoreRequired: false, + rotationPending: false, + ...overrides + } +} + +function closedSnapshot( + overrides: Partial = {} +): PtySourceDeliverySnapshot { + return Object.freeze({ + ...identity, + state: 'closed', + windowSu: 8, + receivedEndSu: 0, + sentEndSu: 0, + creditedEndSu: 0, + exitPublished: false, + generationClosed: false, + ...overrides + }) +} + +function createScenario( + initialProbe: PtySourceDeliverySnapshot | null, + record: RelayPtySourceDeliveryRecord, + notifyAccepted = true +) { + const deliveries = new Map([['pty-1', record]]) + let probe = initialProbe + const setProbe = (next: PtySourceDeliverySnapshot | null): void => { + probe = next + } + const session = { + sourceDeliverySnapshotIfKnown: vi.fn(() => probe), + sourceDeliverySnapshot: vi.fn(() => { + if (!probe) { + throw new Error('Unknown or stale PTY source delivery') + } + return probe + }), + sealDelivery: vi.fn(), + settleExitPublication: vi.fn(), + deliveryMode: vi.fn(() => 'source-owner' as const) + } + const dispatcher = { + tryNotifyPtyExit: vi.fn(() => notifyAccepted), + tryNotifyPtyExitToMatchingClients: vi.fn( + (_matches: (clientId: number) => boolean, _params: unknown) => notifyAccepted + ), + projectPtyExitToMatchingClients: vi.fn(() => true), + tryNotifyPtyExitToClient: vi.fn(() => true) + } + const sender = { pump: vi.fn() } + const counters: RelayPtySourcePublicationCounters = { + opened: 0, + rotated: 0, + appendDenied: 0, + sendCommitted: 0, + sendRolledBack: 0, + exitCommitted: 0, + exitRolledBack: 0 + } + const capacityIds: string[] = [] + const run = (): boolean => + sealAndPublishPtySourceExit({ + params, + record, + deliveries, + dispatcher: dispatcher as unknown as RelayDispatcher, + session: session as unknown as SshPtyConsumerSessionAdapter, + sender: sender as unknown as RelayPtySourceSendScheduler, + counters, + onCapacity: (id) => capacityIds.push(id) + }) + return { deliveries, session, dispatcher, sender, counters, capacityIds, run, setProbe } +} + +describe('sealAndPublishPtySourceExit closed-delivery guard', () => { + it.each([ + ['an unknown probe', null], + ['a closed probe', closedSnapshot()], + ['a closing probe', closedSnapshot({ state: 'closing' })] + ])('broadcasts a legacy exit and retires the record for %s', (_label, probe) => { + const record = deliveryRecord() + const scenario = createScenario(probe, record) + + expect(scenario.run()).toBe(true) + + expect(scenario.dispatcher.tryNotifyPtyExit).toHaveBeenCalledWith(params) + expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() + expect(scenario.session.sealDelivery).not.toHaveBeenCalled() + expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() + expect(scenario.sender.pump).not.toHaveBeenCalled() + expect(scenario.deliveries.has('pty-1')).toBe(false) + }) + + it('re-targets only source-owner clients once a legacy broadcast already landed', () => { + const record = deliveryRecord({ legacyExitAccepted: true }) + const scenario = createScenario(closedSnapshot(), record) + + expect(scenario.run()).toBe(true) + + expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() + const matches = scenario.dispatcher.tryNotifyPtyExitToMatchingClients.mock.calls[0][0] + expect(matches(1)).toBe(true) + scenario.session.deliveryMode.mockReturnValue('legacy-owner' as never) + expect(matches(1)).toBe(false) + expect(scenario.deliveries.has('pty-1')).toBe(false) + }) + + it('keeps the record retryable when the legacy notify is refused', () => { + const record = deliveryRecord() + const scenario = createScenario(closedSnapshot(), record, false) + + expect(scenario.run()).toBe(false) + + expect(scenario.deliveries.get('pty-1')).toBe(record) + }) + + it.each([ + ['the tombstone retains exitPublished', closedSnapshot({ exitPublished: true }), 'idle'], + ['the tombstone was evicted', null, 'published'] + ])('retires a healthily completed delivery silently when %s', (_label, probe, exitState) => { + const record = deliveryRecord({ + sourceExitState: exitState as RelayPtySourceDeliveryRecord['sourceExitState'] + }) + const scenario = createScenario(probe, record) + + expect(scenario.run()).toBe(true) + + expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() + expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() + expect(scenario.sender.pump).not.toHaveBeenCalled() + expect(scenario.deliveries.has('pty-1')).toBe(false) + }) + + it('defers to the in-flight exit frame settlement', () => { + const record = deliveryRecord({ sourceExitState: 'pending' }) + const scenario = createScenario(closedSnapshot(), record) + + expect(scenario.run()).toBe(false) + + expect(scenario.session.sourceDeliverySnapshotIfKnown).not.toHaveBeenCalled() + expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() + expect(scenario.deliveries.get('pty-1')).toBe(record) + }) +}) + +describe('sealAndPublishPtySourceExit settlement closure', () => { + function createInFlightScenario(settlementProbe: PtySourceDeliverySnapshot | null) { + const record = deliveryRecord({ sealed: true, legacyExitAccepted: true }) + const scenario = createScenario(closedSnapshot({ state: 'sealed-unsettled' }), record) + let settle: ((result: { ok: true } | { ok: false; error: Error }) => void) | undefined + scenario.dispatcher.tryNotifyPtyExitToClient.mockImplementation( + (...args: unknown[]): boolean => { + settle = args[2] as typeof settle + return true + } + ) + expect(scenario.run()).toBe(true) + scenario.setProbe(settlementProbe) + return { ...scenario, record, settle: settle! } + } + + it('skips the ledger settle and still resumes capacity when the delivery vanished', () => { + const scenario = createInFlightScenario(null) + + expect(() => scenario.settle({ ok: true })).not.toThrow() + + expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() + expect(scenario.record.sourceExitState).toBe('published') + expect(scenario.capacityIds).toEqual(['pty-1']) + }) + + it('resumes capacity on a failed settlement once the delivery is gone', () => { + const scenario = createInFlightScenario(closedSnapshot()) + + scenario.settle({ ok: false, error: new Error('socket write failed') }) + + expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() + expect(scenario.record.sourceExitState).toBe('idle') + expect(scenario.capacityIds).toEqual(['pty-1']) + }) + + it.each([ + ['committed', { ok: true } as const], + ['rolled back', { ok: false, error: new Error('socket write failed') } as const] + ])('logs, contains and resumes after a %s ledger settlement fault', (_label, result) => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const scenario = createInFlightScenario(closedSnapshot({ state: 'sealed-unsettled' })) + scenario.session.settleExitPublication.mockImplementation(() => { + throw new Error('PTY source delivery is not sealed') + }) + + expect(() => scenario.settle(result)).not.toThrow() + + expect(String(stderr.mock.calls.at(-1)?.[0])).toContain( + '[pty-source-exit] exit settlement failed for pty-1' + ) + expect(scenario.capacityIds).toEqual(['pty-1']) + } finally { + stderr.mockRestore() + } + }) +}) diff --git a/src/relay/relay-pty-source-exit-publication.ts b/src/relay/relay-pty-source-exit-publication.ts index 76990942fc3..deafb51fc5b 100644 --- a/src/relay/relay-pty-source-exit-publication.ts +++ b/src/relay/relay-pty-source-exit-publication.ts @@ -27,6 +27,32 @@ export function sealAndPublishPtySourceExit(options: { } return published } + if (record.sourceExitState === 'pending') { + // Why: an exit frame is in flight; its settlement drives the next step. + return false + } + const probe = session.sourceDeliverySnapshotIfKnown(record.identity) + if (!probe || probe.state === 'closed' || probe.state === 'closing') { + if (probe?.exitPublished === true || record.sourceExitState === 'published') { + // Why: the delivery completed healthily — the owner already has the credit-mode exit. + if (deliveries.get(params.id) === record) { + deliveries.delete(params.id) + } + return true + } + // Why: the delivery was canceled out from under the record; never touch the sealed + // ledger — the exit flows as a legacy broadcast instead. + const published = record.legacyExitAccepted + ? dispatcher.tryNotifyPtyExitToMatchingClients( + (clientId) => session.deliveryMode(clientId) === 'source-owner', + params + ) + : dispatcher.tryNotifyPtyExit(params) + if (published && deliveries.get(params.id) === record) { + deliveries.delete(params.id) + } + return published + } if (!record.sealed) { session.sealDelivery(record.identity) record.sealed = true @@ -50,17 +76,33 @@ export function sealAndPublishPtySourceExit(options: { } record.sourceExitState = 'pending' const settle = onceSinkSettlement((result) => { + if (result.ok) { + record.sourceExitState = 'published' + counters.exitCommitted++ + } else { + record.sourceExitState = 'idle' + counters.exitRolledBack++ + } + let deliveryGone = false + let settlementFailed = false try { - session.settleExitPublication(record.identity, result) - if (result.ok) { - record.sourceExitState = 'published' - counters.exitCommitted++ - } else { - record.sourceExitState = 'idle' - counters.exitRolledBack++ + // Why: a client cancel or rotation can close the delivery while this frame is in + // flight; settling a closed ledger entry throws out of a bare socket write/drain + // callback (dispatcher-client-writer releaseEntry) straight into uncaughtException. + deliveryGone = + session.sourceDeliverySnapshotIfKnown(record.identity)?.state !== 'sealed-unsettled' + if (!deliveryGone) { + session.settleExitPublication(record.identity, result) } + } catch (err) { + settlementFailed = true + process.stderr.write( + `[pty-source-exit] exit settlement failed for ${params.id}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }\n` + ) } finally { - if (result.ok) { + if (result.ok || deliveryGone || settlementFailed) { onCapacity(params.id) } } diff --git a/src/relay/relay-pty-source-output.ts b/src/relay/relay-pty-source-output.ts index 6beefefa6cd..bbc57d1c643 100644 --- a/src/relay/relay-pty-source-output.ts +++ b/src/relay/relay-pty-source-output.ts @@ -1,3 +1,9 @@ +import { randomUUID } from 'node:crypto' +import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { RelayDispatcher } from './dispatcher' +import type { RelayPtySourceDeliveryRecord } from './relay-pty-source-send-scheduler' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + export type RelayPtySourceOutput = { data: string rawLength?: number @@ -6,3 +12,60 @@ export type RelayPtySourceOutput = { sourceAccepted?: boolean sourceSpanId?: string } + +// Why: an evicted tombstone probes null, so an unknown delivery counts as closed. +export function ptySourceDeliveryClosed( + session: SshPtyConsumerSessionAdapter, + identity: PtySourceDeliveryIdentity +): boolean { + const state = session.sourceDeliverySnapshotIfKnown(identity)?.state + return !state || state === 'closed' || state === 'closing' +} + +export function appendPtySourceOutput( + session: SshPtyConsumerSessionAdapter, + record: RelayPtySourceDeliveryRecord, + output: RelayPtySourceOutput +): boolean { + const rawLength = output.rawLength ?? output.data.length + output.sourceSpanId ??= randomUUID() + try { + session.appendSource(record.identity, { + spanId: output.sourceSpanId, + data: output.data, + displayStart: record.displayEnd, + displayEnd: record.displayEnd + output.data.length, + splittable: output.transformed !== true, + transform: { + transformed: output.transformed === true, + rawLengthSu: rawLength, + scalarSafe: output.transformed !== true + } + }) + } catch { + return false + } + output.sourceAccepted = true + record.displayEnd += output.data.length + return true +} + +export function projectPtySourceOutputToLegacy( + dispatcher: RelayDispatcher, + session: SshPtyConsumerSessionAdapter, + id: string, + output: RelayPtySourceOutput, + interactive: boolean +): boolean { + return dispatcher.projectPtyDataToMatchingClients( + (clientId) => session.deliveryMode(clientId) !== 'source-owner', + { + id, + data: output.data, + ...(output.seq === undefined ? {} : { seq: output.seq }), + ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), + ...(output.transformed ? { transformed: true } : {}) + }, + { interactive } + ) +} diff --git a/src/relay/relay-pty-source-publication.test.ts b/src/relay/relay-pty-source-publication.test.ts index 3ce4526efa6..1d30e4e8540 100644 --- a/src/relay/relay-pty-source-publication.test.ts +++ b/src/relay/relay-pty-source-publication.test.ts @@ -19,7 +19,9 @@ function requestFrame(id: number, method: string, params: Record } | null { +type Notification = { method: string; params: Record } + +function notification(buffer: Buffer): Notification | null { if (buffer[0] !== MessageType.Regular) { return null } @@ -52,17 +54,23 @@ describe('RelayPtySourcePublication', () => { async function createHarness( windowSu = 4, settleSourceImmediately = true, - highWaterMark?: number + highWaterMark?: number, + holdExitSettlement = false ) { const writes: Buffer[] = [] const sourceSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const exitSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const capacityIds: string[] = [] dispatcher = new RelayDispatcher( (data, onSettled) => { writes.push(Buffer.from(data)) const frame = notification(data) if (frame?.method === 'pty.data' || frame?.method === 'pty.exit') { sourceSettlements.push(onSettled) - if (settleSourceImmediately) { + if (frame.method === 'pty.exit') { + exitSettlements.push(onSettled) + } + if (settleSourceImmediately && !(holdExitSettlement && frame.method === 'pty.exit')) { onSettled({ ok: true }) } } else { @@ -85,7 +93,7 @@ describe('RelayPtySourcePublication', () => { const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => publication.onCreditAvailable(id) ) - publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) + publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => capacityIds.push(id)) dispatcher.feed( requestFrame(1, 'pty.openClient', { protocolVersion: 1, @@ -105,7 +113,7 @@ describe('RelayPtySourcePublication', () => { }) ).toBe('opened') activationSettlements[0]({ ok: true }) - return { adapter, publication, sourceSettlements, writes } + return { adapter, publication, sourceSettlements, exitSettlements, capacityIds, writes } } it('commits only from writer settlement and resumes exactly after cumulative ACK', async () => { diff --git a/src/relay/relay-pty-source-publication.ts b/src/relay/relay-pty-source-publication.ts index 10ef73b0ce6..b46a65c72e3 100644 --- a/src/relay/relay-pty-source-publication.ts +++ b/src/relay/relay-pty-source-publication.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto' import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' import type { PtySourceRecoveryRequest, @@ -19,7 +18,12 @@ import { } from './relay-pty-source-send-scheduler' import { sealAndPublishPtySourceExit } from './relay-pty-source-exit-publication' import type { RelayDispatcher, RequestContext } from './dispatcher' -import type { RelayPtySourceOutput } from './relay-pty-source-output' +import { + appendPtySourceOutput, + projectPtySourceOutputToLegacy, + ptySourceDeliveryClosed, + type RelayPtySourceOutput +} from './relay-pty-source-output' import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' export class RelayPtySourcePublication { @@ -61,7 +65,7 @@ export class RelayPtySourcePublication { return false } const mode = this.session.deliveryMode(context.clientId) - const current = this.deliveries.get(id) + let current = this.deliveries.get(id) if (mode === 'subscriber') { this.sender.releaseRotationFence(current) return false @@ -69,11 +73,24 @@ export class RelayPtySourcePublication { if (mode === 'legacy-owner') { if (current) { this.session.cancelDelivery(current.identity, 'source-credit-disabled') + this.sender.wakeSendWaiters(current) this.deliveries.delete(id) this.onCapacity(id) } return false } + if ( + current?.clientId === context.clientId && + !current.restoreRequired && + current.sourceExitState !== 'pending' && + this.deliveryClosedUnderRecord(current) + ) { + // Why: a canceled delivery can never resume as 'existing'; retire it so re-attach opens fresh. + this.sender.wakeSendWaiters(current) + this.deliveries.delete(id) + this.onCapacity(id) + current = undefined + } if (current?.clientId === context.clientId) { this.sender.releaseRotationFence(current) if (current.activating && current.activationRecoveryRequest) { @@ -195,42 +212,21 @@ export class RelayPtySourcePublication { ) { return false } - if (!output.sourceAccepted) { - const rawLength = output.rawLength ?? output.data.length - output.sourceSpanId ??= randomUUID() - try { - this.session.appendSource(record.identity, { - spanId: output.sourceSpanId, - data: output.data, - displayStart: record.displayEnd, - displayEnd: record.displayEnd + output.data.length, - splittable: output.transformed !== true, - transform: { - transformed: output.transformed === true, - rawLengthSu: rawLength, - scalarSafe: output.transformed !== true - } - }) - } catch { - this.counters.appendDenied++ + if (!output.sourceAccepted && !appendPtySourceOutput(this.session, record, output)) { + if (this.deliveryClosedUnderRecord(record)) { + this.sender.wakeSendWaiters(record) + this.deliveries.delete(id) + // Why: deferred — publish() can run inside flushPendingOutput's captured-queue drain, + // where pendingOutputByPty is transiently empty; a synchronous capacity callback would + // publish pty.exit ahead of still-buffered output. By microtask time the failed chunk + // has been re-queued (flushPtyOutput re-sets the queue synchronously on failure). + queueMicrotask(() => this.onCapacity(id)) return false } - output.sourceAccepted = true - record.displayEnd += output.data.length + this.counters.appendDenied++ + return false } - if ( - !this.dispatcher.projectPtyDataToMatchingClients( - (clientId) => this.session.deliveryMode(clientId) !== 'source-owner', - { - id, - data: output.data, - ...(output.seq === undefined ? {} : { seq: output.seq }), - ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), - ...(output.transformed ? { transformed: true } : {}) - }, - { interactive } - ) - ) { + if (!projectPtySourceOutputToLegacy(this.dispatcher, this.session, id, output, interactive)) { return false } this.sender.pump(record) @@ -269,6 +265,10 @@ export class RelayPtySourcePublication { dispose = (): void => this.sender.dispose() + private deliveryClosedUnderRecord(record: RelayPtySourceDeliveryRecord): boolean { + return ptySourceDeliveryClosed(this.session, record.identity) + } + private registerActivationSettlement( id: string, record: RelayPtySourceDeliveryRecord, diff --git a/src/relay/relay-pty-source-send-scheduler.test.ts b/src/relay/relay-pty-source-send-scheduler.test.ts new file mode 100644 index 00000000000..7c130be7045 --- /dev/null +++ b/src/relay/relay-pty-source-send-scheduler.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot +} from '../shared/pty-source-credit-contract' +import type { RelayDispatcher } from './dispatcher' +import { + RelayPtySourceSendScheduler, + type RelayPtySourceDeliveryRecord, + type RelayPtySourcePublicationCounters +} from './relay-pty-source-send-scheduler' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const identity: PtySourceDeliveryIdentity = Object.freeze({ + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1' +}) + +function deliveryRecord( + overrides: Partial = {} +): RelayPtySourceDeliveryRecord { + return { + clientId: 1, + identity, + sourceActivation: { + status: 'pending', + clientGeneration: identity.clientGeneration, + ownerGeneration: identity.ownerGeneration, + ptyIncarnation: identity.ptyIncarnation, + deliveryToken: identity.deliveryToken, + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }, + displayEnd: 0, + activating: false, + activationRecoveryRequest: null, + sealed: false, + legacyExitAccepted: false, + sourceExitState: 'idle', + sending: false, + turnFrames: 0, + turnSourceSu: 0, + turnScheduled: false, + sendWaiters: new Set(), + recoveryCheckpointSourceEndSu: null, + recoveryEndSu: null, + recoveryCompletionPending: false, + restoreRequired: false, + rotationPending: false, + ...overrides + } +} + +function createScheduler( + probe: PtySourceDeliverySnapshot | null, + record: RelayPtySourceDeliveryRecord +) { + const deliveries = new Map([['pty-1', record]]) + const session = { + sourceDeliverySnapshotIfKnown: vi.fn(() => probe), + sourceDeliverySnapshot: vi.fn(() => { + throw new Error('Unknown or stale PTY source delivery') + }), + reserveSourceSend: vi.fn(() => null) + } + const dispatcher = { + onLegacyPtyCapacity: vi.fn(() => () => {}), + producerDataBudget: vi.fn(() => 4096), + tryNotifyPtyDataToClient: vi.fn(() => true) + } + const counters: RelayPtySourcePublicationCounters = { + opened: 0, + rotated: 0, + appendDenied: 0, + sendCommitted: 0, + sendRolledBack: 0, + exitCommitted: 0, + exitRolledBack: 0 + } + const capacityIds: string[] = [] + const scheduler = new RelayPtySourceSendScheduler( + dispatcher as unknown as RelayDispatcher, + session as unknown as SshPtyConsumerSessionAdapter, + deliveries, + counters, + (id) => capacityIds.push(id) + ) + return { scheduler, deliveries, session, capacityIds } +} + +describe('RelayPtySourceSendScheduler close handling', () => { + it('leaves an in-flight exit frame untouched on a credit event', () => { + const record = deliveryRecord({ sourceExitState: 'pending' }) + const harness = createScheduler(null, record) + + harness.scheduler.onCreditAvailable('pty-1') + + expect(harness.deliveries.get('pty-1')).toBe(record) + expect(harness.session.sourceDeliverySnapshotIfKnown).not.toHaveBeenCalled() + expect(harness.session.reserveSourceSend).not.toHaveBeenCalled() + expect(harness.capacityIds).toEqual([]) + }) + + it('prunes a record whose tombstone was already evicted', () => { + const waiter = vi.fn() + const record = deliveryRecord({ sendWaiters: new Set([waiter]) }) + const harness = createScheduler(null, record) + + expect(() => harness.scheduler.onCreditAvailable('pty-1')).not.toThrow() + + expect(waiter).toHaveBeenCalledOnce() + expect(record.sendWaiters.size).toBe(0) + expect(harness.deliveries.has('pty-1')).toBe(false) + expect(harness.capacityIds).toEqual(['pty-1']) + }) + + it('preserves a closed record after the legacy exit projection landed', () => { + const record = deliveryRecord({ legacyExitAccepted: true }) + const harness = createScheduler(null, record) + + harness.scheduler.onCreditAvailable('pty-1') + + expect(harness.deliveries.get('pty-1')).toBe(record) + expect(harness.session.reserveSourceSend).not.toHaveBeenCalled() + expect(harness.capacityIds).toEqual(['pty-1']) + }) + + it('prunes a healthily published exit after its final credit closes the delivery', () => { + const record = deliveryRecord({ + legacyExitAccepted: true, + sourceExitState: 'published' + }) + const harness = createScheduler(null, record) + + harness.scheduler.onCreditAvailable('pty-1') + + expect(harness.deliveries.has('pty-1')).toBe(false) + expect(harness.capacityIds).toEqual(['pty-1']) + }) + + it('skips unknown deliveries in the debug snapshot instead of throwing', () => { + const harness = createScheduler(null, deliveryRecord({ sealed: true })) + + expect(harness.scheduler.getDebugSnapshot()).toMatchObject({ + active: 0, + activating: 0, + sealedUnsettled: 0, + outstandingSourceUnits: 0 + }) + }) +}) diff --git a/src/relay/relay-pty-source-send-scheduler.ts b/src/relay/relay-pty-source-send-scheduler.ts index 59402f5e5ba..bbec1a824d3 100644 --- a/src/relay/relay-pty-source-send-scheduler.ts +++ b/src/relay/relay-pty-source-send-scheduler.ts @@ -1,4 +1,7 @@ -import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot +} 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' @@ -100,7 +103,23 @@ export class RelayPtySourceSendScheduler { if (!record || record.restoreRequired) { return } - if (this.pruneClosed(id, record)) { + if (record.sourceExitState === 'pending') { + // Why: the credit-mode exit frame is in flight; pruning now diverts publishPendingExit + // into a duplicate legacy pty.exit, and pumping a closed delivery throws. The exit + // settlement (which fires onCapacity) resumes progress. + return + } + const snapshot = this.session.sourceDeliverySnapshotIfKnown(record.identity) + if ( + record.sourceExitState === 'idle' && + record.legacyExitAccepted && + (!snapshot || snapshot.state === 'closed' || snapshot.state === 'closing') + ) { + // Why: preserve partial exit progress so the retry targets only the source owner. + this.onCapacity(id) + return + } + if (this.pruneClosed(id, record, snapshot)) { this.onCapacity(id) return } @@ -114,7 +133,10 @@ export class RelayPtySourceSendScheduler { let sealedUnsettled = 0 let outstandingSourceUnits = 0 for (const record of this.deliveries.values()) { - const snapshot = this.session.sourceDeliverySnapshot(record.identity) + const snapshot = this.session.sourceDeliverySnapshotIfKnown(record.identity) + if (!snapshot) { + continue + } outstandingSourceUnits += snapshot.sentEndSu - snapshot.creditedEndSu if (record.activating) { activating++ @@ -262,11 +284,20 @@ export class RelayPtySourceSendScheduler { }) } - pruneClosed(id: string, record: RelayPtySourceDeliveryRecord): boolean { - if (this.session.sourceDeliverySnapshot(record.identity).state !== 'closed') { + pruneClosed( + id: string, + record: RelayPtySourceDeliveryRecord, + snapshot: PtySourceDeliverySnapshot | null = this.session.sourceDeliverySnapshotIfKnown( + record.identity + ) + ): boolean { + // Why: an evicted tombstone probes null and must count as closed — this runs from paths + // (credit callbacks, write settlements) where a throw escapes every caller's try/catch. + if (snapshot && snapshot.state !== 'closed') { return false } if (this.deliveries.get(id) === record) { + this.wakeSendWaiters(record) this.deliveries.delete(id) } return true diff --git a/src/relay/relay.ts b/src/relay/relay.ts index ab485f71312..f282cbb483e 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -33,7 +33,7 @@ import { PortScanHandler } from './port-scan-handler' import { AgentExecHandler } from './agent-exec-handler' import { WorkspaceSessionHandler } from './workspace-session-handler' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' -import { PluginOverlayManager, getRelayPiStatusExtensionPath } from './plugin-overlay' +import { PluginOverlayManager } from './plugin-overlay' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, @@ -45,7 +45,10 @@ import { } from '../shared/ssh-types' import { assertPluginSourceUnderByteCap } from './plugin-source-limit' import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' -import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind' +import { + detectExplicitPiAgentKindFromCommand, + isPiCompatibleAgentType +} from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' import { relayLogLine } from './relay-diagnostic-log' @@ -734,15 +737,24 @@ async function main(): Promise { if (pluginOverlay.hasPiSource()) { // Why: install Orca's guarded extension into the launched agent's (Pi vs OMP) real remote dir without redirecting PI_CODING_AGENT_DIR. const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) - const kind = detectPiAgentKindFromCommand(launchCommandHint) + const explicitKind = isPiCompatibleAgentType(ctx.launchAgent) + ? ctx.launchAgent + : ctx.launchAgent === undefined + ? detectExplicitPiAgentKindFromCommand(launchCommandHint) + : null + const kind = explicitKind ?? 'pi' const hasLaunchCommand = typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand if (kind === 'pi') { const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi') - const dir = pluginOverlay.materializePi(overlayId, sourceDir, 'pi') - if (dir) { - env.ORCA_PI_SOURCE_AGENT_DIR = dir + // Why: do not mkdir ~/. on bare shells when the agent home is + // missing — unused agents kept recreating deleted homes (#10196). + const result = pluginOverlay.materializePi(overlayId, sourceDir, 'pi', { + materializeDefaultHome: explicitKind === 'pi' + }) + if (result?.sourceAgentDir) { + env.ORCA_PI_SOURCE_AGENT_DIR = result.sourceAgentDir } } if (shouldPrepareOmpShadow) { @@ -751,10 +763,16 @@ async function main(): Promise { kind === 'omp' ? resolvePiSourceAgentDir(ctx.env, ctx.shell, 'omp') : ctx.env.ORCA_OMP_SOURCE_AGENT_DIR - const dir = pluginOverlay.materializePi(overlayId, sourceDir, 'omp') - if (dir) { - env.ORCA_OMP_STATUS_EXTENSION = getRelayPiStatusExtensionPath(dir) - env.ORCA_OMP_SOURCE_AGENT_DIR = dir + const result = pluginOverlay.materializePi(overlayId, sourceDir, 'omp', { + materializeDefaultHome: explicitKind === 'omp' + }) + // Why: status-only fallback (no sourceAgentDir) is intentional for bare + // shells without ~/.omp — still export ORCA_OMP_STATUS_EXTENSION (#10196). + if (result?.statusExtensionPath) { + env.ORCA_OMP_STATUS_EXTENSION = result.statusExtensionPath + } + if (result?.sourceAgentDir) { + env.ORCA_OMP_SOURCE_AGENT_DIR = result.sourceAgentDir } } } diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts index c5bedb37053..1617fdb09dd 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.ts @@ -217,6 +217,12 @@ export class SshPtyConsumerSessionAdapter { return this.sourceCredit.snapshot(identity) } + sourceDeliverySnapshotIfKnown( + identity: PtySourceDeliveryIdentity + ): PtySourceDeliverySnapshot | null { + return this.sourceCredit.snapshotIfKnown(identity) + } + cancelDelivery(identity: PtySourceDeliveryIdentity, reason: string): void { this.clearPausedIdentity(identity) this.sourceCredit.cancelIdentity(identity, reason) diff --git a/src/relay/ssh-pty-source-credit-adapter.test.ts b/src/relay/ssh-pty-source-credit-adapter.test.ts index f738395a154..915a7683f09 100644 --- a/src/relay/ssh-pty-source-credit-adapter.test.ts +++ b/src/relay/ssh-pty-source-credit-adapter.test.ts @@ -114,6 +114,90 @@ describe('SshPtySourceCreditAdapter cleanup', () => { expect(() => adapter.open(grant, 'pty-late', 'incarnation-late')).toThrow('disposed') }) + it('notifies credit availability on every adapter-side cancellation but not on rotate', () => { + vi.useFakeTimers() + const onCreditAvailable = vi.fn() + const adapter = new SshPtySourceCreditAdapter(undefined, onCreditAvailable) + const grant = ownerGrant(1, 1) + const canceled = adapter.open(grant, 'pty-cancel', 'incarnation-1')! + + adapter.cancel( + { + id: canceled.id, + deliveryToken: canceled.deliveryToken, + clientGeneration: canceled.clientGeneration, + ownerGeneration: canceled.ownerGeneration + }, + grant + ) + expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel']]) + + const expiring = adapter.open(grant, 'pty-grace', 'incarnation-2')! + adapter.retainOrCloseOnDetach(grant) + vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) + expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel'], ['pty-grace']]) + expect(adapter.snapshot(expiring).state).toBe('closed') + + const detaching = adapter.open(ownerGrant(3, 3), 'pty-detach', 'incarnation-3')! + adapter.retainOrCloseOnDetach( + Object.freeze({ + protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, + serverBuildId: 'build-a', + clientGeneration: 3, + role: 'subscriber' + }) + ) + expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel'], ['pty-grace'], ['pty-detach']]) + expect(adapter.snapshot(detaching).state).toBe('closed') + + const rotating = adapter.open(ownerGrant(4, 4), 'pty-rotate', 'incarnation-4')! + adapter.rotate(rotating, ownerGrant(5, 5), 0) + const publicationOwned = adapter.open(ownerGrant(6, 6), 'pty-publication', 'incarnation-5')! + adapter.cancelIdentity(publicationOwned, 'publication-owned') + expect(onCreditAvailable).toHaveBeenCalledTimes(3) + expect(adapter.snapshot(publicationOwned).state).toBe('closed') + }) + + it('swallows a throwing credit-available callback in cancel and in the grace timer', () => { + vi.useFakeTimers() + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const adapter = new SshPtySourceCreditAdapter(undefined, () => { + throw new Error('publication faulted') + }) + const grant = ownerGrant(1, 1) + const canceled = adapter.open(grant, 'pty-1', 'incarnation-1')! + + expect(() => + adapter.cancel( + { + id: canceled.id, + deliveryToken: canceled.deliveryToken, + clientGeneration: canceled.clientGeneration, + ownerGeneration: canceled.ownerGeneration + }, + grant + ) + ).not.toThrow() + + const expiring = adapter.open(grant, 'pty-2', 'incarnation-2')! + adapter.retainOrCloseOnDetach(grant) + expect(() => vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS)).not.toThrow() + + expect(adapter.snapshot(expiring).state).toBe('closed') + expect(stderr.mock.calls.map(([line]) => String(line))).toEqual([ + expect.stringContaining( + '[pty-source-credit] credit-available notification failed for pty-1' + ), + expect.stringContaining( + '[pty-source-credit] credit-available notification failed for pty-2' + ) + ]) + } finally { + stderr.mockRestore() + } + }) + it('returns the same bounded proof for duplicate token cancellation', () => { const adapter = new SshPtySourceCreditAdapter() const grant = ownerGrant(1, 1) diff --git a/src/relay/ssh-pty-source-credit-adapter.ts b/src/relay/ssh-pty-source-credit-adapter.ts index d551dec8142..e03c647c912 100644 --- a/src/relay/ssh-pty-source-credit-adapter.ts +++ b/src/relay/ssh-pty-source-credit-adapter.ts @@ -15,6 +15,7 @@ import { ptySourceCancellationResult, RecentPtySourceCancellationIndex } from './pty-source-cancellation-index' +import { notifyPtySourceCreditAvailable } from './pty-source-credit-availability-notice' import { ownedPtySourceDelivery } from './pty-source-delivery-ownership' import { RelayPtySourceCreditLedger, @@ -141,6 +142,10 @@ export class SshPtySourceCreditAdapter { return this.sourceCredit.snapshot(identity) } + snapshotIfKnown(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot | null { + return this.sourceCredit.snapshotIfKnown(identity) + } + acknowledge( params: Record, grant: Readonly | null @@ -205,6 +210,9 @@ export class SshPtySourceCreditAdapter { this.identityByToken.delete(token) this.recentCancellations.remember(proof) this.clearGraceWhenSettled(identity.ownerGeneration) + // Why: the publication must retire its record the moment the delivery closes, or the next + // exit seals a dead ledger entry. + notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) return ptySourceCancellationResult(proof) } @@ -247,9 +255,7 @@ export class SshPtySourceCreditAdapter { return } this.disposed = true - for (const timer of this.graceTimers.values()) { - clearTimeout(timer) - } + this.graceTimers.forEach((timer) => clearTimeout(timer)) this.graceTimers.clear() this.sourceCredit.closeGeneration(this.relayProviderGeneration) this.identityByToken.clear() @@ -262,6 +268,7 @@ export class SshPtySourceCreditAdapter { continue } this.cancelExact(token, identity, 'client-detached') + notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) } } @@ -274,6 +281,7 @@ export class SshPtySourceCreditAdapter { continue } this.cancelExact(token, identity, 'reconnect-grace-expired') + notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) } }, PTY_CONSUMER_OWNER_GRACE_MS) timer.unref?.() @@ -314,10 +322,7 @@ export class SshPtySourceCreditAdapter { } private clearGraceTimer(ownerGeneration: number): void { - const timer = this.graceTimers.get(ownerGeneration) - if (timer) { - clearTimeout(timer) - this.graceTimers.delete(ownerGeneration) - } + clearTimeout(this.graceTimers.get(ownerGeneration)) + this.graceTimers.delete(ownerGeneration) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1d90f1cf5e0..cda226ef620 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -205,6 +205,7 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import WorktreeBaseFallbackDialog from './components/WorktreeBaseFallbackDialog' import { useOsc52ClipboardDefaultOnNotice } from './components/terminal-pane/osc52-clipboard-default-on-notice' import { hasRequestedBackgroundTerminalWorktreeMount, @@ -444,6 +445,7 @@ function App(): React.JSX.Element { toggleSidebar: s.toggleSidebar, fetchRepos: s.fetchRepos, fetchReposForAllHosts: s.fetchReposForAllHosts, + awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement, fetchProjectGroups: s.fetchProjectGroups, fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, fetchFolderWorkspaces: s.fetchFolderWorkspaces, @@ -898,6 +900,9 @@ function App(): React.JSX.Element { await timeRendererStartupStep('fetch-repos-local', () => actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) ) + await timeRendererStartupStep('repo-catalog-settlement', () => + actions.awaitLocalRepoCatalogSettlement() + ) // Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts), // so keep this chain ordered while overlapping it with session-scoped hydration. const localCatalogChain = (async () => { @@ -950,6 +955,9 @@ function App(): React.JSX.Element { } const sessionRead = sessionOutcome.value await keybindingsPromise + await timeRendererStartupStep('repo-catalog-final-settlement', () => + actions.awaitLocalRepoCatalogSettlement() + ) if (!cancelled) { const sessionHydrationOptions = { additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) @@ -2762,6 +2770,7 @@ function App(): React.JSX.Element { + {/* Why: Electron's drag-region hit-test is DOM-order-based (ignores z-index); render last so WindowControls stay clickable. */} {hasCustomTitleBar && } diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 85dd64ba574..f638ae82083 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -20,6 +20,12 @@ describe('renderer startup runtime routing', () => { const localReposIndex = indexInStartupBlock( "actions.fetchReposForAllHosts({ remoteHosts: 'skip' })" ) + const repoCatalogSettlementIndex = indexInStartupBlock( + "timeRendererStartupStep('repo-catalog-settlement'" + ) + const finalRepoCatalogSettlementIndex = indexInStartupBlock( + "timeRendererStartupStep('repo-catalog-final-settlement'" + ) const localGroupsIndex = indexInStartupBlock( "actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })" ) @@ -39,6 +45,10 @@ describe('renderer startup runtime routing', () => { expect(settingsIndex).toBeLessThan(uiGetIndex) expect(uiGetIndex).toBeLessThan(hydrateUiIndex) expect(hydrateUiIndex).toBeLessThan(localReposIndex) + expect(localReposIndex).toBeLessThan(repoCatalogSettlementIndex) + expect(repoCatalogSettlementIndex).toBeLessThan(sessionIndex) + expect(sessionIndex).toBeLessThan(finalRepoCatalogSettlementIndex) + expect(finalRepoCatalogSettlementIndex).toBeLessThan(startupBlockEnd) // The local catalog chain stays internally ordered (folders merge against project groups). expect(localReposIndex).toBeLessThan(localGroupsIndex) expect(localGroupsIndex).toBeLessThan(localFoldersIndex) @@ -75,6 +85,7 @@ describe('renderer startup runtime routing', () => { // The catalog and selective hydration chains overlap, but both settle before recovery or hydration. const joinStart = indexInStartupBlock('await Promise.allSettled([') expect(joinStart).toBeGreaterThan(hydrateUiIndex) + expect(joinStart).toBeLessThan(finalRepoCatalogSettlementIndex) const joinBlock = source.slice(joinStart, startupBlockEnd) expect(joinBlock).toContain('hydrationSessionChain') expect(joinBlock).toContain('localCatalogChain') diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 920a45bfade..bdb96e71941 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -231,6 +231,7 @@ import { validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' +import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' // Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL. function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { @@ -4320,17 +4321,6 @@ function CommentReplyForm({ ) } -const CHECK_SORT_ORDER: Record = { - failure: 0, - timed_out: 0, - action_required: 0, - cancelled: 1, - pending: 2, - neutral: 3, - skipped: 4, - success: 5 -} - function getCheckConclusion(check: PRCheckDetail): NonNullable { return check.conclusion ?? 'pending' } @@ -4474,11 +4464,7 @@ function ChecksTab({ const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const sorted = [...list].sort( - (a, b) => - (CHECK_SORT_ORDER[getCheckConclusion(a)] ?? 3) - - (CHECK_SORT_ORDER[getCheckConclusion(b)] ?? 3) - ) + const sorted = sortChecksBySeverity(list) const failedChecks = getBrokenChecks(list) const counts = getCheckCounts(list) const summaryLabel = getChecksSummaryLabel(list) diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 029476d4e0d..a835ac96dd3 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -227,6 +227,7 @@ import { } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' // Why: the item URL is the only host-aware repository identity present on every work item across IPC. function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { @@ -4329,17 +4330,6 @@ function CommentReplyForm({ ) } -const CHECK_SORT_ORDER: Record = { - failure: 0, - timed_out: 0, - action_required: 0, - cancelled: 1, - pending: 2, - neutral: 3, - skipped: 4, - success: 5 -} - function getCheckConclusion(check: PRCheckDetail): NonNullable { return check.conclusion ?? 'pending' } @@ -4578,11 +4568,7 @@ function ChecksTab({ const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const sorted = [...list].sort( - (a, b) => - (CHECK_SORT_ORDER[getCheckConclusion(a)] ?? 3) - - (CHECK_SORT_ORDER[getCheckConclusion(b)] ?? 3) - ) + const sorted = sortChecksBySeverity(list) const failedChecks = getBrokenChecks(list) const counts = getCheckCounts(list) const summaryLabel = getChecksSummaryLabel(list) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 94eb63b148f..f9dac6c9fa3 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -2026,6 +2026,9 @@ function getChecksLabel(item: GitHubWorkItem): string { if (summary.pending > 0) { return `${summary.pending} pending` } + if (summary.neutral > 0) { + return `${summary.neutral} unresolved` + } return `${summary.passed}/${summary.total} passed` } diff --git a/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx b/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx new file mode 100644 index 00000000000..2016fa4072a --- /dev/null +++ b/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import WorktreeBaseFallbackDialog from './WorktreeBaseFallbackDialog' +import { + requestWorktreeBaseFallbackNotice, + resetWorktreeBaseFallbackNoticesForTests +} from './worktree-base-fallback-notice' + +const initialState = useAppStore.getInitialState() +let root: Root | null = null + +async function renderDialog(activeModal: AppState['activeModal'] = 'none'): Promise { + useAppStore.setState({ + activeModal, + setContextualToursBlockingSurfaceVisible: vi.fn() + }) + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!button) { + throw new Error(`Button not found: ${label}`) + } + return button +} + +describe('WorktreeBaseFallbackDialog', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + resetWorktreeBaseFallbackNoticesForTests() + }) + + afterEach(async () => { + await act(async () => { + root?.unmount() + }) + root = null + document.body.innerHTML = '' + resetWorktreeBaseFallbackNoticesForTests() + useAppStore.setState(initialState, true) + }) + + it('explains the fallback and advances queued notices', async () => { + requestWorktreeBaseFallbackNotice({ + requestedRef: 'origin/main', + localRef: 'main' + }) + requestWorktreeBaseFallbackNotice({ + requestedRef: 'upstream/develop', + localRef: 'develop' + }) + await renderDialog() + + expect(document.body.textContent).toContain('Workspace created from a local base') + expect(document.body.textContent).toContain( + 'The remote-tracking ref "origin/main" was unavailable' + ) + expect(document.body.textContent).toContain('may not include the latest remote changes') + + await act(async () => { + getButton('Got it').click() + }) + + expect(document.body.textContent).toContain('"upstream/develop" was unavailable') + }) + + it('waits until another app modal closes', async () => { + requestWorktreeBaseFallbackNotice({ + requestedRef: 'origin/main', + localRef: 'main' + }) + await renderDialog('new-workspace-composer') + + expect(document.body.textContent).not.toContain('Workspace created from a local base') + + await act(async () => { + useAppStore.setState({ activeModal: 'none' }) + }) + + expect(document.body.textContent).toContain('Workspace created from a local base') + }) +}) diff --git a/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx b/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx new file mode 100644 index 00000000000..e64e5904511 --- /dev/null +++ b/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useSyncExternalStore } from 'react' +import { TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + dismissWorktreeBaseFallbackNotice, + getWorktreeBaseFallbackNotice, + subscribeWorktreeBaseFallbackNotice +} from './worktree-base-fallback-notice' + +export default function WorktreeBaseFallbackDialog(): React.JSX.Element { + const notice = useSyncExternalStore( + subscribeWorktreeBaseFallbackNotice, + getWorktreeBaseFallbackNotice, + getWorktreeBaseFallbackNotice + ) + const activeModal = useAppStore((state) => state.activeModal) + const setContextualToursBlockingSurfaceVisible = useAppStore( + (state) => state.setContextualToursBlockingSurfaceVisible + ) + const lastNoticeRef = useRef(notice) + const displayedNotice = notice ?? lastNoticeRef.current + const open = notice !== null && activeModal === 'none' + + useEffect(() => { + if (notice) { + lastNoticeRef.current = notice + } + }, [notice]) + + useEffect(() => { + setContextualToursBlockingSurfaceVisible(open) + return () => setContextualToursBlockingSurfaceVisible(false) + }, [open, setContextualToursBlockingSurfaceVisible]) + + return ( + { + if (!isOpen) { + dismissWorktreeBaseFallbackNotice() + } + }} + > + + + + + {displayedNotice ? ( + + {translate( + 'auto.components.WorktreeBaseFallbackDialog.description', + 'The remote-tracking ref "{{value0}}" was unavailable, so Orca used local "{{value1}}" instead. This workspace may not include the latest remote changes.', + { value0: displayedNotice.requestedRef, value1: displayedNotice.localRef } + )} + + ) : null} + + + + + + + ) +} diff --git a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx index 11e094472a6..916abbdcb69 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx @@ -307,6 +307,8 @@ describe('Activity portal pane switching', () => { }) it('releases a latched readiness once the terminal attaches', async () => { + // Drive rAF explicitly — wall-clock waits flake under CI load. + const frames = installAnimationFrameController() const target = document.createElement('div') document.body.append(target) const buildRoot = (mode: 'hidden' | 'sibling' | 'ready'): void => { @@ -352,15 +354,22 @@ describe('Activity portal pane switching', () => { root = createRoot(document.createElement('div')) await act(async () => { root.render() - await new Promise((resolve) => setTimeout(resolve, 180)) }) + for (let frame = 0; frame < 40; frame += 1) { + if (frames.pending() === 0 && statuses.at(-1) === 'unavailable') { + break + } + await frames.flush() + } expect(statuses.at(-1)).toBe('unavailable') churning = false await act(async () => { buildRoot('ready') - await new Promise((resolve) => setTimeout(resolve, 40)) }) + for (let frame = 0; frame < 10 && statuses.at(-1) !== 'ready'; frame += 1) { + await frames.flush() + } expect(statuses.at(-1)).toBe('ready') }) }) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 92805781559..1a2c0279543 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -753,13 +753,31 @@ function retryBrowserTabLoad( webview.src = retryUrl } +export type BrowserFindShortcutScope = 'focused' | 'inactive' | 'owned-target' + +function browserOverlayOwnsShortcutTarget( + target: EventTarget | null, + browserTabId: string +): boolean { + if (!(target instanceof Element)) { + return false + } + return ( + target.closest('[data-browser-overlay-tab-id]')?.getAttribute('data-browser-overlay-tab-id') === + browserTabId + ) +} + export default function BrowserPane({ browserTab, - isActive + isActive, + findShortcutScope }: { browserTab: BrowserWorkspaceState isActive: boolean + findShortcutScope?: BrowserFindShortcutScope }): React.JSX.Element { + const resolvedFindShortcutScope = findShortcutScope ?? (isActive ? 'focused' : 'inactive') const activeRuntimeEnvironmentId = useAppStore((s) => getRuntimeEnvironmentIdForWorktree(s, browserTab.worktreeId) ) @@ -852,6 +870,9 @@ export default function BrowserPane({ sessionProfileId={browserTab.sessionProfileId ?? null} sessionPartition={browserTab.sessionPartition ?? null} isActive={isActive && page.id === activeBrowserPage?.id} + findShortcutScope={ + page.id === activeBrowserPage?.id ? resolvedFindShortcutScope : 'inactive' + } isAutomationVisible={automationVisiblePageIds.has(page.id)} isMobileDriven={mobileDrivenPageIds.has(page.id)} inputLocked={activeBrowserDriver.kind === 'mobile'} @@ -2755,6 +2776,7 @@ function BrowserPagePane({ sessionProfileId, sessionPartition, isActive, + findShortcutScope, isAutomationVisible, isMobileDriven, inputLocked, @@ -2767,6 +2789,7 @@ function BrowserPagePane({ sessionProfileId: string | null sessionPartition: string | null isActive: boolean + findShortcutScope: BrowserFindShortcutScope isAutomationVisible: boolean isMobileDriven: boolean inputLocked: boolean @@ -3447,7 +3470,7 @@ function BrowserPagePane({ // Cmd/Ctrl+F — find in page (renderer path: focus on browser chrome) // Why: unlike bare C/S grab shortcuts, Cmd+F should always open find even from the address bar (matches Chrome/Safari). useEffect(() => { - if (!isActive) { + if (findShortcutScope === 'inactive') { return } const shortcutPlatform = getShortcutPlatform() @@ -3455,13 +3478,19 @@ function BrowserPagePane({ if (!keybindingMatchesAction('browser.find', e, shortcutPlatform, keybindings)) { return } + if ( + findShortcutScope === 'owned-target' && + !browserOverlayOwnsShortcutTarget(e.target, workspaceId) + ) { + return + } e.preventDefault() e.stopPropagation() setFindOpen(true) } window.addEventListener('keydown', handleKeyDown, true) return () => window.removeEventListener('keydown', handleKeyDown, true) - }, [isActive, keybindings]) + }, [findShortcutScope, keybindings, workspaceId]) // Cmd/Ctrl+F — find in page (IPC path: focus inside webview guest) // Why: a focused guest is a separate Chromium process, so main forwards the chord back here. @@ -3469,10 +3498,13 @@ function BrowserPagePane({ if (!isActive) { return } - return window.api.ui.onFindInBrowserPage(() => { - setFindOpen(true) - }) - }, [isActive]) + return window.api.ui.onFindInBrowserPage( + { browserPageId: browserTab.id, browserWorkspaceId: workspaceId }, + () => { + setFindOpen(true) + } + ) + }, [browserTab.id, isActive, workspaceId]) // Browser history shortcuts (renderer path: focus on browser chrome) // Why: macOS can't deliver Logitech side-buttons to Electron; Logi Options+ remaps them to history chords, handled here when chrome is focused. diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx index 3b8c8ad0299..4e49c241722 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx @@ -6,6 +6,7 @@ type MockAppState = { browserTabsByWorktree: Record unifiedTabsByWorktree: Record groupsByWorktree: Record + activeGroupIdByWorktree: Record focusGroup: (worktreeId: string, groupId: string) => void } @@ -36,10 +37,19 @@ vi.mock('@/lib/pane-manager/browser-mobile-driver-state', () => ({ })) vi.mock('./BrowserPane', () => ({ - default: ({ browserTab, isActive }: { browserTab: BrowserTabState; isActive: boolean }) => ( + default: ({ + browserTab, + isActive, + findShortcutScope + }: { + browserTab: BrowserTabState + isActive: boolean + findShortcutScope?: string + }) => ( ) })) @@ -63,6 +73,88 @@ describe('BrowserPaneOverlayLayer', () => { expect(markup).toContain('data-browser-pane-active="false"') }) + it('marks the active browser pane focused when its own group holds focus', () => { + const markup = renderOverlay({ isWorktreeActive: true }) + + // browser-a is the active tab of group-1, and group-1 is the focused split. + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="focused"' + ) + }) + + it('keeps an active browser pane unfocused when another split holds focus (#11348)', () => { + mocks.state = createState() + mocks.state.groupsByWorktree = { + 'wt-1': [ + ...mocks.state.groupsByWorktree['wt-1'], + { + id: 'group-2', + worktreeId: 'wt-1', + activeTabId: null, + tabOrder: [] + } + ] + } + mocks.state.activeGroupIdByWorktree = { 'wt-1': 'group-2' } + + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="inactive"' + ) + }) + + it('limits Find to the owning target while focused-group state is unavailable', () => { + mocks.state = createState() + mocks.state.activeGroupIdByWorktree = {} + + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="owned-target"' + ) + }) + + it('limits Find to the owning target when the focused-group ID is stale', () => { + mocks.state = createState() + mocks.state.activeGroupIdByWorktree = { 'wt-1': 'removed-group' } + + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="owned-target"' + ) + }) + + it('keeps Find ownership with group identities after split order changes', () => { + mocks.state = createState() + const [tabA, tabB] = mocks.state.unifiedTabsByWorktree['wt-1'] + const [groupA] = mocks.state.groupsByWorktree['wt-1'] + mocks.state.unifiedTabsByWorktree = { + 'wt-1': [{ ...tabB, groupId: 'group-2' }, tabA] + } + mocks.state.groupsByWorktree = { + 'wt-1': [ + { + id: 'group-2', + worktreeId: 'wt-1', + activeTabId: tabB.id, + tabOrder: [tabB.id] + }, + { ...groupA, tabOrder: [tabA.id] } + ] + } + + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="focused"' + ) + expect(markup).toContain( + 'data-browser-pane-id="browser-b" data-browser-pane-active="true" data-browser-find-shortcut-scope="inactive"' + ) + }) + it('marks automation-visible inactive browser panes paintable without remounting them', () => { mocks.automationVisiblePageIds.add('page-b') @@ -126,6 +218,8 @@ function createState(): MockAppState { } ] }, + // Default: the browser's own group holds focus, so active === focused. + activeGroupIdByWorktree: { 'wt-1': 'group-1' }, focusGroup: mocks.focusGroup } } diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx index 00008a0500f..b019f1025e7 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx @@ -3,7 +3,7 @@ import { registerBrowserOverlaySlotViewport } from './browser-page-viewport' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import type { BrowserTab as BrowserTabState, Tab, TabGroup } from '../../../../shared/types' -import BrowserPane from './BrowserPane' +import BrowserPane, { type BrowserFindShortcutScope } from './BrowserPane' import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' import { useBrowserAutomationVisibilityForAny } from './browser-automation-visibility' import { useBrowserMobileDriverForAny } from '@/lib/pane-manager/browser-mobile-driver-state' @@ -24,6 +24,7 @@ type BrowserOverlaySlotProps = { // Why: undefined = orphan tab (in browserTabs but not referenced by any group's unified-tab list); the fallback branch keeps these hidden. groupId: string | undefined isActive: boolean + findShortcutScope: BrowserFindShortcutScope // Why: overlay is a sibling of the group layout, so pane focus doesn't bubble to TabGroupPanel; re-sync it here or split-view clicks leave activeGroupIdByWorktree stale. onFocusOwningGroup: ((groupId: string) => void) | undefined isWorktreeActive: boolean @@ -34,6 +35,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ browserTab, groupId, isActive, + findShortcutScope, onFocusOwningGroup, isWorktreeActive }: BrowserOverlaySlotProps): React.JSX.Element { @@ -97,12 +99,18 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ >
{/* Why: hidden worktrees park the heavy pane subtree; visible ones keep stable slots so reparenting can't destroy the webview guest. */} - {shouldMountPane ? : null} + {shouldMountPane ? ( + + ) : null}
) }) -// Why: memoize so parent re-renders on props this layer doesn't consume (e.g. focusedGroupId) don't rerun its selector or assignments mapping. +// Why: memoize so parent re-renders on props this layer doesn't consume don't rerun its selector or assignments mapping (focused-split state comes from the store selector below, not props). const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ worktreeId, isWorktreeActive @@ -110,14 +118,23 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ worktreeId: string isWorktreeActive: boolean }): React.JSX.Element { - const { browserTabs, unifiedTabs, groups } = useAppStore( + const { browserTabs, unifiedTabs, groups, focusedGroupId } = useAppStore( useShallow((state) => ({ browserTabs: state.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS, unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS, - groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS + groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS, + // Why: the focused split within this worktree; gates the browser Find shortcut so a focused terminal in the same split keeps Cmd/Ctrl+F (#11348). + focusedGroupId: state.activeGroupIdByWorktree[worktreeId] })) ) const focusGroup = useAppStore((state) => state.focusGroup) + const knownFocusedGroupId = useMemo( + () => + focusedGroupId !== undefined && groups.some((group) => group.id === focusedGroupId) + ? focusedGroupId + : undefined, + [focusedGroupId, groups] + ) // Why: stable identity so BrowserOverlaySlot's memo holds; groupId is passed at call time so one callback serves every slot. const focusOwningGroup = useCallback( @@ -154,12 +171,20 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ {browserTabs.map((browserTab) => { const assignment = assignments.get(browserTab.id) const isActive = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const findShortcutScope: BrowserFindShortcutScope = !isActive + ? 'inactive' + : knownFocusedGroupId === undefined + ? 'owned-target' + : assignment?.groupId === knownFocusedGroupId + ? 'focused' + : 'inactive' return ( diff --git a/src/renderer/src/components/browser-pane/useGrabMode.test.ts b/src/renderer/src/components/browser-pane/useGrabMode.test.ts index 048a3dc916a..e912d74a0cb 100644 --- a/src/renderer/src/components/browser-pane/useGrabMode.test.ts +++ b/src/renderer/src/components/browser-pane/useGrabMode.test.ts @@ -84,4 +84,191 @@ describe('useGrabMode', () => { enabled: true }) }) + + it('reports a picker injection failure without calling it a readiness error', async () => { + const harness = createReactHookHarness() + vi.doMock('react', () => harness.react) + vi.doMock('@/hooks/useMountedRef', () => ({ + useMountedRef: () => ({ current: true }) + })) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + browser: { + setGrabMode: vi.fn(async () => ({ ok: false, reason: 'injection-failed' })), + awaitGrabSelection: vi.fn(), + cancelGrab: vi.fn() + } + } + }) + const { useGrabMode } = await import('./useGrabMode') + const render = () => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + return useGrabMode('page-1') + } + + render().toggle() + await Promise.resolve() + + const grab = render() + expect(grab.state).toBe('error') + expect(grab.error).toBe('Could not start element selection on this page.') + }) + + it('arms immediately and treats a second toggle as cancellation while enable is pending', async () => { + const harness = createReactHookHarness() + let resolveEnable!: (result: { ok: true }) => void + const pendingEnable = new Promise<{ ok: true }>((resolve) => { + resolveEnable = resolve + }) + const setGrabMode = vi.fn(async ({ enabled }: { enabled: boolean }) => + enabled ? pendingEnable : ({ ok: true } as const) + ) + const awaitGrabSelection = vi.fn(() => new Promise(() => {})) + const cancelGrab = vi.fn() + vi.doMock('react', () => harness.react) + vi.doMock('@/hooks/useMountedRef', () => ({ + useMountedRef: () => ({ current: true }) + })) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + browser: { setGrabMode, awaitGrabSelection, cancelGrab } + } + }) + const { useGrabMode } = await import('./useGrabMode') + const render = () => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + return useGrabMode('page-1') + } + + const grab = render() + grab.toggle() + expect(render().state).toBe('armed') + + grab.toggle() + expect(setGrabMode.mock.calls.filter(([args]) => args.enabled)).toHaveLength(1) + expect(render().state).toBe('idle') + + resolveEnable({ ok: true }) + await pendingEnable + await Promise.resolve() + + expect(awaitGrabSelection).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(cancelGrab).toHaveBeenCalledWith({ browserPageId: 'page-1' }) + }) + }) + + it('cancels the tab owning the grab when the page changes before effects run', async () => { + const harness = createReactHookHarness() + const setGrabMode = vi.fn(async () => ({ ok: true })) + const awaitGrabSelection = vi.fn(() => new Promise(() => {})) + const cancelGrab = vi.fn() + vi.doMock('react', () => harness.react) + vi.doMock('@/hooks/useMountedRef', () => ({ + useMountedRef: () => ({ current: true }) + })) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + browser: { setGrabMode, awaitGrabSelection, cancelGrab } + } + }) + const { useGrabMode } = await import('./useGrabMode') + const render = (browserPageId: string) => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + return useGrabMode(browserPageId) + } + + render('page-1').toggle() + await vi.waitFor(() => { + expect(awaitGrabSelection).toHaveBeenCalledTimes(1) + }) + + render('page-2').cancel() + + expect(setGrabMode).toHaveBeenLastCalledWith({ + browserPageId: 'page-1', + enabled: false + }) + expect(cancelGrab).toHaveBeenCalledWith({ browserPageId: 'page-1' }) + }) + + it('ignores a stale screenshot after restarting on the same page', async () => { + const harness = createReactHookHarness() + let resolveScreenshot!: (result: { + ok: true + screenshot: { dataUrl: string; width: number; height: number } + }) => void + const pendingScreenshot = new Promise<{ + ok: true + screenshot: { dataUrl: string; width: number; height: number } + }>((resolve) => { + resolveScreenshot = resolve + }) + const awaitGrabSelection = vi + .fn() + .mockResolvedValueOnce({ + kind: 'selected', + payload: { + target: { + rectViewport: { x: 0, y: 0, width: 1, height: 1 } + } + } + }) + .mockImplementation(() => new Promise(() => {})) + const captureSelectionScreenshot = vi.fn(() => pendingScreenshot) + vi.doMock('react', () => harness.react) + vi.doMock('@/hooks/useMountedRef', () => ({ + useMountedRef: () => ({ current: true }) + })) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + browser: { + setGrabMode: vi.fn(async () => ({ ok: true })), + awaitGrabSelection, + captureSelectionScreenshot, + cancelGrab: vi.fn() + } + } + }) + const { useGrabMode } = await import('./useGrabMode') + const render = () => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + return useGrabMode('page-1') + } + + render().toggle() + await vi.waitFor(() => { + expect(captureSelectionScreenshot).toHaveBeenCalledTimes(1) + }) + + render().cancel() + render().toggle() + await vi.waitFor(() => { + expect(awaitGrabSelection).toHaveBeenCalledTimes(2) + }) + + resolveScreenshot({ + ok: true, + screenshot: { dataUrl: 'data:image/png;base64,AA==', width: 1, height: 1 } + }) + await pendingScreenshot + + await vi.waitFor(() => { + const grab = render() + expect(grab.state).toBe('awaiting') + expect(grab.payload).toBeNull() + }) + }) }) diff --git a/src/renderer/src/components/browser-pane/useGrabMode.ts b/src/renderer/src/components/browser-pane/useGrabMode.ts index ccf9851ffb5..b3ee2580572 100644 --- a/src/renderer/src/components/browser-pane/useGrabMode.ts +++ b/src/renderer/src/components/browser-pane/useGrabMode.ts @@ -1,9 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { BrowserGrabPayload, + BrowserGrabRejectReason, BrowserGrabScreenshot } from '../../../../shared/browser-grab-types' import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' import { isEditableKeyboardTarget } from './browser-keyboard' // --------------------------------------------------------------------------- @@ -32,6 +34,31 @@ function nextOpId(): string { return `grab-${++opIdCounter}-${Date.now()}` } +function getGrabEnableError(reason: BrowserGrabRejectReason): string { + switch (reason) { + case 'not-ready': + return translate( + 'auto.components.browser-pane.grab.errorNotReady', + "This page isn't ready for element selection yet." + ) + case 'not-authorized': + return translate( + 'auto.components.browser-pane.grab.errorNotAuthorized', + 'Browser access was denied.' + ) + case 'already-active': + return translate( + 'auto.components.browser-pane.grab.errorAlreadyActive', + 'Element selection is already active.' + ) + case 'injection-failed': + return translate( + 'auto.components.browser-pane.grab.errorInjectionFailed', + 'Could not start element selection on this page.' + ) + } +} + /** * Hook that drives the browser grab lifecycle for a single browser page. * @@ -45,6 +72,7 @@ export function useGrabMode(browserPageId: string): GrabModeHook { const [contextMenu, setContextMenu] = useState(false) const activeOpIdRef = useRef(null) const grabTabIdRef = useRef(null) + const armGenerationRef = useRef(0) const browserTabIdRef = useRef(browserPageId) // Why: toolbar/key handlers from the latest render can fire before passive // effects run after a page switch, so keep the target page current in render. @@ -58,6 +86,7 @@ export function useGrabMode(browserPageId: string): GrabModeHook { return () => { const grabTabId = grabTabIdRef.current if (grabTabId) { + armGenerationRef.current += 1 void window.api.browser.setGrabMode({ browserPageId: grabTabId, enabled: false }) void window.api.browser.cancelGrab({ browserPageId: grabTabId }) grabTabIdRef.current = null @@ -68,7 +97,9 @@ export function useGrabMode(browserPageId: string): GrabModeHook { const armAndAwait = useCallback(async () => { const tabId = browserTabIdRef.current + const armGeneration = (armGenerationRef.current += 1) grabTabIdRef.current = tabId + setState('armed') // Enable grab mode — injects the overlay const setResult = await window.api.browser.setGrabMode({ @@ -77,25 +108,28 @@ export function useGrabMode(browserPageId: string): GrabModeHook { }) if ( !mountedRef.current || + armGenerationRef.current !== armGeneration || browserTabIdRef.current !== tabId || grabTabIdRef.current !== tabId ) { - void window.api.browser.setGrabMode({ browserPageId: tabId, enabled: false }) - void window.api.browser.cancelGrab({ browserPageId: tabId }) - if (grabTabIdRef.current === tabId) { - grabTabIdRef.current = null + const supersededBySameTab = + armGenerationRef.current !== armGeneration && grabTabIdRef.current === tabId + if (!supersededBySameTab) { + void window.api.browser.setGrabMode({ browserPageId: tabId, enabled: false }) + void window.api.browser.cancelGrab({ browserPageId: tabId }) + if (grabTabIdRef.current === tabId) { + grabTabIdRef.current = null + } } return } if (!setResult.ok) { grabTabIdRef.current = null setState('error') - setError(`Cannot enable grab mode: ${setResult.reason}`) + setError(getGrabEnableError(setResult.reason)) return } - setState('armed') - // Generate opId and await selection const opId = nextOpId() activeOpIdRef.current = opId @@ -127,7 +161,11 @@ export function useGrabMode(browserPageId: string): GrabModeHook { } catch { // Screenshot failure is non-fatal } - if (!mountedRef.current || grabTabIdRef.current !== tabId) { + if ( + !mountedRef.current || + armGenerationRef.current !== armGeneration || + grabTabIdRef.current !== tabId + ) { return } @@ -146,20 +184,22 @@ export function useGrabMode(browserPageId: string): GrabModeHook { }, [mountedRef]) const toggle = useCallback(() => { - if (state === 'idle' || state === 'error') { + if ((state === 'idle' || state === 'error') && grabTabIdRef.current === null) { setError(null) setPayload(null) setContextMenu(false) void armAndAwait() } else { // Disable grab mode + const targetTabId = grabTabIdRef.current ?? browserTabIdRef.current + armGenerationRef.current += 1 void window.api.browser.setGrabMode({ - browserPageId: browserTabIdRef.current, + browserPageId: targetTabId, enabled: false }) if (activeOpIdRef.current) { void window.api.browser.cancelGrab({ - browserPageId: browserTabIdRef.current + browserPageId: targetTabId }) activeOpIdRef.current = null } @@ -172,13 +212,15 @@ export function useGrabMode(browserPageId: string): GrabModeHook { }, [state, armAndAwait]) const cancel = useCallback(() => { + const targetTabId = grabTabIdRef.current ?? browserTabIdRef.current + armGenerationRef.current += 1 void window.api.browser.setGrabMode({ - browserPageId: browserTabIdRef.current, + browserPageId: targetTabId, enabled: false }) if (activeOpIdRef.current) { void window.api.browser.cancelGrab({ - browserPageId: browserTabIdRef.current + browserPageId: targetTabId }) activeOpIdRef.current = null } @@ -204,8 +246,10 @@ export function useGrabMode(browserPageId: string): GrabModeHook { }, [armAndAwait]) const exit = useCallback(() => { + const targetTabId = grabTabIdRef.current ?? browserTabIdRef.current + armGenerationRef.current += 1 void window.api.browser.setGrabMode({ - browserPageId: browserTabIdRef.current, + browserPageId: targetTabId, enabled: false }) // Why: clear the active opId so that any in-flight result from the diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx index 33c5017b63b..f13bc19a336 100644 --- a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx +++ b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx @@ -9,7 +9,7 @@ import { } from '@/lib/agent-skill-cli-prerequisite' import { AgentSkillSetupPanel } from '../settings/AgentSkillSetupPanel' import { buildSkillCommandForRuntime } from '../settings/CliSkillRuntimeSetup' -import { StepBadge } from '../settings/BrowserUseStepBadge' +import { StepBadge } from '../settings/SetupStepBadge' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts index b20565d823f..4e350eaf0a4 100644 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts @@ -1,5 +1,5 @@ import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import type { StepState } from '../settings/BrowserUseStepBadge' +import type { StepState } from '../settings/SetupStepBadge' export function getMobileEmulatorCliPathNeedsAttention(status: CliInstallStatus | null): boolean { return status?.state === 'installed' && status.pathConfigured === false diff --git a/src/renderer/src/components/github-pr-merge-state.test.ts b/src/renderer/src/components/github-pr-merge-state.test.ts index a2f52f8fd31..036d34c4c0d 100644 --- a/src/renderer/src/components/github-pr-merge-state.test.ts +++ b/src/renderer/src/components/github-pr-merge-state.test.ts @@ -6,7 +6,7 @@ function pr(overrides: Partial = {}): GitHubPRMergeStat state: 'open', mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN', - checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0 }, + checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0, neutral: 0 }, autoMergeAllowed: true, ...overrides } @@ -44,7 +44,14 @@ describe('presentGitHubPRMergeState', () => { presentGitHubPRMergeState( pr({ mergeStateStatus: 'BLOCKED', - checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } + checksSummary: { + state: 'pending', + total: 1, + passed: 0, + failed: 0, + pending: 1, + neutral: 0 + } }) ).autoMergeAction ).toMatchObject({ kind: 'enable', label: 'Enable auto-merge' }) @@ -53,7 +60,16 @@ describe('presentGitHubPRMergeState', () => { it('does not offer enable auto-merge when only optional checks are pending', () => { expect( presentGitHubPRMergeState( - pr({ checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } }) + pr({ + checksSummary: { + state: 'pending', + total: 1, + passed: 0, + failed: 0, + pending: 1, + neutral: 0 + } + }) ) ).toMatchObject({ label: 'Checks pending', @@ -68,7 +84,14 @@ describe('presentGitHubPRMergeState', () => { pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE', - checksSummary: { state: 'failure', total: 1, passed: 0, failed: 1, pending: 0 } + checksSummary: { + state: 'failure', + total: 1, + passed: 0, + failed: 1, + pending: 0, + neutral: 0 + } }) ).autoMergeAction ).toBeNull() @@ -110,7 +133,7 @@ describe('presentGitHubPRMergeState', () => { expect( presentGitHubPRMergeState({ state: 'open', - checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0 } + checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0, neutral: 0 } }) ).toMatchObject({ label: 'Checks passed', directMergeAvailable: true, autoMergeAction: null }) expect(presentGitHubPRMergeState(pr())).toMatchObject({ @@ -135,12 +158,30 @@ describe('presentGitHubPRMergeState', () => { }) expect( presentGitHubPRMergeState( - pr({ checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } }) + pr({ + checksSummary: { + state: 'pending', + total: 1, + passed: 0, + failed: 0, + pending: 1, + neutral: 0 + } + }) ) ).toMatchObject({ label: 'Checks pending', directMergeAvailable: true }) expect( presentGitHubPRMergeState( - pr({ checksSummary: { state: 'failure', total: 1, passed: 0, failed: 1, pending: 0 } }) + pr({ + checksSummary: { + state: 'failure', + total: 1, + passed: 0, + failed: 1, + pending: 0, + neutral: 0 + } + }) ) ).toMatchObject({ label: 'Checks failed', directMergeAvailable: true }) expect(presentGitHubPRMergeState(pr())).toMatchObject({ @@ -155,7 +196,14 @@ describe('presentGitHubPRMergeState', () => { pr({ mergeable: 'UNKNOWN', mergeStateStatus: null, - checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } + checksSummary: { + state: 'pending', + total: 1, + passed: 0, + failed: 0, + pending: 1, + neutral: 0 + } }) ) ).toMatchObject({ @@ -168,7 +216,7 @@ describe('presentGitHubPRMergeState', () => { expect( presentGitHubPRMergeState({ state: 'open', - checksSummary: { state: 'success', total: 3, passed: 3, failed: 0, pending: 0 } + checksSummary: { state: 'success', total: 3, passed: 3, failed: 0, pending: 0, neutral: 0 } }) ).toMatchObject({ label: 'Checks passed', @@ -179,7 +227,14 @@ describe('presentGitHubPRMergeState', () => { pr({ mergeable: 'UNKNOWN', mergeStateStatus: null, - checksSummary: { state: 'success', total: 3, passed: 3, failed: 0, pending: 0 } + checksSummary: { + state: 'success', + total: 3, + passed: 3, + failed: 0, + pending: 0, + neutral: 0 + } }) ) ).toMatchObject({ diff --git a/src/renderer/src/components/github-pr-merge-state.ts b/src/renderer/src/components/github-pr-merge-state.ts index a5b1663c234..ca6c47e71b4 100644 --- a/src/renderer/src/components/github-pr-merge-state.ts +++ b/src/renderer/src/components/github-pr-merge-state.ts @@ -1,6 +1,6 @@ import type { CheckStatus, - GitHubPRCheckSummary, + ProviderCheckSummary, PRMergeableState, PRReviewDecision, PRState @@ -14,7 +14,7 @@ export type GitHubPRMergeStateInput = { mergeStateStatus?: string | null reviewDecision?: PRReviewDecision | null checksStatus?: CheckStatus - checksSummary?: GitHubPRCheckSummary + checksSummary?: ProviderCheckSummary autoMergeEnabled?: boolean autoMergeAllowed?: boolean | null mergeQueueRequired?: boolean | null diff --git a/src/renderer/src/components/integration-status-pill.tsx b/src/renderer/src/components/integration-status-pill.tsx index 418826cbe6b..a36faa2a926 100644 --- a/src/renderer/src/components/integration-status-pill.tsx +++ b/src/renderer/src/components/integration-status-pill.tsx @@ -4,7 +4,7 @@ export type IntegrationStatusTone = 'connected' | 'attention' | 'neutral' const TONE_CLASSES: Record = { connected: { - pill: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300', + pill: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' }, attention: { diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index bfe556addf4..414b438bad7 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -113,4 +113,8 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(FIELD_SOURCE).toContain('onPointerDown={() => {') expect(FIELD_SOURCE).toContain('markSourcePopoverUserEngaged()') }) + + it('confines source-mode overflow to the source strip', () => { + expect(FIELD_SOURCE).toContain('overflow-x-auto overflow-y-hidden px-0 scrollbar-sleek') + }) }) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index a0168f90093..ead339690ea 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -394,11 +394,10 @@ export default function SmartWorkspaceNameField({ link: NonNullable> matchingRepo: RepoOption | null } | null>(null) - // Why: Jira status is read lazily — mounting the composer must not cost an IPC/RPC round-trip, - // so only reach for it once the user opens the source picker, picks Jira, or pastes an issue URL. + // Why: read Jira status when the composer mounts so an already-configured source is available + // before users start typing, without showing Jira for hosts where it is not configured. const jiraConnection = useJiraSourceConnection({ - enabled: - !disabled && !textOnly && (open || mode === 'jira' || isBlockingJiraUrlIntent(mode, value)), + enabled: !disabled && !textOnly && jiraSourceContext !== null, sourceContext: jiraSourceContext }) const jiraConnectionStatus = jiraConnection.status @@ -1624,7 +1623,7 @@ export default function SmartWorkspaceNameField({ { // Why: Radix Tabs roving focus re-applies tabindex=0 to the active trigger (races React commits), so forward Tab to the input. const previous = event.relatedTarget as HTMLElement | null diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx new file mode 100644 index 00000000000..1b55391dec4 --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal' +import { + ORCA_TERMINAL_COMMAND_FINISHED_EVENT, + type TerminalCommandFinishedEventDetail +} from '@/hooks/terminal-command-finished-event' + +const mocks = vi.hoisted(() => ({ + createTab: vi.fn(() => ({ id: 'tab-1' })), + closeTab: vi.fn(), + setActiveTabForWorktree: vi.fn(), + setTabCustomTitle: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + createTab: mocks.createTab, + closeTab: mocks.closeTab, + setActiveTabForWorktree: mocks.setActiveTabForWorktree, + setTabCustomTitle: mocks.setTabCustomTitle + }) +})) + +vi.mock('@/components/terminal-pane/TerminalPane', () => ({ + default: () =>
+})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: vi.fn() +})) + +function dispatchCommandFinished(worktreeId: string, exitCode: number | null): void { + window.dispatchEvent( + new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { + detail: { worktreeId, exitCode } + }) + ) +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +describe('OnboardingInlineCommandTerminal command-finished forwarding', () => { + beforeEach(() => { + mocks.createTab.mockClear() + mocks.closeTab.mockClear() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { + getFloatingTerminalCwd: vi.fn(async () => '/tmp') + } + } + }) + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + Reflect.deleteProperty(window, 'api') + }) + + it('forwards exit codes only for its own branded worktree id', async () => { + const onCommandFinished = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render( + + ) + }) + await act(async () => {}) + + await act(async () => { + dispatchCommandFinished('some-other-worktree', 1) + }) + expect(onCommandFinished).not.toHaveBeenCalled() + + // Why unbranded-miss matters: pty-connection dispatches the BRANDED id; + // matching the raw panel id would silently never fire in production. + await act(async () => { + dispatchCommandFinished('settings-orchestration-skill-terminal', 1) + }) + expect(onCommandFinished).not.toHaveBeenCalled() + + await act(async () => { + dispatchCommandFinished('ephemeral-setup-terminal:settings-orchestration-skill-terminal', 1) + }) + expect(onCommandFinished).toHaveBeenCalledWith(1) + + await act(async () => { + dispatchCommandFinished( + 'ephemeral-setup-terminal:settings-orchestration-skill-terminal', + null + ) + }) + expect(onCommandFinished).toHaveBeenLastCalledWith(null) + }) +}) diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index a0b8e894b47..0a7f0ae7a8f 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -3,6 +3,10 @@ import { Loader2 } from 'lucide-react' import TerminalPane from '@/components/terminal-pane/TerminalPane' import { PASTE_TERMINAL_TEXT_EVENT, type PasteTerminalTextDetail } from '@/constants/terminal' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { + ORCA_TERMINAL_COMMAND_FINISHED_EVENT, + type TerminalCommandFinishedEventDetail +} from '@/hooks/terminal-command-finished-event' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { brandEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id' @@ -29,6 +33,8 @@ type OnboardingInlineCommandTerminalProps = { onOpened?: () => void onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent) => void onTerminalExit?: () => void + // OSC 133;D reports the command outcome while the shell remains alive. + onCommandFinished?: (bestEffortExitCode: number | null) => void } /** @@ -48,7 +54,8 @@ export function OnboardingInlineCommandTerminal({ shellOverride, onOpened, onInteracted, - onTerminalExit + onTerminalExit, + onCommandFinished }: OnboardingInlineCommandTerminalProps): React.JSX.Element { // Why: brand the id so a remote runtime scopes this ephemeral terminal to the // floating terminal instead of rejecting the synthetic id. @@ -80,6 +87,24 @@ export function OnboardingInlineCommandTerminal({ onOpened?.() }, [onOpened]) + // Why: the branded id isolates command outcomes to this inline terminal. + useEffect(() => { + if (!onCommandFinished) { + return + } + const handleCommandFinished = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.worktreeId !== worktreeId) { + return + } + onCommandFinished(detail.exitCode) + } + window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + return () => { + window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + } + }, [onCommandFinished, worktreeId]) + useEffect(() => { let cancelled = false void window.api.app.getFloatingTerminalCwd({ path: '~' }).then((nextCwd) => { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index d349d789aa0..6f5a4cc5fb1 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -265,11 +265,14 @@ import { getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep, + shouldAttemptCreateHostedReviewForIntent, + shouldGenerateHostedReviewDetailsForIntent, type CreatePrIntentRunToken } from './source-control-create-pr-intent-flow' import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state' import { resolveBlockedCreateReviewNoticeMessage } from './source-control-create-review-blocked-action' import { + buildCreatePrIntentUnavailableEligibility, buildLoadingHostedReviewCreationEligibility, buildLocalBlockerHostedReviewCreationEligibility, resolveHostedReviewCreationProviderForTarget @@ -1580,6 +1583,7 @@ function SourceControlInner(): React.JSX.Element { resolveHostedReviewCreationProviderForTarget( hostedReviewCreationProviderHintRef.current, { repoId: activeRepoId, worktreeId: activeWorktreeId ?? null, branch: branchName }, + // Why: provisional already infers the remote host and defaults to github; never fall back to unsupported mid-load. provisionalHostedReviewProvider ) ) @@ -3409,7 +3413,7 @@ function SourceControlInner(): React.JSX.Element { token: CreatePrIntentRunToken, eligibility: HostedReviewCreationEligibility ): Promise => { - if (!activeRepo || !token.branch || !eligibility.canCreate) { + if (!activeRepo || !token.branch || !shouldAttemptCreateHostedReviewForIntent(eligibility)) { return false } @@ -3441,6 +3445,7 @@ function SourceControlInner(): React.JSX.Element { } if ( + shouldGenerateHostedReviewDetailsForIntent(eligibility) && hasConfiguredSourceControlTextGenerationDefaults({ actionId: 'pullRequest', settings, @@ -3651,23 +3656,43 @@ function SourceControlInner(): React.JSX.Element { if (!activeRepo || !token.branch) { return null } - const result = await getHostedReviewCreationEligibility({ - repoPath: activeRepo.path, - repoId: activeRepo.id, - worktreePath: token.worktreePath, - branch: token.branch, - base: token.baseRef ?? null, - hasUncommittedChanges, - hasUpstream: upstreamStatus?.hasUpstream, - ahead: upstreamStatus?.ahead, - behind: upstreamStatus?.behind, - linkedGitHubPR, - fallbackGitHubPR: fallbackGitHubPRNumber, - linkedGitLabMR, - linkedBitbucketPR, - linkedAzureDevOpsPR, - linkedGiteaPR - }) + let result: HostedReviewCreationEligibility + try { + result = await getHostedReviewCreationEligibility({ + repoPath: activeRepo.path, + repoId: activeRepo.id, + worktreePath: token.worktreePath, + branch: token.branch, + base: token.baseRef ?? null, + hasUncommittedChanges, + hasUpstream: upstreamStatus?.hasUpstream, + ahead: upstreamStatus?.ahead, + behind: upstreamStatus?.behind, + linkedGitHubPR, + fallbackGitHubPR: fallbackGitHubPRNumber, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR + }) + } catch (error) { + console.warn('[SourceControl] Create PR intent eligibility failed', error) + // Why: when local status still yields a prep step (dirty/push/sync), keep the intent + // moving. If nothing actionable can be synthesized, rethrow so the outer intent + // catch surfaces a retry notice instead of leaving "Preparing…" stuck forever. + const fallback = buildCreatePrIntentUnavailableEligibility(token.provider, { + branch: token.branch, + baseRef: token.baseRef, + hasUncommittedChanges, + hasUpstream: upstreamStatus?.hasUpstream, + ahead: upstreamStatus?.ahead, + behind: upstreamStatus?.behind + }) + if (!fallback) { + throw error + } + result = fallback + } setHostedReviewCreationState({ repoId: activeRepo.id, worktreeId: token.worktreeId, @@ -3739,6 +3764,9 @@ function SourceControlInner(): React.JSX.Element { worktreeId: activeWorktreeId, worktreePath, branch: branchName, + // Why: token carries the same provisional provider used for UI copy so a failed + // eligibility IPC can synthesize local prep steps for the correct host. + provider: provisionalHostedReviewProvider, // Why: intent crosses async commit/push steps, so the base stays tied to what was selected when the run started. baseRef: effectiveBaseRef ?? null }) @@ -3939,19 +3967,25 @@ function SourceControlInner(): React.JSX.Element { } } - const branchAhead = await refreshBranchCompareForCreatePrIntent(token) - if (abortIfStale()) { - return - } let eligibility = await readHostedReviewCreationEligibilityForIntent({ token, hasUncommittedChanges: latestStatusEntries.length > 0, upstreamStatus: latestUpstreamStatus }) - if (abortIfStale() || !eligibility) { + if (abortIfStale()) { return } - if (eligibility.canCreate) { + if (!eligibility) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.d7492cafce', + 'Could not refresh Source Control. Retry Create PR.' + ) + }) + return + } + if (shouldAttemptCreateHostedReviewForIntent(eligibility)) { await createHostedReviewForCreatePrIntent(token, eligibility) if (abortIfStale()) { return @@ -3963,6 +3997,13 @@ function SourceControlInner(): React.JSX.Element { return } + const branchAhead = + eligibility.blockedReason === 'no_upstream' + ? await refreshBranchCompareForCreatePrIntent(token) + : undefined + if (abortIfStale()) { + return + } const remoteStep = resolveCreatePrIntentRemoteStep({ upstreamStatus: latestUpstreamStatus, hostedReviewCreation: eligibility, @@ -4047,19 +4088,23 @@ function SourceControlInner(): React.JSX.Element { if (abortIfStale()) { return } - if (eligibility?.canCreate) { + if (eligibility && shouldAttemptCreateHostedReviewForIntent(eligibility)) { await createHostedReviewForCreatePrIntent(token, eligibility) if (abortIfStale()) { return } return } + // Why: prefer the blocked-reason notice (incl. unavailable lookup) over a generic stop. + const blockedNotice = resolveBlockedCreateReviewNoticeMessage(eligibility) setCreatePrIntentNoticeForWorktree(token.worktreeId, { - tone: 'muted', - message: translate( - 'auto.components.right.sidebar.SourceControl.995c5e67ec', - 'Review setup needs attention.' - ) + tone: blockedNotice ? 'destructive' : 'muted', + message: + blockedNotice ?? + translate( + 'auto.components.right.sidebar.SourceControl.995c5e67ec', + 'Review setup needs attention.' + ) }) } catch (error) { console.warn('[SourceControl] Create PR intent failed', error) @@ -4106,6 +4151,7 @@ function SourceControlInner(): React.JSX.Element { readHostedReviewCreationEligibilityForIntent, refreshGitStatusForCreatePrIntent, refreshBranchCompareForCreatePrIntent, + provisionalHostedReviewProvider, remoteStatus, runRemoteAction, setCreatePrIntentNoticeForWorktree, diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index ab2716d1558..1d83301ae8e 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -99,6 +99,7 @@ import { import { translate } from '@/i18n/i18n' import { useActiveWorktree } from '@/store/selectors' import { useAppStore } from '@/store' +import { sortChecksBySeverity } from '../../../../shared/pr-check-severity-order' export const PullRequestIcon = GitPullRequest @@ -504,17 +505,6 @@ export function ConflictTriageStrip({ ) } -const CHECK_SORT_ORDER: Record = { - failure: 0, - timed_out: 0, - action_required: 0, - cancelled: 1, - pending: 2, - neutral: 3, - skipped: 4, - success: 5 -} - type CheckDetailsLoadState = { loading: boolean details: PRCheckRunDetails | null @@ -970,15 +960,7 @@ export function ChecksList({ shouldConstrainCheckList && checks.length > 0 ) detailsContextRef.current = checkDetailsContextKey - const sorted = React.useMemo( - () => - [...checks].sort( - (a, b) => - (CHECK_SORT_ORDER[a.conclusion ?? 'pending'] ?? 3) - - (CHECK_SORT_ORDER[b.conclusion ?? 'pending'] ?? 3) - ), - [checks] - ) + const sorted = React.useMemo(() => sortChecksBySeverity(checks), [checks]) const rows = React.useMemo( () => sorted.map((check, index) => ({ diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index 753ebaaa750..c28e428fb4d 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -8,7 +8,9 @@ import { getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, - resolveCreatePrIntentRemoteStep + resolveCreatePrIntentRemoteStep, + shouldAttemptCreateHostedReviewForIntent, + shouldGenerateHostedReviewDetailsForIntent } from './source-control-create-pr-intent-flow' import type { GitStatusEntry } from '../../../../shared/types' @@ -21,6 +23,7 @@ describe('source-control Create PR intent flow helpers', () => { worktreeId: 'wt-1', worktreePath: '/repo', branch: 'feature', + provider: 'github', baseRef: 'origin/main' }) @@ -44,7 +47,8 @@ describe('source-control Create PR intent flow helpers', () => { repoId: 'repo-1', worktreeId: 'wt-1', worktreePath: '/repo', - branch: 'feature/pr' + branch: 'feature/pr', + provider: 'github' }) expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/feature/pr' })).toBe( @@ -63,7 +67,8 @@ describe('source-control Create PR intent flow helpers', () => { repoId: 'repo-1', worktreeId: 'wt-1', worktreePath: wt1Path, - branch: 'feature/pr' + branch: 'feature/pr', + provider: 'github' }) expect( @@ -92,6 +97,7 @@ describe('source-control Create PR intent flow helpers', () => { worktreeId: 'wt-1', worktreePath, branch: 'feature/pr', + provider: 'github', baseRef: 'refs/remotes/origin/main' }) @@ -283,6 +289,41 @@ describe('source-control Create PR intent flow helpers', () => { ).toBe('blocked') }) + it('uses main preflight as the final lookup authority after preparation', () => { + const unavailable = { + provider: 'github' as const, + review: null, + canCreate: false, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' as const, + head: 'feature-branch' + } + expect(shouldAttemptCreateHostedReviewForIntent(unavailable)).toBe(true) + // Loading placeholders share the unavailable/null-reason shape but carry no branch. + expect(shouldAttemptCreateHostedReviewForIntent({ ...unavailable, head: undefined })).toBe( + false + ) + expect(shouldGenerateHostedReviewDetailsForIntent(unavailable)).toBe(false) + expect( + shouldGenerateHostedReviewDetailsForIntent({ + ...unavailable, + canCreate: true, + reviewLookupOutcome: 'not_found' + }) + ).toBe(true) + expect( + shouldAttemptCreateHostedReviewForIntent({ + provider: 'github', + review: null, + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'unavailable' + }) + ).toBe(false) + }) + it('surfaces the commit failure summary in the Create PR intent notice', () => { expect( getCreatePrIntentCommitFailureNoticeMessage( diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts index bafba4c8504..03abd03a755 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -2,7 +2,10 @@ import { isBehindOnlyUpstream, shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' -import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' +import type { + HostedReviewCreationEligibility, + HostedReviewProvider +} from '../../../../shared/hosted-review' import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef @@ -24,6 +27,7 @@ export type CreatePrIntentRunToken = { worktreeId: string worktreePath: string branch: string + provider: HostedReviewProvider baseRef?: string | null startedAt: number } @@ -162,6 +166,25 @@ export function resolveCreatePrIntentRemoteStep({ return 'none' } +export function shouldAttemptCreateHostedReviewForIntent( + eligibility: HostedReviewCreationEligibility +): boolean { + return ( + eligibility.canCreate || + // Why: `head` separates a real unavailable-lookup result from a loading + // placeholder, which carries the same outcome/reason pair but no branch. + (eligibility.reviewLookupOutcome === 'unavailable' && + eligibility.blockedReason === null && + Boolean(eligibility.head?.trim())) + ) +} + +export function shouldGenerateHostedReviewDetailsForIntent( + eligibility: HostedReviewCreationEligibility +): boolean { + return eligibility.canCreate +} + export function getCreatePrIntentCommitFailureNoticeMessage( commitError: string | null | undefined, copy: { diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts index e2bd937f1c2..c40468b9e49 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { canClickBlockedCreateReviewReason, - resolveBlockedCreateReviewNoticeMessage + resolveBlockedCreateReviewNoticeMessage, + resolveUnavailableCreateReviewLookupNoticeMessage } from './source-control-create-review-blocked-action' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' @@ -62,6 +63,51 @@ describe('source-control-create-review-blocked-action', () => { ) }) + it('preserves a known dirty-tree prerequisite when review lookup is unavailable', () => { + expect( + resolveBlockedCreateReviewNoticeMessage( + eligibility({ + blockedReason: 'dirty', + nextAction: 'commit', + reviewLookupOutcome: 'unavailable' + }) + ) + ).toBe('Create PR failed: commit or discard local changes before creating a pull request.') + }) + + it('preserves authentication guidance when review lookup is unavailable', () => { + expect( + resolveBlockedCreateReviewNoticeMessage( + eligibility({ + provider: 'gitlab', + blockedReason: 'auth_required', + nextAction: 'authenticate', + reviewLookupOutcome: 'unavailable' + }) + ) + ).toBe( + 'Create MR failed: GitLab is not authenticated. Next step: Run glab auth login in this environment.' + ) + }) + + it('reports unavailable review lookup authority when no local blocker is known', () => { + expect(resolveUnavailableCreateReviewLookupNoticeMessage('gitlab')).toBe( + 'Create MR failed: Orca could not confirm whether this branch already has a merge request. Retry once the GitLab lookup succeeds.' + ) + expect( + resolveBlockedCreateReviewNoticeMessage( + eligibility({ + provider: 'gitlab', + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' + }) + ) + ).toBe( + 'Create MR failed: Orca could not confirm whether this branch already has a merge request. Retry once the GitLab lookup succeeds.' + ) + }) + it('returns null when the blocked reason should remain non-clickable', () => { expect( resolveBlockedCreateReviewNoticeMessage( diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts index 4bae174619b..1e5c02efb84 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts @@ -36,6 +36,13 @@ export function resolveHostedReviewAuthInstruction(provider: HostedReviewProvide return 'Run gh auth login' } +export function resolveUnavailableCreateReviewLookupNoticeMessage( + provider: HostedReviewProvider +): string { + const copy = localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(provider)) + return `Create ${copy.shortLabel} failed: Orca could not confirm whether this branch already has a ${copy.reviewLabel}. Retry once the ${copy.providerName} lookup succeeds.` +} + export function resolveBlockedCreateReviewNoticeMessage( eligibility: HostedReviewCreationEligibility | null | undefined ): string | null { @@ -43,6 +50,9 @@ export function resolveBlockedCreateReviewNoticeMessage( return null } const reason = eligibility.blockedReason + if (eligibility.reviewLookupOutcome === 'unavailable' && reason === null) { + return resolveUnavailableCreateReviewLookupNoticeMessage(eligibility.provider) + } if (!canClickBlockedCreateReviewReason(reason)) { return null } diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts new file mode 100644 index 00000000000..be66db37a06 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { + resolveDropdownItems, + type DropdownActionInputs, + type DropdownItem +} from './source-control-dropdown-items' + +// Why: a shared defaults object keeps each case row terse while making the +// "this is the one knob that differs from the baseline" intent obvious. +function inputs(overrides: Partial = {}): DropdownActionInputs { + return { + stagedCount: 0, + hasUnstagedChanges: false, + hasStageableChanges: false, + hasPartiallyStagedChanges: false, + hasMessage: false, + hasUnresolvedConflicts: false, + isCommitting: false, + isRemoteOperationActive: false, + upstreamStatus: undefined, + ...overrides + } +} + +describe('resolveDropdownItems Create PR intent', () => { + it('enables the push-before-PR recovery action when review creation is only blocked by unpushed commits', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, + hostedReviewCreation: { + provider: 'github', + review: null, + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.disabled).toBe(false) + expect(byKind.create_pr.hint).toBe('Push first') + expect(byKind.push_create_pr.label).toBe('Push before PR') + expect(byKind.push_create_pr.disabled).toBe(false) + }) + + it.each([ + { + name: 'push', + provider: 'github' as const, + upstreamStatus: { hasUpstream: true as const, ahead: 2, behind: 0 }, + blockedReason: 'needs_push' as const, + expectedTitle: 'Push local commits before creating a pull request' + }, + { + name: 'force push', + provider: 'gitlab' as const, + upstreamStatus: { + hasUpstream: true as const, + upstreamName: 'origin/feature', + ahead: 2, + behind: 1, + behindCommitsArePatchEquivalent: true + }, + blockedReason: 'needs_sync' as const, + expectedTitle: 'Force push with lease before creating a merge request' + } + ])( + 'keeps $name-before-review recovery available when review lookup is unavailable', + ({ provider, upstreamStatus, blockedReason, expectedTitle }) => { + const items = resolveDropdownItems( + inputs({ + branchCommitsAhead: 2, + upstreamStatus, + hostedReviewCreation: { + provider, + review: null, + canCreate: false, + blockedReason, + nextAction: blockedReason === 'needs_push' ? 'push' : 'sync', + reviewLookupOutcome: 'unavailable' + } + }) + ) + const pushCreate = items.find((item): item is DropdownItem => item.kind === 'push_create_pr') + + expect(pushCreate?.disabled).toBe(false) + expect(pushCreate?.title).toBe(expectedTitle) + expect(pushCreate?.hint).toBeUndefined() + } + ) + + it('uses GitLab MR copy for create and push-before-create rows', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, + hostedReviewCreation: { + provider: 'gitlab', + review: null, + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.label).toBe('Create MR') + expect(byKind.create_pr.hint).toBe('Push first') + expect(byKind.create_pr.disabled).toBe(false) + expect(byKind.push_create_pr.label).toBe('Push before MR') + expect(byKind.push_create_pr.title).toBe('Push local commits before creating a merge request') + expect(byKind.push_create_pr.disabled).toBe(false) + }) + + it.each(['azure-devops', 'gitea'] as const)( + 'enables push-before-PR recovery for %s review creation', + (provider) => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, + hostedReviewCreation: { + provider, + review: null, + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.label).toBe('Create PR') + expect(byKind.create_pr.hint).toBe('Push first') + expect(byKind.create_pr.disabled).toBe(false) + expect(byKind.push_create_pr.label).toBe('Push before PR') + expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request') + expect(byKind.push_create_pr.disabled).toBe(false) + } + ) + + it.each([ + ['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'], + ['gitea', 'Set ORCA_GITEA_TOKEN in this environment'] + ] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + provider, + review: null, + canCreate: false, + blockedReason: 'auth_required', + nextAction: 'authenticate', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.hint).toBe(hint) + }) + + it('uses GitLab auth copy when MR creation needs authentication', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + provider: 'gitlab', + review: null, + canCreate: false, + blockedReason: 'auth_required', + nextAction: 'authenticate', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.hint).toBe('Run glab auth login in this environment') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index d97707dd273..14573a50ec2 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -663,125 +663,6 @@ describe('resolveDropdownItems', () => { 'Try a fast-forward pull; git may reject local commits' ) }) - - it('enables the push-before-PR recovery action when review creation is only blocked by unpushed commits', () => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, - hostedReviewCreation: { - provider: 'github', - review: null, - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.disabled).toBe(false) - expect(byKind.create_pr.hint).toBe('Push first') - expect(byKind.push_create_pr.label).toBe('Push before PR') - expect(byKind.push_create_pr.disabled).toBe(false) - }) - - it('uses GitLab MR copy for create and push-before-create rows', () => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.label).toBe('Create MR') - expect(byKind.create_pr.hint).toBe('Push first') - expect(byKind.create_pr.disabled).toBe(false) - expect(byKind.push_create_pr.label).toBe('Push before MR') - expect(byKind.push_create_pr.title).toBe('Push local commits before creating a merge request') - expect(byKind.push_create_pr.disabled).toBe(false) - }) - - it.each(['azure-devops', 'gitea'] as const)( - 'enables push-before-PR recovery for %s review creation', - (provider) => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, - hostedReviewCreation: { - provider, - review: null, - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.label).toBe('Create PR') - expect(byKind.create_pr.hint).toBe('Push first') - expect(byKind.create_pr.disabled).toBe(false) - expect(byKind.push_create_pr.label).toBe('Push before PR') - expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request') - expect(byKind.push_create_pr.disabled).toBe(false) - } - ) - - it.each([ - ['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'], - ['gitea', 'Set ORCA_GITEA_TOKEN in this environment'] - ] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, - hostedReviewCreation: { - provider, - review: null, - canCreate: false, - blockedReason: 'auth_required', - nextAction: 'authenticate', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.hint).toBe(hint) - }) - - it('uses GitLab auth copy when MR creation needs authentication', () => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: false, - blockedReason: 'auth_required', - nextAction: 'authenticate', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.hint).toBe('Run glab auth login in this environment') - }) }) // Why: PR #8196 — drive the real push-target resolution the component uses so diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts index 13d7bc5dac9..ec4031c7aed 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + buildCreatePrIntentUnavailableEligibility, buildLocalBlockerHostedReviewCreationEligibility, resolveHostedReviewCreationProviderForTarget } from './source-control-hosted-review-creation-eligibility-snapshot' @@ -32,7 +33,7 @@ describe('resolveHostedReviewCreationProviderForTarget', () => { }) describe('buildLocalBlockerHostedReviewCreationEligibility', () => { - it('reports dirty without offering create intent when the review lookup failed', () => { + it('reports dirty with unavailable lookup while still allowing prepare-only Create PR intent', () => { const eligibility = buildLocalBlockerHostedReviewCreationEligibility('github', { ...featureBranch, hasUncommittedChanges: true, @@ -45,7 +46,7 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { nextAction: 'commit', reviewLookupOutcome: 'unavailable' }) - // Why: branch guidance can remain specific, but a failed lookup cannot authorize creation. + // Why: branch prep is safe without lookup authority; final create stays fail-closed in main. expect( resolveCreatePrIntentEligibility({ stagedCount: 1, @@ -56,7 +57,7 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { hostedReviewCreation: eligibility, branchCommitsAhead: 0 }) - ).toEqual({ eligible: false, kind: null }) + ).toEqual({ eligible: true, kind: 'dirty' }) }) it('prefers dirty over no_upstream when both apply, matching main-process ordering', () => { @@ -122,6 +123,19 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { ).toBeNull() }) + it('returns null when the default branch is unknown', () => { + expect( + buildLocalBlockerHostedReviewCreationEligibility('github', { + branch: 'main', + baseRef: null, + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).toBeNull() + }) + it('returns null for a detached HEAD', () => { expect( buildLocalBlockerHostedReviewCreationEligibility('github', { @@ -185,3 +199,72 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { ).toBeNull() }) }) + +describe('buildCreatePrIntentUnavailableEligibility', () => { + it('keeps a rejected click-time probe moving through push and final preflight', () => { + expect( + buildCreatePrIntentUnavailableEligibility('github', { + ...featureBranch, + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 2, + behind: 0 + }) + ).toMatchObject({ + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'unavailable' + }) + expect( + buildCreatePrIntentUnavailableEligibility('github', { + ...featureBranch, + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).toMatchObject({ + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' + }) + }) + + it('never synthesizes final intent eligibility for the default branch', () => { + expect( + buildCreatePrIntentUnavailableEligibility('github', { + branch: 'main', + baseRef: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 1, + behind: 0 + }) + ).toBeNull() + }) + + it('never synthesizes intent eligibility when the default branch is unknown', () => { + expect( + buildCreatePrIntentUnavailableEligibility('github', { + branch: 'main', + baseRef: null, + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).toBeNull() + }) + + it('never synthesizes intent eligibility for an unsupported remote provider', () => { + expect( + buildCreatePrIntentUnavailableEligibility('bitbucket', { + ...featureBranch, + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts index 0857f94f6a0..b964323830e 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts @@ -5,6 +5,33 @@ import type { HostedReviewProvider } from '../../../../shared/hosted-review' +type UnavailableHostedReviewStatus = { + branch: string | null | undefined + baseRef: string | null | undefined + hasUncommittedChanges: boolean + hasUpstream: boolean | undefined + ahead: number | undefined + behind: number | undefined +} + +function resolveUnavailableHostedReviewBranch( + provider: HostedReviewProvider, + status: UnavailableHostedReviewStatus +): string | null { + const branch = status.branch?.trim() ?? '' + const baseBranch = normalizeHostedReviewBaseRef(status.baseRef ?? '').trim() + if ( + branch === '' || + branch === 'HEAD' || + baseBranch === '' || + !supportsHostedReviewCreation(provider) || + branch.toLowerCase() === baseBranch.toLowerCase() + ) { + return null + } + return branch +} + export function buildLoadingHostedReviewCreationEligibility( provider: HostedReviewProvider ): HostedReviewCreationEligibility { @@ -41,44 +68,31 @@ export function resolveHostedReviewCreationProviderForTarget( * times out, so the UI can show branch guidance without treating the failed * lookup as authority to create a review. * - * Mirrors the main process's own lookup-failure fallback exactly — both its - * `canReturnLocalBlocker` guard and its blocker ordering - * (`src/main/source-control/hosted-review-creation.ts`). The guard is - * load-bearing: without it a failed probe on the default branch or a detached - * HEAD would synthesize `dirty`/`commit` and surface an *enabled* Create PR - * that commits onto the base branch, where the real probe returns - * `default_branch`/`detached_head` (disabled). Returns null when no local - * blocker is determinable (unknown upstream, ahead-only, fully synced) so the - * caller can surface the retryable state — matching main, which won't offer a - * push it can't first auth-check. + * Blocker ordering matches main (`dirty` → `no_upstream` → `needs_sync`). The + * branch/base guard is load-bearing and intentionally stricter than main when + * base is unknown: without a known base, a failed probe must not synthesize + * `dirty`/`commit` on what might be the default branch (main would return + * `default_branch`/`detached_head` and keep Create disabled). Returns null + * when no local blocker is determinable (unknown base, unknown upstream, + * ahead-only, fully synced) so the caller can surface the retryable state — + * matching main, which won't offer a push it can't first auth-check. */ export function buildLocalBlockerHostedReviewCreationEligibility( provider: HostedReviewProvider, - status: { - branch: string | null | undefined - baseRef: string | null | undefined - hasUncommittedChanges: boolean - hasUpstream: boolean | undefined - ahead: number | undefined - behind: number | undefined - } + status: UnavailableHostedReviewStatus ): HostedReviewCreationEligibility | null { - const branch = status.branch?.trim() ?? '' - const baseBranch = normalizeHostedReviewBaseRef(status.baseRef ?? '').trim() - const canReturnLocalBlocker = - branch !== '' && - branch !== 'HEAD' && - supportsHostedReviewCreation(provider) && - (baseBranch === '' || branch.toLowerCase() !== baseBranch.toLowerCase()) && - (status.hasUncommittedChanges || status.hasUpstream !== true || (status.behind ?? 0) > 0) - if (!canReturnLocalBlocker) { + const branch = resolveUnavailableHostedReviewBranch(provider, status) + if ( + !branch || + (!status.hasUncommittedChanges && status.hasUpstream === true && (status.behind ?? 0) === 0) + ) { return null } const base = { provider, review: null, canCreate: false as const, - defaultBaseRef: null, + defaultBaseRef: normalizeHostedReviewBaseRef(status.baseRef ?? '').trim(), head: branch, // Why: local Git blockers cannot prove that a hosted review does not exist. reviewLookupOutcome: 'unavailable' as const @@ -96,3 +110,29 @@ export function buildLocalBlockerHostedReviewCreationEligibility( } return null } + +export function buildCreatePrIntentUnavailableEligibility( + provider: HostedReviewProvider, + status: UnavailableHostedReviewStatus +): HostedReviewCreationEligibility | null { + const localBlocker = buildLocalBlockerHostedReviewCreationEligibility(provider, status) + if (localBlocker) { + return localBlocker + } + const branch = resolveUnavailableHostedReviewBranch(provider, status) + if (!branch || status.hasUpstream !== true) { + return null + } + const base = { + provider, + review: null, + canCreate: false as const, + defaultBaseRef: normalizeHostedReviewBaseRef(status.baseRef ?? '').trim(), + head: branch, + reviewLookupOutcome: 'unavailable' as const + } + if ((status.ahead ?? 0) > 0) { + return { ...base, blockedReason: 'needs_push', nextAction: 'push' } + } + return { ...base, blockedReason: null, nextAction: null } +} diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts index c56892b71ec..5c526e047b6 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts @@ -177,6 +177,32 @@ describe('resolvePrimaryAction Create PR intent', () => { }) }) + it('returns Create PR intent for a dirty tree when review lookup is unavailable', () => { + const input = inputs({ + hasUnstagedChanges: true, + hasStageableChanges: true, + upstreamStatus: upstreamInSync, + hostedReviewCreation: { + provider: 'github', + review: null, + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + defaultBaseRef: 'main', + reviewLookupOutcome: 'unavailable' + } + }) + + expect(resolvePrimaryAction(input)).toMatchObject({ + kind: 'create_pr_intent', + disabled: false + }) + expect(resolveCreatePrHeaderAction(input)).toMatchObject({ + kind: 'create_pr_intent', + disabled: false + }) + }) + it('returns Create PR intent for staged changes without a message so the flow can request one', () => { const input = inputs({ stagedCount: 1, diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index d9b5cd182c0..59dd184bab1 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -103,8 +103,8 @@ describe('AccountsPane', () => { it('scopes account copy to the active remote server and disables local sign-in actions', () => { // Note: static SSR markup reads the store's initial state (zustand v5), so - // this exercises the fallback server label; the named-server path is - // covered by live validation against a paired server. + // this exercises the pre-hydration path where no server name is known yet. + // The named-server label is covered in provider-account-scope.test.ts. const markup = renderPane( { ...getDefaultSettings('/tmp'), diff --git a/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx b/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx new file mode 100644 index 00000000000..eba25d637a4 --- /dev/null +++ b/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx @@ -0,0 +1,18 @@ +import { translate } from '@/i18n/i18n' + +export function AgentSkillSetupFailureNotice(props: { + exitCode: number | null +}): React.JSX.Element | null { + if (props.exitCode === null) { + return null + } + return ( +

+ {translate( + 'auto.components.settings.AgentSkillSetupPanel.setupCommandFailed', + 'The setup command exited with code {{value0}}. This error will clear after a successful retry.', + { value0: props.exitCode } + )} +

+ ) +} diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index 69d2360b7e7..ac44ed3285c 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, type ComponentProps } from 'react' +import { act, useState, type ComponentProps } from 'react' import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,9 +12,17 @@ const UPDATE_COMMAND = 'npx skills update orca-cli --global' const mocks = vi.hoisted(() => ({ clipboardWrite: vi.fn(), - terminalProps: [] as { command: string; description: string }[], + terminalProps: [] as { + command: string + description: string + onTerminalExit?: () => void + onCommandFinished?: (bestEffortExitCode: number | null) => void + }[], toastError: vi.fn(), - toastSuccess: vi.fn() + toastSuccess: vi.fn(), + skillsChanged: vi.fn(), + skillsRefreshed: vi.fn(), + terminalInstanceCount: 0 })) vi.mock('sonner', () => ({ @@ -24,14 +32,29 @@ vi.mock('sonner', () => ({ } })) +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + notifyInstalledAgentSkillsChanged: mocks.skillsChanged, + notifyInstalledAgentSkillsRefreshed: mocks.skillsRefreshed +})) + vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ - OnboardingInlineCommandTerminal: (props: { command: string; description: string }) => { + OnboardingInlineCommandTerminal: (props: { + command: string + description: string + onTerminalExit?: () => void + onCommandFinished?: (bestEffortExitCode: number | null) => void + }) => { + const [instance] = useState(() => { + mocks.terminalInstanceCount += 1 + return mocks.terminalInstanceCount + }) mocks.terminalProps.push(props) return (
{props.command}
@@ -39,6 +62,12 @@ vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ } })) +vi.mock('../skills/SkillFreshnessStatusPill', () => ({ + SkillFreshnessStatusPill: ({ skillName }: { skillName: string }) => ( + {skillName} + ) +})) + function panelProps( overrides: Partial> = {} ): ComponentProps { @@ -124,6 +153,9 @@ describe('AgentSkillSetupPanel', () => { mocks.terminalProps.length = 0 mocks.toastError.mockReset() mocks.toastSuccess.mockReset() + mocks.skillsChanged.mockReset() + mocks.skillsRefreshed.mockReset() + mocks.terminalInstanceCount = 0 Object.defineProperty(window, 'api', { configurable: true, value: { @@ -157,6 +189,26 @@ describe('AgentSkillSetupPanel', () => { expect(buttonLabels(html)).toContain('Re-check') }) + it('surfaces skill freshness under hideHeader for guided setup hubs', () => { + const html = renderPanel({ + installed: true, + hideHeader: true, + description: null, + freshnessSkillName: 'orca-linear' + }) + + expect(html).toContain('data-testid="skill-freshness"') + expect(html).toContain('orca-linear') + expect(html).not.toContain('CLI skill') + }) + + it('does not render an empty description paragraph when description is null', () => { + const html = renderPanel({ description: null, hideHeader: true }) + + expect(html).not.toContain('text-muted-foreground">null') + expect(html).not.toMatch(/

<\/p>/) + }) + it('hides only re-check when installed re-checks are disabled', () => { const html = renderPanel({ installed: true, showRecheckWhenInstalled: false }) @@ -217,6 +269,25 @@ describe('AgentSkillSetupPanel', () => { expect(buttonMarkupByLabel(html, 'Install')).toContain('disabled=""') }) + it('notifies sibling surfaces only after re-check finishes', async () => { + let finishRecheck: (() => void) | null = null + const recheck = new Promise((resolve) => { + finishRecheck = resolve + }) + await renderInteractivePanel({ onRecheck: () => recheck }) + + await clickButton('Re-check') + expect(mocks.skillsRefreshed).not.toHaveBeenCalled() + + await act(async () => { + finishRecheck?.() + await recheck + }) + + expect(mocks.skillsRefreshed).toHaveBeenCalledOnce() + expect(mocks.skillsChanged).not.toHaveBeenCalled() + }) + it('opens not-installed setup with the install command for preview, copy, and terminal', async () => { await renderInteractivePanel({ installedCommand: UPDATE_COMMAND }) @@ -300,4 +371,167 @@ describe('AgentSkillSetupPanel', () => { expect(container?.textContent).toContain(INSTALL_COMMAND) expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) }) + + it('keeps a failed setup command visible with durable recovery', async () => { + const onRecheck = vi.fn() + await renderInteractivePanel({ onRecheck }) + await clickButton('Install') + onRecheck.mockClear() + + await act(async () => { + const onCommandFinished = mocks.terminalProps.at(-1)?.onCommandFinished + onCommandFinished?.(1) + onCommandFinished?.(0) + }) + + expect(container?.textContent).toContain( + 'The setup command exited with code 1. This error will clear after a successful retry.' + ) + expect(container?.textContent).toContain('Setup failed') + expect(container?.querySelector('[data-testid="inline-command-terminal"]')).not.toBeNull() + expect(findButton('Retry').disabled).toBe(false) + expect(onRecheck).toHaveBeenCalledTimes(1) + }) + + it('clears the failure notice when a later command succeeds', async () => { + await renderInteractivePanel() + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await clickButton('Retry') + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(0) + }) + + expect(container?.textContent).not.toContain('exited with code') + }) + + it('keeps the failure verdict when a command finishes without an exit code', async () => { + await renderInteractivePanel() + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await clickButton('Retry') + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(null) + }) + + expect(container?.textContent).toContain( + 'The setup command exited with code 1. This error will clear after a successful retry.' + ) + }) + + it('retries a failed command in a fresh interactive terminal', async () => { + let finishRetryPreflight: (() => void) | null = null + const retryPreflight = new Promise((resolve) => { + finishRetryPreflight = resolve + }) + let preflightCount = 0 + await renderInteractivePanel({ + onBeforeOpenTerminal: () => { + preflightCount += 1 + return preflightCount === 1 ? undefined : retryPreflight + } + }) + await clickButton('Install') + const firstInstance = container + ?.querySelector('[data-testid="inline-command-terminal"]') + ?.getAttribute('data-instance') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await clickButton('Retry') + expect(container?.querySelector('[data-testid="inline-command-terminal"]')).toBeNull() + + await act(async () => { + finishRetryPreflight?.() + await retryPreflight + }) + await act(async () => {}) + + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) + expect( + container + ?.querySelector('[data-testid="inline-command-terminal"]') + ?.getAttribute('data-instance') + ).not.toBe(firstInstance) + expect(findButton('Retry').disabled).toBe(true) + }) + + it('keeps the command failure authoritative over presence discovery', async () => { + await renderInteractivePanel({ freshnessSkillName: 'orca-cli' }) + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await rerenderInteractivePanel({ installed: true, freshnessSkillName: 'orca-cli' }) + + expect(container?.textContent).toContain('Setup failed') + expect(container?.textContent).toContain('exited with code 1') + expect(container?.textContent).not.toContain('Installed') + expect(container?.querySelector('[data-testid="skill-freshness"]')).toBeNull() + expect(findButton('Retry').disabled).toBe(false) + }) + + it('keeps failed updates recoverable when installed re-check is hidden', async () => { + await renderInteractivePanel({ + installed: true, + installedCommand: UPDATE_COMMAND, + showRecheckWhenInstalled: false + }) + await clickButton('Update') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + + expect(container?.textContent).toContain('Setup failed') + expect(container?.textContent).toContain('exited with code 1') + expect(findButton('Retry').disabled).toBe(false) + + await clickButton('Retry') + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: UPDATE_COMMAND }) + }) + + it('invalidates shared skill state before the direct completion re-check', async () => { + const calls: string[] = [] + mocks.skillsChanged.mockImplementation(() => calls.push('invalidate')) + const onRecheck = vi.fn(() => { + calls.push('recheck') + }) + await renderInteractivePanel({ freshnessSkillName: 'orca-cli', onRecheck }) + await clickButton('Install') + calls.length = 0 + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(0) + }) + + expect(calls).toEqual(['invalidate', 'recheck']) + }) + + it('re-enables Install after the setup shell exits so a failed attempt can retry', async () => { + await renderInteractivePanel() + await clickButton('Install') + + expect(findButton('Install').disabled).toBe(true) + + await act(async () => { + mocks.terminalProps.at(-1)?.onTerminalExit?.() + }) + + expect(findButton('Install').disabled).toBe(false) + expect(container?.querySelector('[data-testid="inline-command-terminal"]')).toBeNull() + + await clickButton('Install') + + expect(findButton('Install').disabled).toBe(true) + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) + }) }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 02862251d54..c141b9bf472 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -1,62 +1,22 @@ -import { useCallback, useEffect, useState, type ComponentProps, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react' import { toast } from 'sonner' import { IntegrationStatusPill } from '../integration-status-pill' import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' +import { AgentSkillSetupFailureNotice } from './AgentSkillSetupFailureNotice' +import type { AgentSkillSetupPanelProps } from './agent-skill-setup-panel-props' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' -import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { + notifyInstalledAgentSkillsChanged, + notifyInstalledAgentSkillsRefreshed +} from '@/hooks/useInstalledAgentSkills' import { useMountedRef } from '@/hooks/useMountedRef' import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -type AgentSkillSetupPanelVariant = 'card' | 'inline' -type SkillPrerequisiteStatus = Awaited> - -type AgentSkillSetupPanelProps = { - title: string - description: ReactNode - command: string - installedCommand?: string - terminalTitle: string - terminalAriaLabel: string - terminalWorktreeId: string - installed: boolean - loading: boolean - error: string | null - installDisabled?: boolean - terminalHeightPx?: number - terminalShellOverride?: string - leading?: ReactNode - icon?: ReactNode - variant?: AgentSkillSetupPanelVariant - className?: string - // Why: when an enclosing surface (e.g. a modal) already shows the title and - // status, hide the panel's own header row to avoid a duplicate heading. - hideHeader?: boolean - preInstallNotice?: ReactNode - getPrerequisiteStatus?: () => Promise - isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean - onBeforeOpenTerminal?: () => void | Promise - showInstallWhenInstalled?: boolean - showRecheckWhenInstalled?: boolean - installLabel?: string - installedInstallLabel?: string - // Why: defaults to 'outline' so settings panels stay unchanged; modals that make - // Install the sole footer CTA pass 'default' for a filled primary hierarchy. - installVariant?: ComponentProps['variant'] - actionHint?: ReactNode - openingHint?: ReactNode - footer?: ReactNode - onRecheck: () => void | Promise - // Why: when set, the installed pill reflects skill freshness and Re-check also - // refreshes the freshness inventory. Callers omit it for non-local runtimes, - // which the local-host-only freshness scan cannot vouch for. - freshnessSkillName?: string -} - export function AgentSkillSetupPanel({ title, description, @@ -82,8 +42,8 @@ export function AgentSkillSetupPanel({ onBeforeOpenTerminal, showInstallWhenInstalled = true, showRecheckWhenInstalled = true, - installLabel = 'Install', - installedInstallLabel = 'Update', + installLabel, + installedInstallLabel, installVariant = 'outline', actionHint, openingHint, @@ -91,9 +51,19 @@ export function AgentSkillSetupPanel({ onRecheck, freshnessSkillName }: AgentSkillSetupPanelProps): React.JSX.Element { + const resolvedInstallLabel = + installLabel ?? + translate('auto.components.settings.AgentSkillSetupPanel.installLabel', 'Install') + const resolvedInstalledInstallLabel = + installedInstallLabel ?? + translate('auto.components.settings.AgentSkillSetupPanel.updateLabel', 'Update') const [terminalOpen, setTerminalOpen] = useState(false) const [terminalCommand, setTerminalCommand] = useState(null) + const [terminalAttempt, setTerminalAttempt] = useState(0) const [terminalOpening, setTerminalOpening] = useState(false) + const [setupAttemptRunning, setSetupAttemptRunning] = useState(false) + const [setupCommandFailedCode, setSetupCommandFailedCode] = useState(null) + const setupAttemptRunningRef = useRef(false) const [preInstallNoticeVisible, setPreInstallNoticeVisible] = useState( Boolean(preInstallNotice && !installed) ) @@ -107,6 +77,68 @@ export function AgentSkillSetupPanel({ // already-open terminal pinned to the command selected by the user's click. const openTerminalCommand = terminalCommand ?? activeCommand + const openSetupTerminal = (): void => { + if (terminalOpening || setupAttemptRunning) { + return + } + const nextCommand = + setupCommandFailedCode !== null && terminalCommand ? terminalCommand : activeCommand + setTerminalOpening(true) + if (setupCommandFailedCode !== null) { + setTerminalOpen(false) + } + void (async () => { + let shouldOpenTerminal = false + try { + await onBeforeOpenTerminal?.() + await refreshPreInstallNotice() + shouldOpenTerminal = true + } catch { + shouldOpenTerminal = false + } finally { + if (mountedRef.current) { + setTerminalOpening(false) + if (shouldOpenTerminal) { + setTerminalCommand(nextCommand) + setTerminalAttempt((attempt) => attempt + 1) + setTerminalOpen(true) + setupAttemptRunningRef.current = true + setSetupAttemptRunning(true) + } + } + } + })() + } + + // Why: PTY exit is the shell's status; OSC 133;D reports the install command. + const handleSetupCommandFinished = useCallback( + (bestEffortExitCode: number | null): void => { + // Nested shells can emit duplicate completion markers in one PTY chunk. + if (!setupAttemptRunningRef.current) { + return + } + setupAttemptRunningRef.current = false + setSetupAttemptRunning(false) + if (bestEffortExitCode !== null) { + setSetupCommandFailedCode(bestEffortExitCode === 0 ? null : bestEffortExitCode) + } + if (freshnessSkillName) { + notifyInstalledAgentSkillsChanged() + } + void onRecheck() + }, + [freshnessSkillName, onRecheck] + ) + + const handleTerminalExit = useCallback((): void => { + if (mountedRef.current) { + setupAttemptRunningRef.current = false + setTerminalOpen(false) + setSetupAttemptRunning(false) + } + notifyInstalledAgentSkillsChanged() + }, [mountedRef]) + useEffect(() => { if (!preInstallNotice) { setPreInstallNoticeVisible(false) @@ -171,36 +203,12 @@ export function AgentSkillSetupPanel({ const actionRow = (

- {!installed || showInstallWhenInstalled ? ( + {(!installed || showInstallWhenInstalled) && setupCommandFailedCode === null ? ( ) : null} - {!installed || showRecheckWhenInstalled ? ( + {setupCommandFailedCode !== null || !installed || showRecheckWhenInstalled ? ( ) : null} {terminalOpening ? ( @@ -257,9 +275,15 @@ export function AgentSkillSetupPanel({ className={variant === 'card' ? cn('px-5 pt-5', terminalOpen ? 'pb-2' : 'pb-5') : 'pt-1.5'} > {hideHeader ? ( - error ? ( -

{error}

- ) : null + <> + {error ?

{error}

: null} + {/* hideHeader drops the title row; keep freshness so guided hubs still surface updates. */} + {installed && freshnessSkillName && setupCommandFailedCode === null ? ( +
+ +
+ ) : null} + ) : (
{leading} @@ -271,7 +295,14 @@ export function AgentSkillSetupPanel({

{title}

- {loading && !installed ? ( + {setupCommandFailedCode !== null ? ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.setupFailed', + 'Setup failed' + )} + + ) : loading && !installed ? ( {translate( 'auto.components.settings.AgentSkillSetupPanel.68a468752e', @@ -303,8 +334,11 @@ export function AgentSkillSetupPanel({
)}
-

{description}

+ {description != null ? ( +

{description}

+ ) : null} {actionRow} + {actionHint ?
{actionHint}
: null} {!installed && preInstallNotice && preInstallNoticeVisible ? (

@@ -356,6 +390,7 @@ export function AgentSkillSetupPanel({

) : null} diff --git a/src/renderer/src/components/settings/BrowserUseCliStep.tsx b/src/renderer/src/components/settings/BrowserUseCliStep.tsx index e3e2070ec2d..62474b3c2ae 100644 --- a/src/renderer/src/components/settings/BrowserUseCliStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseCliStep.tsx @@ -2,7 +2,7 @@ import type { CliInstallStatus } from '../../../../shared/cli-install-types' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { SearchableSetting } from './SearchableSetting' -import { StepBadge } from './BrowserUseStepBadge' +import { StepBadge } from './SetupStepBadge' import { getBrowserUsePaneSearchEntries } from './browser-use-search' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx index 3ac98241a12..1332883e4a1 100644 --- a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx @@ -16,7 +16,7 @@ import { } from '../ui/dropdown-menu' import { useAppStore } from '../../store' import { SearchableSetting } from './SearchableSetting' -import { StepBadge } from './BrowserUseStepBadge' +import { StepBadge } from './SetupStepBadge' import { getBrowserUsePaneSearchEntries } from './browser-use-search' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx index 20788509441..a9a63cefa73 100644 --- a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' -import { StepBadge } from './BrowserUseStepBadge' +import { StepBadge } from './SetupStepBadge' import { translate } from '@/i18n/i18n' type Props = { diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx index 6150bd808eb..269074ff895 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -243,6 +243,55 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { } }) + it('skips the Windows preflight when the configured Windows shell is POSIX-family', () => { + const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) + const windowsHost = { runtime: 'host', label: 'Windows' } as const + const previous = useAppStore.getState() + + try { + // MSYS rewrites cmd.exe's leading /d /s /c switches into drive paths, so + // the copied command must stay bare for a Git Bash / wsl.exe paste target. + for (const terminalWindowsShell of ['git-bash', 'C:\\Program Files\\Git\\bin\\bash.exe']) { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell } + }) + expect(buildSkillCommandForRuntime(installCommand, windowsHost, 'win32')).toBe( + installCommand + ) + } + + // cmd-family shells still need the preflight wrapper. + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'cmd.exe' } + }) + expect(buildSkillCommandForRuntime(installCommand, windowsHost, 'win32')).toBe( + `${windowsNpxPreflightPrefix}${windowsNpxGuidance}) else (${installCommand})"` + ) + } finally { + useAppStore.setState({ settings: previous.settings }) + } + }) + + it('keeps the bare reinstall rewrite for POSIX-family Windows skill updates', () => { + const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) + const previous = useAppStore.getState() + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'git-bash' } + }) + + try { + expect( + buildSkillCommandForRuntime( + 'npx skills update orchestration --global', + { runtime: 'host', label: 'Windows' }, + 'win32' + ) + ).toBe(installCommand) + } finally { + useAppStore.setState({ settings: previous.settings }) + } + }) + it('does not wrap unrelated Windows host commands', () => { expect( buildSkillCommandForRuntime( diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index bee39049fc5..005b5dfc5c6 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -8,6 +8,7 @@ import { quotePowerShellNativeArgument } from '../../../../shared/powershell-native-argument' import { buildWslLoginShellCommand } from '../../../../shared/wsl-login-shell-command' +import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell' import { getProjectAgentSkillTerminalShellOverride } from '@/lib/project-skill-runtime' import { useAppStore } from '@/store' import { buildAgentFeatureSkillInstallCommand } from '../../../../shared/agent-feature-install-commands' @@ -136,6 +137,10 @@ function wrapWindowsSkillCommandWithNpxPrerequisite( // Why: skill setup terminals spawn on the focused runtime environment, so a // Windows client must not hand a cmd.exe command to a remote host. isRemoteRuntimeEnvironmentFocused() || + // Why: the copied command lands in the user's configured shell, and MSYS + // shells rewrite cmd.exe's leading /d /s /c switches into drive paths, + // starting an interactive cmd session instead of running the payload. + isPosixFamilyWindowsShellConfigured() || !/^npx\s+skills\s+(?:add|update)\b/i.test(trimmedCommand) ) { return command @@ -149,6 +154,13 @@ function wrapWindowsSkillCommandWithNpxPrerequisite( return `cmd.exe /d /s /c "where.exe npx >nul 2>nul & if errorlevel 1 (${missingNpxGuidance}) else (${trimmedCommand})"` } +function isPosixFamilyWindowsShellConfigured(): boolean { + return ( + resolveWindowsShellStartupFamily(useAppStore.getState().settings?.terminalWindowsShell) === + 'posix' + ) +} + function isRemoteRuntimeEnvironmentFocused(): boolean { // Why: the terminal router also weighs how many environments are saved, but // that slice has no subscriber here. Read only the focused id, which every diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index b9e6635f36e..7095ab97163 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -9,6 +9,7 @@ import { SettingsSubsectionHeader } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' import { GeneralRemoteServerUpdates } from './GeneralRemoteServerUpdates' +import { ReleaseChannelSection } from './ReleaseChannelSection' export function GeneralUpdateSettingsSection(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) @@ -39,6 +40,10 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { const [appVersion, setAppVersion] = useState(null) const updateCheckHint = getUpdateCheckHint() + // Why: channel switching is a power-user escape hatch that can downgrade the app + // onto an unvetted build. Option/Alt-clicking the header reveals it, matching the + // Help menu's hidden admin options rather than shipping it on the default surface. + const [channelSwitcherRevealed, setChannelSwitcherRevealed] = useState(false) useEffect(() => { let cancelled = false @@ -62,17 +67,25 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { return (
- +
{ + if (event.altKey) { + setChannelSwitcherRevealed((revealed) => !revealed) + } + }} + > + +
+ {channelSwitcherRevealed ? : null}
) diff --git a/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx new file mode 100644 index 00000000000..75da65cd885 --- /dev/null +++ b/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx @@ -0,0 +1,86 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { LinearAgentSkillGuide } from './LinearAgentSkillGuide' + +const baseStatus = { + connected: true, + connectionChecking: false, + skillInstalled: false, + skillChecking: false, + visibleInTasks: true +} + +describe('LinearAgentSkillGuide', () => { + it('renders the setup checklist with an inlined skill panel', () => { + const markup = renderToStaticMarkup( + Skill install panel
} + /> + ) + + expect(markup).toContain('Setup checklist') + expect(markup).toContain('2 of 3 ready') + expect(markup).toContain('Task Sources') + expect(markup).toContain('Skill install panel') + expect(markup).not.toContain('Ready below') + expect(markup).not.toContain('Install below') + expect(markup).not.toContain('Agent skill') + expect(markup).not.toContain('Open Task Sources setup') + }) + + it('marks the checklist complete when every step is done', () => { + const markup = renderToStaticMarkup( + Skill panel
} + /> + ) + + expect(markup).toContain('All set') + }) + + it('keeps durable progress while a skill recheck is in flight', () => { + const markup = renderToStaticMarkup( + Skill panel
} + /> + ) + + expect(markup).toContain('Checking…') + expect(markup).not.toContain('2 of 3 ready') + expect(markup).not.toContain('All set') + }) + + it('keeps durable progress while a connection check is in flight', () => { + const markup = renderToStaticMarkup( + Skill panel} + /> + ) + + expect(markup).toContain('Checking…') + expect(markup).not.toContain('2 of 3 ready') + }) +}) diff --git a/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx b/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx new file mode 100644 index 00000000000..0bbcc77ec5f --- /dev/null +++ b/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx @@ -0,0 +1,191 @@ +import type { ReactNode } from 'react' +import { Check, Circle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { translate } from '@/i18n/i18n' + +export type LinearSetupStepStatus = { + connected: boolean + connectionChecking: boolean + skillInstalled: boolean + skillChecking: boolean + visibleInTasks: boolean +} + +type LinearAgentSkillGuideProps = { + status: LinearSetupStepStatus + onOpenTaskSources: () => void + onManageLinearAccess: () => void + // Why: skill install/update lives once under step 2 so the page does not + // repeat an "Agent skill" section after the checklist. + skillPanel: ReactNode +} + +function SetupStatusIcon({ + done, + checking +}: { + done: boolean + checking: boolean +}): React.JSX.Element { + // Keep a fixed size-5 slot so checking/done/pending never shift the column. + if (checking) { + return ( + + + + ) + } + if (done) { + return ( + + + + ) + } + return ( + + + + ) +} + +// Connect, skill, and Tasks visibility in one checklist — skill UI is inlined. +export function LinearAgentSkillGuide({ + status, + onOpenTaskSources, + onManageLinearAccess, + skillPanel +}: LinearAgentSkillGuideProps): React.JSX.Element { + // Count durable outcomes even while a recheck runs so the pill does not flash + // from "All set" down to "2 of 3 ready" during skill/connection scans. + const checking = status.connectionChecking || status.skillChecking + const completed = [status.connected, status.skillInstalled, status.visibleInTasks].filter( + Boolean + ).length + const total = 3 + const allReady = completed === total && !checking + + return ( +
+
+
+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupTitle', + 'Setup checklist' + )} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupBody', + 'All three are required for the full Tasks + agent loop. First-time path is also under Task Sources.' + )} +

+
+ + {checking + ? translate('auto.components.settings.LinearAgentSkillGuide.setupChecking', 'Checking…') + : allReady + ? translate('auto.components.settings.LinearAgentSkillGuide.setupReady', 'All set') + : translate( + 'auto.components.settings.LinearAgentSkillGuide.setupProgress', + '{{done}} of {{total}} ready', + { done: completed, total } + )} + +
+ +
+
+
+ +
+
+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupConnectTitle', + '1. Connect Linear' + )} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupConnectBody', + 'Personal API key so Orca can list issues and open linked workspaces.' + )} +

+
+ +
+ +
+
+
+ +
+
+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupSkillTitle', + '2. Install the agent skill' + )} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupSkillBody', + 'Gives coding agents /orca-linear for reading, updates, triage, and attaching pull or merge requests.' + )} +

+
+
+ {skillPanel} +
+ +
+
+ +
+
+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupVisibleTitle', + '3. Show Linear in Tasks' + )} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupVisibleBody', + 'Keeps Linear in the Tasks source picker and sidebar shortcuts.' + )} +

+
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx new file mode 100644 index 00000000000..c00e193df4a --- /dev/null +++ b/src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx @@ -0,0 +1,16 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { LinearAgentSkillNotes } from './LinearAgentSkillNotes' + +describe('LinearAgentSkillNotes', () => { + it('renders titled note cards instead of a dense bullet list', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('Good to know') + expect(markup).toContain('Start from a Linear issue') + expect(markup).toContain('Mention /orca-linear') + expect(markup).toContain('Keys follow the runtime') + expect(markup).toContain('Hiding ≠ disconnect') + expect(markup).not.toContain(' +
+

+ {translate('auto.components.settings.LinearAgentSkillGuide.notesTitle', 'Good to know')} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.notesIntro', + 'Quick reminders once Linear is connected and the skill is installed.' + )} +

+
+ +
+ {notes.map((note) => { + const Icon = note.icon + return ( +
+
+ +
+
+

{note.title}

+

{note.body}

+
+
+ ) + })} +
+ + ) +} diff --git a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx index b7d6a9d56bc..e8e0dd442a0 100644 --- a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx +++ b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx @@ -17,7 +17,11 @@ const mocks = vi.hoisted(() => ({ panelProps: [] as Record[], runtime: 'native' as 'native' | 'wsl', skillInstalled: true, - updateSkillName: 'orca-linear' + updateSkillName: 'orca-linear', + linearConnected: true, + visibleTaskProviders: ['github', 'linear'] as string[], + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() })) vi.mock('./AgentSkillSetupPanel', () => ({ @@ -25,7 +29,8 @@ vi.mock('./AgentSkillSetupPanel', () => ({ mocks.panelProps.push(props) return (
-

{String(props.title)}

+ {!props.hideHeader &&

{String(props.title)}

} + {props.description != null &&

{String(props.description)}

} {props.installed ? 'Installed' : 'Not installed'} {String(props.command)} {String(props.installedCommand)} @@ -68,6 +73,41 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ }) })) +vi.mock('@/hooks/useLinearProviderConnected', () => ({ + useLinearProviderConnected: () => mocks.linearConnected +})) + +vi.mock('@/lib/provider-runtime-context', () => ({ + getProviderRuntimeContextKey: () => 'runtime-key' +})) + +vi.mock('@/components/linear-api-key-dialog', () => ({ + LinearApiKeyDialog: () => null +})) + +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { + openSettingsPage: () => void + openSettingsTarget: (target: unknown) => void + settings: { visibleTaskProviders: string[] } + linearStatusChecked: boolean + linearStatusContextKey: string + checkLinearConnection: () => void + settingsSearchQuery: string + }) => unknown + ) => + selector({ + openSettingsPage: mocks.openSettingsPage, + openSettingsTarget: mocks.openSettingsTarget, + settings: { visibleTaskProviders: mocks.visibleTaskProviders }, + linearStatusChecked: true, + linearStatusContextKey: 'runtime-key', + checkLinearConnection: vi.fn(), + settingsSearchQuery: '' + }) +})) + let root: Root | null = null let container: HTMLDivElement | null = null @@ -95,13 +135,71 @@ describe('LinearAgentSkillPane', () => { mocks.runtime = 'native' mocks.skillInstalled = true mocks.updateSkillName = 'orca-linear' + mocks.linearConnected = true + mocks.visibleTaskProviders = ['github', 'linear'] + mocks.openSettingsPage.mockClear() + mocks.openSettingsTarget.mockClear() }) - it('renders the Linear skill card and the usage examples', () => { + it('keeps a clickable route to the Integrations credentials pane', async () => { + const rendered = await renderPane() + const link = [...rendered.querySelectorAll('button')].find( + (button) => button.textContent === 'Integrations' + ) + + expect(link).toBeDefined() + await act(async () => { + link?.click() + }) + + expect(mocks.openSettingsPage).toHaveBeenCalled() + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'integrations', + repoId: null, + sectionId: 'integrations-linear' + }) + }) + + it('routes the connected checklist credential action to Integrations', async () => { + const rendered = await renderPane() + const manageKeys = [...rendered.querySelectorAll('button')].find( + (button) => button.textContent === 'Manage keys' + ) + + expect(manageKeys).toBeDefined() + await act(async () => { + manageKeys?.click() + }) + + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'integrations', + repoId: null, + sectionId: 'integrations-linear' + }) + }) + + it('does not pass an icon the hideHeader layout would drop', () => { + renderToStaticMarkup() + + expect(mocks.panelProps.at(-1)).not.toHaveProperty('icon') + }) + + it('renders checklist with inlined skill panel and usage examples', () => { const markup = renderToStaticMarkup() - expect(markup).toContain('Linear skill') - expect(markup).toContain('How to use it') + expect(markup).toContain('Setup checklist') + expect(markup).toContain('2. Install the agent skill') + expect(mocks.panelProps.at(-1)).toEqual( + expect.objectContaining({ hideHeader: true, description: null }) + ) + expect(markup).not.toContain('Agent skill') + expect(markup).not.toContain('Ready below') + expect(markup).toContain('Example prompts') + expect(markup).toContain('Good to know') + expect(markup).toContain('Start from a Linear issue') + expect(markup).toContain('All set') + // Skill lives inside the checklist; notes stay after examples. + expect(markup.indexOf('Example prompts')).toBeLessThan(markup.indexOf('Good to know')) const examples = getLinearUsageExamples() expect(examples).toHaveLength(5) for (const example of examples) { @@ -111,6 +209,16 @@ describe('LinearAgentSkillPane', () => { } }) + it('shows incomplete checklist when the skill is missing', () => { + mocks.skillInstalled = false + const markup = renderToStaticMarkup() + + expect(markup).toContain('2 of 3 ready') + expect(mocks.panelProps.at(-1)).toEqual( + expect.objectContaining({ hideHeader: true, description: null }) + ) + }) + it('passes the orca-linear install/update commands and freshness on a local runtime', async () => { await renderPane() diff --git a/src/renderer/src/components/settings/LinearAgentSkillPane.tsx b/src/renderer/src/components/settings/LinearAgentSkillPane.tsx index 4f86bbb2653..8461d16cccd 100644 --- a/src/renderer/src/components/settings/LinearAgentSkillPane.tsx +++ b/src/renderer/src/components/settings/LinearAgentSkillPane.tsx @@ -1,37 +1,25 @@ -import { useMemo } from 'react' +import { useState } from 'react' import { ArrowRightCircle, BookOpen, Link2, ListTodo, MessageSquarePlus } from 'lucide-react' import type { LucideIcon } from 'lucide-react' +import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearIcon } from '@/components/icons/LinearIcon' import { Button } from '@/components/ui/button' import { useAppStore } from '@/store' -import { - LINEAR_AGENT_SKILL_NAMES, - ORCA_LINEAR_SKILL_INSTALL_COMMAND, - ORCA_LINEAR_SKILL_NAME -} from '@/lib/agent-feature-install-commands' -import { - AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal -} from '@/lib/agent-skill-cli-prerequisite' -import { getLinearAgentSkillUpdateTarget } from '@/lib/linear-agent-skill-update-command' +import { ORCA_LINEAR_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { getLinearUsageExamples } from '@/lib/linear-usage-examples' import type { SkillUsageExample } from '@/lib/skill-usage-example' -import { - GLOBAL_AGENT_SKILL_SOURCE_KINDS, - useInstalledAgentSkillNames -} from '@/hooks/useInstalledAgentSkills' -import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { useLinearProviderConnected } from '@/hooks/useLinearProviderConnected' +import { normalizeVisibleTaskProviders } from '../../../../shared/task-providers' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' -import { - buildSkillCommandForRuntime, - ensureWslCliAvailableForAgentSkillTerminal, - getWslCliDistroRequest -} from './CliSkillRuntimeSetup' +import { LinearAgentSkillGuide } from './LinearAgentSkillGuide' +import { LinearAgentSkillNotes } from './LinearAgentSkillNotes' import { getLinearAgentSkillPaneSearchEntries } from './linear-agent-skill-search' import { SearchableSetting } from './SearchableSetting' import { SkillUsageExamplesSection } from './SkillUsageExamplesSection' +import { LINEAR_INTEGRATION_SECTION_ID } from './task-provider-integration-section-ids' +import { useLinearAgentSkillSetup } from './use-linear-agent-skill-setup' import { translate } from '@/i18n/i18n' -export { getLinearAgentSkillPaneSearchEntries } from './linear-agent-skill-search' const LINEAR_EXAMPLE_ICONS: Record = { 'read-ticket': BookOpen, @@ -45,117 +33,109 @@ function resolveLinearExampleIcon(example: SkillUsageExample): LucideIcon { return LINEAR_EXAMPLE_ICONS[example.id] ?? LinearIcon } -// Why: this section is rendered as a Settings section only when the Linear -// provider is connected, so the orca-linear agent skill sits beside the -// connection that makes it useful. +// Checklist owns connect + skill + visibility; examples and notes sit below. export function LinearAgentSkillPane(): React.JSX.Element { - const activeSkillRuntime = useActiveProjectSkillRuntime() const openSettingsPage = useAppStore((state) => state.openSettingsPage) const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + const settings = useAppStore((state) => state.settings) + const linearStatusChecked = useAppStore((state) => state.linearStatusChecked) + const linearStatusContextKey = useAppStore((state) => state.linearStatusContextKey) + const linearConnected = useLinearProviderConnected() + const checkLinearConnection = useAppStore((state) => state.checkLinearConnection) + const [linearKeyDialogOpen, setLinearKeyDialogOpen] = useState(false) + const skillSetup = useLinearAgentSkillSetup() + + const openTaskSources = (): void => { + openSettingsPage() + openSettingsTarget({ pane: 'tasks', repoId: null }) + } const openIntegrationSettings = (): void => { openSettingsPage() - openSettingsTarget({ pane: 'integrations', repoId: null }) + openSettingsTarget({ + pane: 'integrations', + repoId: null, + sectionId: LINEAR_INTEGRATION_SECTION_ID + }) } - const { - installed: linearSkillInstalled, - loading: linearSkillLoading, - error: linearSkillError, - skills: linearSkills, - refresh: refreshLinearSkill - } = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { - discoveryTarget: activeSkillRuntime.discoveryTarget, - sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS - }) + const visibleInTasks = normalizeVisibleTaskProviders(settings?.visibleTaskProviders).includes( + 'linear' + ) + const connectionChecking = + linearStatusContextKey !== getProviderRuntimeContextKey(settings) || !linearStatusChecked - // Why: the built command also depends on the focused runtime environment, so - // memoizing it on the runtime alone can serve a stale Windows host command. - const installCommand = activeSkillRuntime.installDisabledReason - ? ORCA_LINEAR_SKILL_INSTALL_COMMAND - : buildSkillCommandForRuntime( - ORCA_LINEAR_SKILL_INSTALL_COMMAND, - activeSkillRuntime.agentRuntime - ) - const updateTarget = useMemo( - () => getLinearAgentSkillUpdateTarget(linearSkills, linearSkillInstalled), - [linearSkillInstalled, linearSkills] + const skillPanel = ( + ) - const updateCommand = activeSkillRuntime.installDisabledReason - ? updateTarget.command - : buildSkillCommandForRuntime(updateTarget.command, activeSkillRuntime.agentRuntime) return ( - } - preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} - getPrerequisiteStatus={() => - activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? window.api.cli.getWslInstallStatus( - getWslCliDistroRequest(activeSkillRuntime.agentRuntime) - ) - : window.api.cli.getInstallStatus() - } - onBeforeOpenTerminal={async () => { - await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) - : ensureOrcaCliAvailableForAgentSkillTerminal()) + setLinearKeyDialogOpen(true) } + skillPanel={skillPanel} /> + +

{translate( 'auto.components.settings.LinearAgentSkillPane.manageConnectionHint', @@ -170,10 +150,22 @@ export function LinearAgentSkillPane(): React.JSX.Element { > {translate( 'auto.components.settings.LinearAgentSkillPane.manageConnectionLink', - 'Integrations settings' + 'Integrations' )}

+ + { + void checkLinearConnection(true) + }} + />
) } diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx index 1c8f7387a68..4aaad31fb2c 100644 --- a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx @@ -13,7 +13,7 @@ import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRunti import { useMobileEmulatorAgentSetupState } from '../emulator-pane/use-mobile-emulator-agent-setup-state' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { buildSkillCommandForRuntime } from './CliSkillRuntimeSetup' -import { StepBadge } from './BrowserUseStepBadge' +import { StepBadge } from './SetupStepBadge' import { MobileEmulatorExamples } from './MobileEmulatorExamples' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx new file mode 100644 index 00000000000..29a7acdfe84 --- /dev/null +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -0,0 +1,316 @@ +import type React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AlertTriangle, Apple, Loader2, RefreshCw } from 'lucide-react' +import { toast } from 'sonner' +import { useAppStore } from '../../store' +import { Button } from '../ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SettingsSegmentedControl, SettingsSubsectionHeader } from './SettingsFormControls' +import { Badge } from '../ui/badge' +import { translate } from '@/i18n/i18n' +import { getShortcutPlatform } from '@/lib/shortcut-platform' +import { + RELEASE_CHANNELS, + getVersionChannel, + isChannelSupportedOnPlatform, + parseHourlyVersionStamp, + type ReleaseBuild, + type ReleaseChannel +} from '../../../../shared/release-channel' + +const CHANNEL_LABELS: Record = { + stable: 'Stable', + rc: 'RC', + hourly: 'Hourly' +} + +const CHANNEL_DESCRIPTIONS: Record = { + stable: 'Shipped releases. What everyone else is running.', + rc: 'Release candidates cut ahead of each stable.', + hourly: 'macOS only. Unvetted builds from main, built every hour. No tests, no notarization.' +} + +function formatBuildLabel(build: ReleaseBuild): string { + const stamp = parseHourlyVersionStamp(build.version) + if (!stamp) { + return build.version + } + // Why: an hourly's semver tail is an opaque timestamp; show it as local time so + // "which build was that" is answerable at a glance. + return `${build.version.split('-')[0]} · ${stamp.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}` +} + +export function ReleaseChannelSection(): React.JSX.Element { + const updateStatus = useAppStore((s) => s.updateStatus) + const releaseChannelOverride = useAppStore((s) => s.releaseChannelOverride) + const setReleaseChannelOverride = useAppStore((s) => s.setReleaseChannelOverride) + + const [appVersion, setAppVersion] = useState(null) + const [builds, setBuilds] = useState(null) + const [loadError, setLoadError] = useState(null) + const [loading, setLoading] = useState(false) + const [selectedTag, setSelectedTag] = useState(null) + + const platform = getShortcutPlatform() + const runningChannel = appVersion ? getVersionChannel(appVersion) : null + const requestedChannel = releaseChannelOverride ?? runningChannel ?? 'stable' + // Why: a persisted 'hourly' can arrive on Linux/Windows — settings sync, or a + // profile carried over from a Mac. Fall back rather than rendering a selected + // segment the user cannot act on and a build list that can never install. + const activeChannel = isChannelSupportedOnPlatform(requestedChannel, platform) + ? requestedChannel + : 'stable' + const busy = updateStatus.state === 'checking' || updateStatus.state === 'downloading' + + useEffect(() => { + let cancelled = false + void window.api.updater.getVersion().then((version) => { + if (!cancelled) { + setAppVersion(version) + } + }) + return () => { + cancelled = true + } + }, []) + + // Why: two loads can be in flight at once — activeChannel flips on mount when + // getVersion resolves, and rapid channel clicks stack requests. Without this + // guard a slower earlier request can land last and fill the list with builds + // from a channel the picker is no longer showing. + const latestRequestRef = useRef(0) + + const loadBuilds = useCallback(async (channel: ReleaseChannel): Promise => { + const requestId = latestRequestRef.current + 1 + latestRequestRef.current = requestId + const isStale = (): boolean => latestRequestRef.current !== requestId + setLoading(true) + setLoadError(null) + try { + const result = await window.api.updater.listBuilds(channel) + if (isStale()) { + return + } + if (result.ok) { + setBuilds(result.builds) + setSelectedTag(result.builds[0]?.tag ?? null) + } else { + setBuilds(null) + setLoadError(result.message) + } + } catch (error) { + if (isStale()) { + return + } + setBuilds(null) + setLoadError(String((error as Error)?.message ?? error)) + } finally { + // Why: only the newest request owns the spinner; a superseded one clearing + // it would show "no builds" while the current load is still running. + if (!isStale()) { + setLoading(false) + } + } + }, []) + + // Why: reload whenever the channel changes so the picker never offers tags + // from the channel the user just switched away from. + useEffect(() => { + setBuilds(null) + setSelectedTag(null) + void loadBuilds(activeChannel) + }, [activeChannel, loadBuilds]) + + const selectedBuild = useMemo( + () => builds?.find((build) => build.tag === selectedTag) ?? null, + [builds, selectedTag] + ) + + const handleSwitchTo = (build: ReleaseBuild): void => { + void window.api.updater + .check({ channel: build.channel, targetTag: build.tag }) + .catch((error) => { + toast.error( + translate( + 'auto.components.settings.ReleaseChannelSection.switchFailed', + 'Could not switch to that build.' + ), + { description: String((error as Error)?.message ?? error) } + ) + }) + } + + const isRunningBuild = selectedBuild?.version === appVersion + + return ( +
+
+ + + {translate('auto.components.settings.ReleaseChannelSection.devOnly', 'Dev only')} + +
+ +
+ + value={activeChannel} + // Why: selecting the running build's own channel clears the override + // rather than pinning it. Without this there is no way back to "follow + // this build's channel", so a dev who merely looked at the panel would + // leave background checks pinned to whatever they last clicked. + onChange={(channel) => + setReleaseChannelOverride(channel === runningChannel ? null : channel) + } + ariaLabel={translate( + 'auto.components.settings.ReleaseChannelSection.channelAriaLabel', + 'Update channel' + )} + // Why disabled rather than hidden: a Linux/Windows dev who has heard + // about the hourly channel should see that it exists and why it is + // unavailable, instead of silently not finding it. + options={RELEASE_CHANNELS.map((channel) => { + const supported = isChannelSupportedOnPlatform(channel, platform) + return { + value: channel, + label: supported ? ( + CHANNEL_LABELS[channel] + ) : ( + + {CHANNEL_LABELS[channel]} + + ), + disabled: !supported, + ariaLabel: supported + ? undefined + : translate( + 'auto.components.settings.ReleaseChannelSection.hourlyMacOnlyAria', + 'Hourly (macOS only)' + ), + tooltip: supported + ? undefined + : translate( + 'auto.components.settings.ReleaseChannelSection.hourlyMacOnly', + 'Hourly builds are produced only for macOS. Linux and Windows stay on Stable or RC.' + ) + } + })} + /> +

{CHANNEL_DESCRIPTIONS[activeChannel]}

+
+ + {activeChannel === 'hourly' ? ( +
+ +

+ {translate( + 'auto.components.settings.ReleaseChannelSection.hourlyWarning', + 'Hourly builds are macOS-only, ship straight from main with no test gate, and are signed but not notarized. Keep a stable build handy.' + )} +

+
+ ) : null} + +
+
+ + + + + +
+ + {loadError ? ( +

{loadError}

+ ) : isRunningBuild ? ( +

+ {translate( + 'auto.components.settings.ReleaseChannelSection.alreadyRunning', + 'This is the build you are running.' + )} +

+ ) : selectedBuild ? ( +

+ {translate( + 'auto.components.settings.ReleaseChannelSection.willSwitch', + '{{value0}} → {{value1}}', + { value0: appVersion ?? '…', value1: selectedBuild.version } + )} +

+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 0e93b0ad465..1cede19ac80 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1281,7 +1281,7 @@ function Settings(): React.JSX.Element { title={translate('auto.components.settings.Settings.linearTitle', 'Linear')} description={translate( 'auto.components.settings.Settings.linearDescription', - 'Give agents the skill to read and update your linked Linear tickets.' + 'How Linear works in Orca, setup checklist, agent skill, and example prompts.' )} searchEntries={getSectionSearchEntries('linear')} > @@ -1431,8 +1431,8 @@ function Settings(): React.JSX.Element { id="tasks" title={translate('auto.components.settings.Settings.11faa2f7dd', 'Task Sources')} description={translate( - 'auto.components.settings.Settings.dd72ed437a', - 'Choose which task providers appear in the Tasks page and sidebar.' + 'auto.components.settings.Settings.tasksDescription', + 'Connect providers, install the Linear skill, and choose what appears in Tasks.' )} searchEntries={getSectionSearchEntries('tasks')} > diff --git a/src/renderer/src/components/settings/BrowserUseStepBadge.tsx b/src/renderer/src/components/settings/SetupStepBadge.tsx similarity index 100% rename from src/renderer/src/components/settings/BrowserUseStepBadge.tsx rename to src/renderer/src/components/settings/SetupStepBadge.tsx diff --git a/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx b/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx new file mode 100644 index 00000000000..878bef8125c --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TaskSourceLinearSetup } from './TaskSourceLinearSetup' + +const mocks = vi.hoisted(() => ({ + skillInstalled: false, + skillLoading: false, + checkLinearConnection: vi.fn(), + panelProps: [] as { installed?: boolean }[] +})) + +vi.mock('./AgentSkillSetupPanel', () => ({ + AgentSkillSetupPanel: (props: { installed?: boolean }) => { + mocks.panelProps.push(props) + return
{props.installed ? 'Installed' : 'Not installed'}
+ } +})) + +vi.mock('./CliSkillRuntimeSetup', () => ({ + buildSkillCommandForRuntime: (command: string) => command, + ensureWslCliAvailableForAgentSkillTerminal: vi.fn(), + getWslCliDistroRequest: () => undefined +})) + +vi.mock('@/lib/linear-agent-skill-update-command', () => ({ + getLinearAgentSkillUpdateTarget: () => ({ + command: 'npx skills update orca-linear --global', + skillName: 'orca-linear' + }) +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'], + useInstalledAgentSkillNames: () => ({ + installed: mocks.skillInstalled, + loading: mocks.skillLoading, + error: null, + skills: [], + refresh: vi.fn() + }) +})) + +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ + discoveryTarget: undefined, + agentRuntime: { runtime: 'native' }, + terminalShellOverride: undefined, + installDisabledReason: null + }) +})) + +vi.mock('@/components/linear-api-key-dialog', () => ({ + LinearApiKeyDialog: () => null +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: { checkLinearConnection: () => void }) => unknown) => + selector({ checkLinearConnection: mocks.checkLinearConnection }) +})) + +function setupElement( + overrides: { + connected?: boolean + visible?: boolean + onOpenIntegrations?: () => void + } = {} +): React.JSX.Element { + return ( + + ) +} + +function renderSetup(overrides: { connected?: boolean; visible?: boolean } = {}): string { + return renderToStaticMarkup(setupElement(overrides)) +} + +describe('TaskSourceLinearSetup', () => { + beforeEach(() => { + mocks.skillInstalled = false + mocks.skillLoading = false + mocks.panelProps = [] + }) + + afterEach(() => { + cleanup() + }) + + it('blocks first-time skill install until Linear is connected', () => { + const markup = renderSetup({ connected: false }) + + expect(markup).toContain('Connect Linear first') + expect(markup).not.toContain('data-testid="skill-panel"') + }) + + it('shows the skill panel as done when the skill is already installed without a connection', () => { + mocks.skillInstalled = true + const markup = renderSetup({ connected: false }) + + expect(markup).not.toContain('Connect Linear first') + expect(markup).toContain('data-testid="skill-panel"') + expect(markup).toContain('Installed') + expect(mocks.panelProps.at(-1)).toEqual(expect.objectContaining({ installed: true })) + }) + + it('keeps the skill panel visible while a scan is in flight without a connection', () => { + mocks.skillLoading = true + const markup = renderSetup({ connected: false }) + + expect(markup).not.toContain('Connect Linear first') + expect(markup).toContain('data-testid="skill-panel"') + }) + + it('shows the install panel when connected and the skill is missing', () => { + const markup = renderSetup({ connected: true }) + + expect(markup).toContain('data-testid="skill-panel"') + expect(markup).toContain('Not installed') + expect(markup).not.toContain('Connect Linear first') + }) + + it('routes connected credential management to Integrations', () => { + const onOpenIntegrations = vi.fn() + const rendered = render(setupElement({ connected: true, onOpenIntegrations })) + + fireEvent.click(rendered.getByRole('button', { name: 'Manage keys' })) + + expect(onOpenIntegrations).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/settings/TaskSourceLinearSetup.tsx b/src/renderer/src/components/settings/TaskSourceLinearSetup.tsx new file mode 100644 index 00000000000..deb6ab2d72d --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceLinearSetup.tsx @@ -0,0 +1,179 @@ +import { useState } from 'react' +import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' +import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' +import { TaskSourceShowInTasksStep } from './TaskSourceShowInTasksStep' +import { TaskSourceStepRow } from './TaskSourceStepRow' +import { useLinearAgentSkillSetup } from './use-linear-agent-skill-setup' +import { translate } from '@/i18n/i18n' + +type TaskSourceLinearSetupProps = { + connected: boolean + checking: boolean + visible: boolean + onToggleVisible: () => void + onOpenIntegrations: () => void + canHide: boolean +} + +// Keep connection, skill install, and visibility in the same guided flow. +export function TaskSourceLinearSetup({ + connected, + checking, + visible, + onToggleVisible, + onOpenIntegrations, + canHide +}: TaskSourceLinearSetupProps): React.JSX.Element { + const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + const [dialogOpen, setDialogOpen] = useState(false) + const skillSetup = useLinearAgentSkillSetup() + + const connectState = checking ? 'in-progress' : connected ? 'done' : 'pending' + // Skill install is independent of API connection; only gate the first-time install CTA. + const skillState = skillSetup.skillChecking + ? 'in-progress' + : skillSetup.skillInstalled + ? 'done' + : 'pending' + // Keep the panel visible while scanning so we don't flash "connect first" over an installed skill. + const skillInstallBlocked = !connected && !skillSetup.skillInstalled && !skillSetup.skillChecking + + return ( + <> +
    + setDialogOpen(true)} + > + {connected + ? translate( + 'auto.components.settings.TaskSourceLinearSetup.manageAccess', + 'Manage keys' + ) + : translate( + 'auto.components.settings.TaskSourceLinearSetup.addAccess', + 'Add Linear access' + )} + + } + > + {connected ? ( +

    + {translate( + 'auto.components.settings.TaskSourceLinearSetup.connectedHint', + 'Workspaces and keys are stored for the active runtime. You can add more access any time.' + )} +

    + ) : ( + + )} +
    + + + {skillInstallBlocked ? ( +

    + {translate( + 'auto.components.settings.TaskSourceLinearSetup.skillBlocked', + 'Connect Linear first, then install the skill for agents.' + )} +

    + ) : ( + + )} +
    + + +
+ + { + void checkLinearConnection(true) + }} + /> + + ) +} diff --git a/src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx b/src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx new file mode 100644 index 00000000000..71e0da02529 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TaskSourceProviderCard } from './TaskSourceProviderCard' + +const readiness = { + connected: false, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: true +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null +}) + +describe('TaskSourceProviderCard', () => { + it('shows the visibility action in only one place when expanded', () => { + const markup = renderToStaticMarkup( + } + name="Linear" + description="Linear setup" + readiness={readiness} + visible + canHide + defaultExpanded + onToggleVisible={vi.fn()} + > + + + ) + + expect(markup.match(/aria-label="Hide Linear from Tasks"/g)).toHaveLength(1) + expect(markup).toContain('aria-label="Collapse Linear setup steps"') + expect(markup).toContain('aria-expanded="true"') + }) + + it('keeps visibility available while setup steps are collapsed', () => { + const markup = renderToStaticMarkup( + } + name="Linear" + description="Linear setup" + readiness={readiness} + visible + canHide + defaultExpanded={false} + onToggleVisible={vi.fn()} + > + Expanded content + + ) + + expect(markup).toContain('aria-label="Hide Linear from Tasks"') + expect(markup).not.toContain('aria-pressed') + expect(markup).toContain('>Hide') + expect(markup).not.toContain('>Shown') + expect(markup).toContain('aria-label="Show Linear setup steps"') + expect(markup).not.toContain('Expanded content') + }) + + it('stays expanded when the auto-expand target moves to another provider', async () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + const card = (defaultExpanded: boolean): React.JSX.Element => ( + } + name="Linear" + description="Linear setup" + readiness={readiness} + visible + canHide + defaultExpanded={defaultExpanded} + onToggleVisible={vi.fn()} + > + Install terminal + + ) + + await act(async () => { + root?.render(card(true)) + }) + expect(container.textContent).toContain('Install terminal') + + // A slower provider check makes another card the auto-expand target. + await act(async () => { + root?.render(card(false)) + }) + + expect(container.textContent).toContain('Install terminal') + }) + + it('labels the locked final provider as shown instead of offering a hide action', () => { + const markup = renderToStaticMarkup( + } + name="Linear" + description="Linear setup" + readiness={readiness} + visible + canHide={false} + defaultExpanded={false} + onToggleVisible={vi.fn()} + /> + ) + + // aria-disabled, not disabled: the explanation must stay keyboard reachable. + expect(markup).toContain('aria-disabled="true"') + expect(markup).not.toContain('disabled=""') + expect(markup).toContain( + 'aria-label="Linear is shown in Tasks. At least one provider must stay visible."' + ) + expect(markup).toContain('>Shown') + expect(markup).not.toContain('>Hide') + }) +}) diff --git a/src/renderer/src/components/settings/TaskSourceProviderCard.tsx b/src/renderer/src/components/settings/TaskSourceProviderCard.tsx new file mode 100644 index 00000000000..56e77fa5931 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceProviderCard.tsx @@ -0,0 +1,174 @@ +import { useId, useState, type ReactNode } from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { getShowInTasksActionLabel, getShowInTasksLabel } from './TaskSourceShowInTasksStep' +import { + TASK_PROVIDER_SETUP_STATUS_TONE, + getTaskProviderCompletedSteps, + getTaskProviderSetupStatus, + isTaskProviderChecking, + type TaskProviderReadiness, + type TaskProviderSetupStatus +} from './task-source-setup-state' +import { translate } from '@/i18n/i18n' + +type TaskSourceProviderCardProps = { + icon: ReactNode + name: string + description: string + readiness: TaskProviderReadiness + visible: boolean + canHide: boolean + defaultExpanded: boolean + onToggleVisible: () => void + children?: ReactNode +} + +function getSetupStatusLabel(status: TaskProviderSetupStatus): string { + switch (status) { + case 'checking': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusChecking', + 'Checking…' + ) + case 'ready': + return translate('auto.components.settings.TaskSourceProviderCard.statusReady', 'Ready') + case 'connect-required': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusConnectRequired', + 'Connect required' + ) + case 'skill-required': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusSkillRequired', + 'Skill required' + ) + case 'unavailable': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusUnavailable', + 'Status unavailable' + ) + case 'hidden': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusHidden', + 'Hidden from Tasks' + ) + case 'incomplete': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusIncomplete', + 'Needs setup' + ) + } +} + +export function TaskSourceProviderCard({ + icon, + name, + description, + readiness, + visible, + canHide, + defaultExpanded, + onToggleVisible, + children +}: TaskSourceProviderCardProps): React.JSX.Element { + const [expanded, setExpanded] = useState(defaultExpanded) + const [lastDefaultExpanded, setLastDefaultExpanded] = useState(defaultExpanded) + // Auto-expand only ever opens: collapsing is the user's call, so a readiness + // change that moves the auto-expand target cannot close a card in use. + if (lastDefaultExpanded !== defaultExpanded) { + setLastDefaultExpanded(defaultExpanded) + if (defaultExpanded) { + setExpanded(true) + } + } + const status = getTaskProviderSetupStatus(readiness) + const progress = getTaskProviderCompletedSteps(readiness) + const visibilityLocked = visible && !canHide + const setupId = useId() + + return ( +
+
+ + {icon} + + +
+
+

{name}

+ + {getSetupStatusLabel(status)} + + {isTaskProviderChecking(readiness) || + status === 'ready' || + status === 'unavailable' || + status === 'hidden' ? null : ( + // Only unfinished providers reach here, so the count is always partial. + + {`${progress.completed}/${progress.total}`} + + )} +
+

{description}

+
+ +
+ {!expanded ? ( + + ) : null} + +
+
+ + {expanded ? ( +
+ {children} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx b/src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx new file mode 100644 index 00000000000..7fdd8e05229 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx @@ -0,0 +1,41 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { TaskSourceShowInTasksStep } from './TaskSourceShowInTasksStep' + +describe('TaskSourceShowInTasksStep', () => { + it('uses an explicit hide action when another provider can remain visible', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('aria-pressed') + expect(markup).toContain('>Hide') + }) + + it('explains why the final visible provider cannot be hidden', () => { + const markup = renderToStaticMarkup( + + ) + + // aria-disabled, not disabled: the explanation must stay keyboard reachable. + expect(markup).toContain('aria-disabled="true"') + expect(markup).not.toContain('disabled=""') + expect(markup).toContain( + 'aria-label="GitLab is shown in Tasks. At least one provider must stay visible."' + ) + expect(markup).toContain('>Shown') + expect(markup).toContain('At least one provider must stay visible in Tasks.') + }) +}) diff --git a/src/renderer/src/components/settings/TaskSourceShowInTasksStep.tsx b/src/renderer/src/components/settings/TaskSourceShowInTasksStep.tsx new file mode 100644 index 00000000000..a6b11f1ad13 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceShowInTasksStep.tsx @@ -0,0 +1,99 @@ +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { TaskSourceStepRow } from './TaskSourceStepRow' +import { translate } from '@/i18n/i18n' + +type TaskSourceShowInTasksStepProps = { + index: number + providerLabel: string + visible: boolean + canHide: boolean + onToggleVisible: () => void + description?: string +} + +export function getShowInTasksLabel(visible: boolean, canHide: boolean): string { + if (!visible) { + return translate('auto.components.settings.TaskSourceShowInTasksStep.show', 'Show') + } + return canHide + ? translate('auto.components.settings.TaskSourceShowInTasksStep.hide', 'Hide') + : translate('auto.components.settings.TaskSourceShowInTasksStep.shown', 'Shown') +} + +export function getShowInTasksLastProviderHint(): string { + return translate( + 'auto.components.settings.TaskSourceShowInTasksStep.lastProviderHint', + 'At least one provider must stay visible in Tasks.' + ) +} + +export function getShowInTasksActionLabel( + visible: boolean, + canHide: boolean, + providerLabel: string +): string { + if (visible && !canHide) { + return translate( + 'auto.components.settings.TaskSourceShowInTasksStep.lastProviderAction', + '{{provider}} is shown in Tasks. At least one provider must stay visible.', + { provider: providerLabel } + ) + } + return visible + ? translate( + 'auto.components.settings.TaskSourceShowInTasksStep.hideProviderAction', + 'Hide {{provider}} from Tasks', + { provider: providerLabel } + ) + : translate( + 'auto.components.settings.TaskSourceShowInTasksStep.showProviderAction', + 'Show {{provider}} in Tasks', + { provider: providerLabel } + ) +} + +export function TaskSourceShowInTasksStep({ + index, + providerLabel, + visible, + canHide, + onToggleVisible, + description +}: TaskSourceShowInTasksStepProps): React.JSX.Element { + const locked = visible && !canHide + + return ( + + {getShowInTasksLabel(visible, canHide)} + + } + > + {locked ? ( +

{getShowInTasksLastProviderHint()}

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx b/src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx new file mode 100644 index 00000000000..c85146cb6af --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { JiraSetupSteps } from './TaskSourceSimpleSetup' + +const mocks = vi.hoisted(() => ({ + dialogOpen: [] as boolean[] +})) + +vi.mock('@/components/jira-connect-dialog', () => ({ + JiraConnectDialog: ({ open }: { open: boolean }) => { + mocks.dialogOpen.push(open) + return null + } +})) + +afterEach(() => { + cleanup() + mocks.dialogOpen = [] +}) + +describe('JiraSetupSteps', () => { + it('routes connected credential management to Integrations', () => { + const onOpenIntegrations = vi.fn() + const rendered = render( + + ) + + fireEvent.click(rendered.getByRole('button', { name: 'Manage keys' })) + + expect(onOpenIntegrations).toHaveBeenCalledOnce() + expect(mocks.dialogOpen.at(-1)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/settings/TaskSourceSimpleSetup.tsx b/src/renderer/src/components/settings/TaskSourceSimpleSetup.tsx new file mode 100644 index 00000000000..50f53cfa5f2 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceSimpleSetup.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react' +import { JiraConnectDialog } from '@/components/jira-connect-dialog' +import { Button } from '@/components/ui/button' +import { TaskSourceShowInTasksStep } from './TaskSourceShowInTasksStep' +import { TaskSourceStepRow } from './TaskSourceStepRow' +import { translate } from '@/i18n/i18n' + +type ConnectStepProps = { + visible: boolean + canHide: boolean + onToggleVisible: () => void + connected: boolean + checking: boolean +} + +function getConnectStepState(props: ConnectStepProps): 'in-progress' | 'done' | 'pending' { + if (props.checking) { + return 'in-progress' + } + return props.connected ? 'done' : 'pending' +} + +export function CodeHostSetupSteps( + props: ConnectStepProps & { + providerLabel: string + unavailable?: boolean + onOpenIntegrations: () => void + onRetryConnection: () => void + } +): React.JSX.Element { + const connectionDescription = props.unavailable + ? translate( + 'auto.components.settings.TasksPane.connectionCheckUnavailable', + "Orca couldn't check this connection. Try again, or open Integrations for setup details." + ) + : translate( + 'auto.components.settings.TasksPane.connectCodeHostDescription', + 'Install and authenticate the CLI under Integrations so Orca can load issues.' + ) + + return ( +
    + + {props.unavailable + ? translate('auto.components.settings.TasksPane.retryConnection', 'Try again') + : props.connected + ? translate('auto.components.settings.TasksPane.openIntegrations', 'Integrations') + : translate( + 'auto.components.settings.TasksPane.connectInIntegrations', + 'Set up in Integrations' + )} + + } + /> + +
+ ) +} + +export function JiraSetupSteps( + props: ConnectStepProps & { onConnected: () => void; onOpenIntegrations: () => void } +): React.JSX.Element { + const [dialogOpen, setDialogOpen] = useState(false) + + return ( + <> +
    + setDialogOpen(true)} + > + {props.connected + ? translate('auto.components.settings.TasksPane.manageJira', 'Manage keys') + : translate('auto.components.settings.TasksPane.addJira', 'Add Jira access')} + + } + /> + +
+ + + ) +} diff --git a/src/renderer/src/components/settings/TaskSourceStepRow.tsx b/src/renderer/src/components/settings/TaskSourceStepRow.tsx new file mode 100644 index 00000000000..3c9f9f6c799 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceStepRow.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' +import { StepBadge, type StepState } from './SetupStepBadge' +import { cn } from '@/lib/utils' + +type TaskSourceStepRowProps = { + index: number + state: StepState + title: string + description: string + action?: ReactNode + children?: ReactNode + className?: string +} + +export function TaskSourceStepRow({ + index, + state, + title, + description, + action, + children, + className +}: TaskSourceStepRowProps): React.JSX.Element { + return ( +
  • +
    + +
    +
    +
    +

    {title}

    +

    {description}

    +
    + {action ?
    {action}
    : null} +
    + {children} +
    +
    +
  • + ) +} diff --git a/src/renderer/src/components/settings/TasksPane.test.tsx b/src/renderer/src/components/settings/TasksPane.test.tsx new file mode 100644 index 00000000000..e87df95c0db --- /dev/null +++ b/src/renderer/src/components/settings/TasksPane.test.tsx @@ -0,0 +1,394 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings, TaskProvider } from '../../../../shared/types' +import type { TaskProviderReadiness } from './task-source-setup-state' +import { TasksPane } from './TasksPane' + +const mocks = vi.hoisted(() => ({ + readiness: {} as Record, + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn(), + refreshPreflightStatus: vi.fn(), + checkLinearConnection: vi.fn(), + checkJiraConnection: vi.fn(), + linearSetupProps: [] as { + connected: boolean + checking: boolean + onOpenIntegrations: () => void + }[], + jiraSetupProps: [] as { onOpenIntegrations: () => void }[] +})) + +vi.mock('./use-task-source-provider-readiness', () => ({ + useTaskSourceProviderReadiness: () => mocks.readiness +})) + +vi.mock('./use-integration-provider-status-refresh', () => ({ + useIntegrationProviderStatusRefresh: vi.fn() +})) + +vi.mock('./TaskSourceLinearSetup', () => ({ + TaskSourceLinearSetup: (props: { + connected: boolean + checking: boolean + onOpenIntegrations: () => void + }) => { + mocks.linearSetupProps.push(props) + return
    Linear setup steps
    + } +})) + +vi.mock('./TaskSourceSimpleSetup', () => ({ + CodeHostSetupSteps: (props: { + providerLabel: string + unavailable?: boolean + onRetryConnection: () => void + }) => ( +
    + {props.unavailable ? ( + <> + Orca couldn't check this connection + + + ) : ( + 'Code host setup' + )} +
    + ), + JiraSetupSteps: (props: { onOpenIntegrations: () => void }) => { + mocks.jiraSetupProps.push(props) + return
    Jira setup
    + } +})) + +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { + openSettingsPage: () => void + openSettingsTarget: (target: unknown) => void + refreshPreflightStatus: () => void + checkLinearConnection: () => void + checkJiraConnection: () => void + settingsSearchQuery: string + }) => unknown + ) => + selector({ + openSettingsPage: mocks.openSettingsPage, + openSettingsTarget: mocks.openSettingsTarget, + refreshPreflightStatus: mocks.refreshPreflightStatus, + checkLinearConnection: mocks.checkLinearConnection, + checkJiraConnection: mocks.checkJiraConnection, + settingsSearchQuery: '' + }) +})) + +const baseSettings = { + visibleTaskProviders: ['github', 'gitlab', 'linear'], + defaultTaskSource: 'github' +} as GlobalSettings + +const INCOMPLETE_BANNER = 'Some visible providers still need setup' +const LINEAR_SETUP_MARKER = 'data-testid="linear-setup"' + +function renderPane(): string { + return renderToStaticMarkup() +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderInteractivePane(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + return container +} + +async function rerenderInteractivePane(): Promise { + await act(async () => { + root?.render() + }) +} + +describe('TasksPane', () => { + beforeEach(() => { + mocks.linearSetupProps = [] + mocks.jiraSetupProps = [] + mocks.openSettingsPage.mockClear() + mocks.openSettingsTarget.mockClear() + mocks.readiness = { + github: { connected: true, checking: false, visible: true }, + gitlab: { connected: true, checking: false, visible: true }, + // Started-then-stalled: a Linear key is stored but agents have no skill. + linear: { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: true + }, + jira: { connected: false, checking: false, visible: false } + } + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + }) + + it('frames Task Sources as a guided setup hub, not visibility-only toggles', () => { + const markup = renderPane() + + expect(markup).toContain('Task management setup') + expect(markup).toContain('Linear also needs the agent skill') + expect(markup).toContain(INCOMPLETE_BANNER) + expect(markup).toContain('Linear setup steps') + expect(markup).toContain('Hide providers you do not use') + expect(markup).toContain('API access, the agent skill, and Show in Tasks') + }) + + it('does not warn on a fresh install where nothing is connected yet', () => { + // Settings ship with every provider visible, so untouched providers are the + // default state; the cards still say "Connect required" on their own. + mocks.readiness.github = { connected: false, checking: false, visible: true } + mocks.readiness.gitlab = { connected: false, checking: false, visible: true } + mocks.readiness.linear = { + connected: false, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: true + } + mocks.readiness.jira = { connected: false, checking: false, visible: true } + + const markup = renderPane() + + expect(markup).not.toContain(INCOMPLETE_BANNER) + expect(markup).toContain('Connect required') + }) + + it('hides the incomplete banner when every visible provider is ready', () => { + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false, + visible: true + } + + expect(renderPane()).not.toContain(INCOMPLETE_BANNER) + }) + + it('does not warn or expand while connection checks are still in flight', () => { + mocks.readiness.github = { connected: false, checking: true, visible: true } + mocks.readiness.gitlab = { connected: false, checking: true, visible: true } + mocks.readiness.linear = { + connected: false, + checking: true, + skillInstalled: false, + skillChecking: true, + visible: true + } + + const markup = renderPane() + + expect(markup).not.toContain(INCOMPLETE_BANNER) + expect(markup).not.toContain(LINEAR_SETUP_MARKER) + }) + + it('passes context-safe readiness into the expanded Linear setup', () => { + renderPane() + + expect(mocks.linearSetupProps).toEqual([ + expect.objectContaining({ connected: true, checking: false }) + ]) + }) + + it('deep-links connected Linear credential management to its integration card', async () => { + await renderInteractivePane() + + mocks.linearSetupProps.at(-1)?.onOpenIntegrations() + + expect(mocks.openSettingsPage).toHaveBeenCalledOnce() + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'integrations', + repoId: null, + sectionId: 'integrations-linear' + }) + }) + + it('deep-links connected Jira credential management to its integration card', async () => { + mocks.readiness.jira = { connected: true, checking: false, visible: true } + await renderInteractivePane() + const expandJira = Array.from(container?.querySelectorAll('button') ?? []).find( + (button) => button.getAttribute('aria-label') === 'Show Jira setup steps' + ) + + await act(async () => { + expandJira?.click() + }) + mocks.jiraSetupProps.at(-1)?.onOpenIntegrations() + + expect(mocks.openSettingsPage).toHaveBeenCalledOnce() + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'integrations', + repoId: null, + sectionId: 'integrations-jira' + }) + }) + + it('auto-expands only the first incomplete provider', () => { + mocks.readiness.gitlab = { connected: false, checking: false, visible: true } + + const markup = renderPane() + + // GitLab is first in provider order, so Linear stays collapsed. + expect(markup).toContain('Code host setup') + expect(markup).not.toContain(LINEAR_SETUP_MARKER) + }) + + it('leaves hidden providers out of the incomplete warning', () => { + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: false + } + + expect(renderPane()).not.toContain(INCOMPLETE_BANNER) + }) + + it('keeps Linear setup mounted while a skill recheck is in flight after auto-expand', async () => { + // Same component instance keeps the sticky auto-expand ref across rechecks. + await renderInteractivePane() + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + expect(container?.textContent).toContain(INCOMPLETE_BANNER) + + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: true, + visible: true + } + await rerenderInteractivePane() + + // Sticky expand keeps the (possibly open) install terminal mounted mid-scan. + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + }) + + it('keeps the auto-expanded card open after hiding it instead of popping another open', async () => { + await renderInteractivePane() + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + + // Hiding the stalled provider is the banner's own advice; GitHub must not + // silently take over the expansion on a later unrelated render. + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: false + } + mocks.readiness.github = { connected: false, checking: false, visible: true } + await rerenderInteractivePane() + await rerenderInteractivePane() + + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + expect(container?.textContent).not.toContain('Code host setup') + }) + + it('keeps Linear expanded when the slower code-host preflight lands after it', async () => { + // Cold open: nothing has resolved, so no card auto-expands yet. + mocks.readiness.github = { connected: false, checking: true, visible: true } + mocks.readiness.gitlab = { connected: false, checking: true, visible: true } + mocks.readiness.linear = { + connected: false, + checking: true, + skillInstalled: false, + skillChecking: true, + visible: true + } + await renderInteractivePane() + expect(container?.querySelector('[data-testid="linear-setup"]')).toBeNull() + + // Linear status + skill scan land first, so Linear auto-expands. + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: true + } + await rerenderInteractivePane() + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + + // gh/glab preflight lands last with gh unauthenticated: GitHub is now the + // first incomplete provider, but it must not steal Linear's expansion. + mocks.readiness.github = { connected: false, checking: false, visible: true } + mocks.readiness.gitlab = { connected: false, checking: false, visible: true } + await rerenderInteractivePane() + + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + }) + + it('does not warn about an unconnected code host once Linear is finished', () => { + // A code host is single-step, so "not connected" is never a stalled setup. + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false, + visible: true + } + mocks.readiness.github = { connected: false, checking: false, visible: true } + + expect(renderPane()).not.toContain(INCOMPLETE_BANNER) + }) + + it('shows a retry action instead of setup instructions when preflight is unavailable', async () => { + mocks.readiness.github = { + connected: false, + checking: false, + unavailable: true, + visible: true + } + mocks.readiness.gitlab = { connected: true, checking: false, visible: true } + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false, + visible: true + } + await renderInteractivePane() + + expect(container?.textContent).toContain('Status unavailable') + expect(container?.textContent).toContain("Orca couldn't check this connection") + const retry = Array.from(container?.querySelectorAll('button') ?? []).find( + (button) => button.textContent === 'Try again' + ) + expect(retry).toBeDefined() + + await act(async () => { + retry?.click() + }) + expect(mocks.refreshPreflightStatus).toHaveBeenCalledWith({ force: true }) + }) +}) diff --git a/src/renderer/src/components/settings/TasksPane.tsx b/src/renderer/src/components/settings/TasksPane.tsx index c95383a013b..c5b547b3850 100644 --- a/src/renderer/src/components/settings/TasksPane.tsx +++ b/src/renderer/src/components/settings/TasksPane.tsx @@ -1,16 +1,31 @@ -import { Check, Github, Gitlab } from 'lucide-react' +import { useState } from 'react' +import { Github, Gitlab } from 'lucide-react' import type { GlobalSettings, TaskProvider } from '../../../../shared/types' import { TASK_PROVIDERS, normalizeVisibleTaskProviders, resolveVisibleTaskProvider } from '../../../../shared/task-providers' -import { cn } from '@/lib/utils' import { JiraIcon } from '@/components/icons/JiraIcon' import { LinearIcon } from '@/components/icons/LinearIcon' -import { Label } from '../ui/label' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader } from './SettingsFormControls' +import { CodeHostSetupSteps, JiraSetupSteps } from './TaskSourceSimpleSetup' +import { TaskSourceLinearSetup } from './TaskSourceLinearSetup' +import { TaskSourceProviderCard } from './TaskSourceProviderCard' +import { + getStalledVisibleTaskProviders, + resolveStickyAutoExpandedTaskProvider +} from './task-source-setup-state' +import { + JIRA_INTEGRATION_SECTION_ID, + LINEAR_INTEGRATION_SECTION_ID +} from './task-provider-integration-section-ids' +import { getTasksPaneSearchKeywords } from './tasks-search' +import { useIntegrationProviderStatusRefresh } from './use-integration-provider-status-refresh' +import { useTaskSourceProviderReadiness } from './use-task-source-provider-readiness' import { translate } from '@/i18n/i18n' type TasksPaneProps = { @@ -18,68 +33,88 @@ type TasksPaneProps = { updateSettings: (updates: Partial) => void } -const TASK_PROVIDER_OPTIONS: readonly { - id: TaskProvider - label: string - description: string - Icon: (props: { className?: string }) => React.JSX.Element -}[] = [ +const PROVIDER_META: Record< + TaskProvider, { - id: 'github', + label: string + description: string + Icon: (props: { className?: string }) => React.JSX.Element + } +> = { + github: { get label() { return translate('auto.components.settings.TasksPane.e14063e727', 'GitHub') }, get description() { return translate( - 'auto.components.settings.TasksPane.1db47236cd', - 'Show GitHub in the Tasks source picker and sidebar shortcuts.' + 'auto.components.settings.TasksPane.githubDescription', + 'Browse GitHub issues and start workspaces from them.' ) }, Icon: ({ className }) => }, - { - id: 'gitlab', + gitlab: { get label() { return translate('auto.components.settings.TasksPane.7c5d7fdc20', 'GitLab') }, get description() { return translate( - 'auto.components.settings.TasksPane.dd67a1b6e1', - 'Show GitLab in the Tasks source picker and sidebar shortcuts.' + 'auto.components.settings.TasksPane.gitlabDescription', + 'Browse GitLab issues and start workspaces from them.' ) }, Icon: ({ className }) => }, - { - id: 'linear', + linear: { get label() { return translate('auto.components.settings.TasksPane.09ae2d7c51', 'Linear') }, get description() { return translate( - 'auto.components.settings.TasksPane.e4170c9615', - 'Show Linear in the Tasks source picker and sidebar shortcuts.' + 'auto.components.settings.TasksPane.linearDescription', + 'Connect Linear, install the agent skill, and show it in Tasks.' ) }, Icon: ({ className }) => }, - { - id: 'jira', + jira: { get label() { return translate('auto.components.settings.TasksPane.6b23a34f6d', 'Jira') }, get description() { return translate( - 'auto.components.settings.TasksPane.8e1305fcc6', - 'Show Jira in the Tasks source picker and sidebar shortcuts.' + 'auto.components.settings.TasksPane.jiraDescription', + 'Connect Jira Cloud or self-hosted Jira and show it in Tasks.' ) }, Icon: ({ className }) => } -] +} export function TasksPane({ settings, updateSettings }: TasksPaneProps): React.JSX.Element { const visibleProviders = normalizeVisibleTaskProviders(settings.visibleTaskProviders) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const checkJiraConnection = useAppStore((s) => s.checkJiraConnection) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const readinessByProvider = useTaskSourceProviderReadiness(visibleProviders) + useIntegrationProviderStatusRefresh() + + // Warn only about started-then-stalled setup; untouched providers are the default. + const stalledVisible = getStalledVisibleTaskProviders(TASK_PROVIDERS, readinessByProvider) + // Sticky across rechecks so expanded Linear install terminals are not unmounted. + // Claim during render (not an effect): a layout effect elsewhere can force a + // sync re-render before passive effects flush and collapse the open card. + // useState (not a ref write) keeps render pure for React Doctor. + const [previousAutoExpanded, setPreviousAutoExpanded] = useState(null) + const autoExpandedProvider = resolveStickyAutoExpandedTaskProvider({ + providers: TASK_PROVIDERS, + readinessByProvider, + previousAutoExpanded + }) + if (autoExpandedProvider !== null && previousAutoExpanded === null) { + setPreviousAutoExpanded(autoExpandedProvider) + } const toggleProvider = (provider: TaskProvider): void => { const isVisible = visibleProviders.includes(provider) @@ -97,87 +132,137 @@ export function TasksPane({ settings, updateSettings }: TasksPaneProps): React.J }) } + const openIntegrations = (sectionId?: string): void => { + openSettingsPage() + openSettingsTarget({ + pane: 'integrations', + repoId: null, + ...(sectionId ? { sectionId } : {}) + }) + } + return (
    + {stalledVisible.length > 0 ? ( +
    +

    + {translate( + 'auto.components.settings.TasksPane.incompleteBannerTitle', + 'Some visible providers still need setup' + )} +

    + {/* Linear is the only multi-step provider today; the generic body + covers any future provider that can stall the same way. */} +

    + {stalledVisible.includes('linear') + ? translate( + 'auto.components.settings.TasksPane.incompleteBannerBodyWithLinear', + 'Hide providers you do not use, or expand a card and finish its steps. For Linear: API access, the agent skill, and Show in Tasks.' + ) + : translate( + 'auto.components.settings.TasksPane.incompleteBannerBody', + 'Hide providers you do not use, or expand a card and finish its steps.' + )} +

    +
    + ) : null} + - {TASK_PROVIDER_OPTIONS.map((option) => { - const enabled = visibleProviders.includes(option.id) - const isLastEnabled = enabled && visibleProviders.length === 1 - const Icon = option.Icon + {TASK_PROVIDERS.map((provider) => { + const meta = PROVIDER_META[provider] + const readiness = readinessByProvider[provider] + const Icon = meta.Icon + const visible = readiness.visible + const canHide = visibleProviders.length > 1 return ( - + {provider === 'linear' ? ( + toggleProvider('linear')} + onOpenIntegrations={() => openIntegrations(LINEAR_INTEGRATION_SECTION_ID)} + /> + ) : provider === 'jira' ? ( + toggleProvider('jira')} + onConnected={() => void checkJiraConnection()} + onOpenIntegrations={() => openIntegrations(JIRA_INTEGRATION_SECTION_ID)} + /> + ) : ( + toggleProvider(provider)} + onOpenIntegrations={() => openIntegrations()} + onRetryConnection={() => void refreshPreflightStatus({ force: true })} + /> + )} + ) })} + +

    + {translate( + 'auto.components.settings.TasksPane.integrationsHint', + 'Credentials for all providers also live under' + )}{' '} + + {translate( + 'auto.components.settings.TasksPane.skillHint', + '. After Linear is connected, usage examples stay under Settings → Linear.' + )} +

    ) diff --git a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts index 78f6df37490..9f041ea076f 100644 --- a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts +++ b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts @@ -24,10 +24,24 @@ const updateCapableCallers = new Map([ ['COMPUTER_USE_SKILL_UPDATE_COMMAND', 'installedCommand={updateCommand}'] ], [ - // Why: the Linear settings section shares the update-target resolver so - // legacy-only installs keep the command and freshness identity aligned. + // Shared hook owns update-target resolution for Linear settings + Task Sources. + 'src/renderer/src/components/settings/use-linear-agent-skill-setup.ts', + [ + 'getLinearAgentSkillUpdateTarget', + 'updateTarget.command', + // The builder also reads the focused runtime environment, so memoizing + // either command on the runtime alone serves a stale Windows host command. + 'const installCommand = activeSkillRuntime.installDisabledReason', + 'const updateCommand = activeSkillRuntime.installDisabledReason' + ] + ], + [ 'src/renderer/src/components/settings/LinearAgentSkillPane.tsx', - ['getLinearAgentSkillUpdateTarget', 'installedCommand={updateCommand}'] + ['installedCommand={skillSetup.updateCommand}'] + ], + [ + 'src/renderer/src/components/settings/TaskSourceLinearSetup.tsx', + ['installedCommand={skillSetup.updateCommand}'] ], [ 'src/renderer/src/components/settings/EphemeralVmsPane.tsx', @@ -94,10 +108,12 @@ const installOnlyCallers = new Map([ const directPanelCallers = new Set([ // BrowserUsePane and LinearAgentSkillSetupPrompt delegate through child setup // components that forward installedCommand and are validated separately above. + // use-linear-agent-skill-setup owns the update-target resolver but is not a panel host. ...[...updateCapableCallers.keys()].filter( (relativePath) => relativePath !== 'src/renderer/src/components/settings/BrowserUsePane.tsx' && - relativePath !== 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx' + relativePath !== 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx' && + relativePath !== 'src/renderer/src/components/settings/use-linear-agent-skill-setup.ts' ), ...installOnlyCallers.keys() ]) diff --git a/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts b/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts new file mode 100644 index 00000000000..9596d47d911 --- /dev/null +++ b/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts @@ -0,0 +1,43 @@ +import type { ComponentProps, ReactNode } from 'react' +import type { Button } from '../ui/button' + +type AgentSkillSetupPanelVariant = 'card' | 'inline' +type SkillPrerequisiteStatus = Awaited> + +export type AgentSkillSetupPanelProps = { + title: string + description: ReactNode + command: string + installedCommand?: string + terminalTitle: string + terminalAriaLabel: string + terminalWorktreeId: string + installed: boolean + loading: boolean + error: string | null + installDisabled?: boolean + terminalHeightPx?: number + terminalShellOverride?: string + leading?: ReactNode + icon?: ReactNode + variant?: AgentSkillSetupPanelVariant + className?: string + // Enclosing modals can own the title and status. + hideHeader?: boolean + preInstallNotice?: ReactNode + getPrerequisiteStatus?: () => Promise + isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean + onBeforeOpenTerminal?: () => void | Promise + showInstallWhenInstalled?: boolean + showRecheckWhenInstalled?: boolean + installLabel?: string + installedInstallLabel?: string + // Modal footers can promote Install to the primary action. + installVariant?: ComponentProps['variant'] + actionHint?: ReactNode + openingHint?: ReactNode + footer?: ReactNode + onRecheck: () => void | Promise + // Freshness inventory is local-host-only. + freshnessSkillName?: string +} diff --git a/src/renderer/src/components/settings/integration-card-shell.tsx b/src/renderer/src/components/settings/integration-card-shell.tsx index 54c288d4258..0e5f80d6111 100644 --- a/src/renderer/src/components/settings/integration-card-shell.tsx +++ b/src/renderer/src/components/settings/integration-card-shell.tsx @@ -18,6 +18,7 @@ export function IntegrationCardShell(props: { statusTone: IntegrationCardStatusTone checking?: boolean className?: string + settingsSectionId?: string actions?: React.ReactNode children?: React.ReactNode }): React.JSX.Element { @@ -36,7 +37,7 @@ export function IntegrationCardShell(props: { ) return ( -
    +
    {props.icon}
    diff --git a/src/renderer/src/components/settings/jira-integration-card.test.tsx b/src/renderer/src/components/settings/jira-integration-card.test.tsx index 50b3990748c..0abbeb706ef 100644 --- a/src/renderer/src/components/settings/jira-integration-card.test.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.test.tsx @@ -99,6 +99,7 @@ describe('JiraIntegrationCard account scope', () => { const rendered = await renderCard() + expect(rendered.querySelector('[data-settings-section="integrations-jira"]')).not.toBeNull() expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1') expect(rendered.textContent).toContain('Acme Jira') expect(rendered.textContent).toContain('https://acme.atlassian.net · jira@example.test') diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index 8427e6f4cf7..e1889c2f073 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -13,6 +13,7 @@ import { IntegrationCardDetails, IntegrationCardShell } from './integration-card import { useIntegrationSubordinateRowClass } from './integration-card-presentation' import { getProviderAccountScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' +import { JIRA_INTEGRATION_SECTION_ID } from './task-provider-integration-section-ids' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -76,6 +77,7 @@ export function JiraIntegrationCard(): React.JSX.Element { return ( } name="Jira" description={ diff --git a/src/renderer/src/components/settings/linear-agent-skill-guide-content.ts b/src/renderer/src/components/settings/linear-agent-skill-guide-content.ts new file mode 100644 index 00000000000..63fbb4f75be --- /dev/null +++ b/src/renderer/src/components/settings/linear-agent-skill-guide-content.ts @@ -0,0 +1,62 @@ +import { GitBranch, KeyRound, EyeOff, Slash, type LucideIcon } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export type LinearGuideNote = { + id: string + icon: LucideIcon + title: string + body: string +} + +export function getLinearGuideNotes(): LinearGuideNote[] { + return [ + { + id: 'linked-worktree', + icon: GitBranch, + title: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteLinkedTitle', + 'Start from a Linear issue' + ), + body: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteLinkedBody', + 'Ticket actions work best in a worktree created from Tasks so the issue stays linked as context.' + ) + }, + { + id: 'slash-command', + icon: Slash, + title: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteSlashTitle', + 'Mention /orca-linear' + ), + body: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteSlashBody', + 'In chat, use /orca-linear (or ask in plain language) so the agent loads the skill for that turn.' + ) + }, + { + id: 'keys', + icon: KeyRound, + title: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteKeysTitle', + 'Keys follow the runtime' + ), + body: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteKeysBody', + 'API keys and workspaces are stored for the active runtime.' + ) + }, + { + id: 'visibility', + icon: EyeOff, + title: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteVisibilityTitle', + 'Hiding ≠ disconnect' + ), + body: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteVisibilityBody', + 'Hiding Linear in Task Sources only removes it from the picker. It does not remove your key or skill.' + ) + } + ] +} diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx index a3a4a1db3dd..793e9985d88 100644 --- a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx @@ -113,7 +113,9 @@ describe('LinearAgentSkillInstallCta', () => { expect(rendered.textContent).toContain('Agent skill:') expect(rendered.textContent).toContain('orca-linear') expect(rendered.textContent).toContain('Not installed') - expect(rendered.textContent).toContain('Let your agents read and edit Linear tasks.') + expect(rendered.textContent).toContain( + 'Full guided setup (connect + skill + visibility) is under Settings → Task Sources.' + ) expect(rendered.textContent).toContain( 'npx skills add https://github.com/stablyai/orca --skill orca-linear --global' ) diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx index 43e755dcbe2..cec5c4013ac 100644 --- a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx @@ -143,7 +143,7 @@ export function LinearAgentSkillInstallCta({ ) : translate( 'auto.components.settings.linear.agent.skill.install.cta.description', - 'Let your agents read and edit Linear tasks.' + 'Let agents read and edit Linear tasks. Full guided setup (connect + skill + visibility) is under Settings → Task Sources.' )}

    diff --git a/src/renderer/src/components/settings/linear-agent-skill-search.ts b/src/renderer/src/components/settings/linear-agent-skill-search.ts index 3fdf6dbe2b9..1eeda947034 100644 --- a/src/renderer/src/components/settings/linear-agent-skill-search.ts +++ b/src/renderer/src/components/settings/linear-agent-skill-search.ts @@ -7,7 +7,7 @@ export const getLinearAgentSkillPaneSearchEntries = createLocalizedCatalog(() => title: translate('auto.components.settings.linear.agent.skill.search.title', 'Linear'), description: translate( 'auto.components.settings.linear.agent.skill.search.description', - 'Give agents the skill to read and update your linked Linear tickets.' + 'Linear skill status, usage examples, and links to Task Sources setup.' ), keywords: [ ...translateSearchKeyword( diff --git a/src/renderer/src/components/settings/task-provider-integration-section-ids.ts b/src/renderer/src/components/settings/task-provider-integration-section-ids.ts new file mode 100644 index 00000000000..6cd7d9933af --- /dev/null +++ b/src/renderer/src/components/settings/task-provider-integration-section-ids.ts @@ -0,0 +1,2 @@ +export const LINEAR_INTEGRATION_SECTION_ID = 'integrations-linear' +export const JIRA_INTEGRATION_SECTION_ID = 'integrations-jira' diff --git a/src/renderer/src/components/settings/task-source-setup-state.test.ts b/src/renderer/src/components/settings/task-source-setup-state.test.ts new file mode 100644 index 00000000000..d2e4e6a5c27 --- /dev/null +++ b/src/renderer/src/components/settings/task-source-setup-state.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import type { TaskProvider } from '../../../../shared/types' +import { + getAutoExpandedTaskProvider, + getIncompleteVisibleTaskProviders, + getStalledVisibleTaskProviders, + getTaskProviderCompletedSteps, + getTaskProviderSetupStatus, + isTaskProviderReady, + resolveStickyAutoExpandedTaskProvider, + type TaskProviderReadiness +} from './task-source-setup-state' + +const ORDER: readonly TaskProvider[] = ['github', 'gitlab', 'linear', 'jira'] + +function buildReadiness( + overrides: Partial>> = {} +): Record { + const base: Record = { + github: { connected: true, checking: false, visible: true }, + gitlab: { connected: true, checking: false, visible: true }, + linear: { + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false, + visible: true + }, + jira: { connected: true, checking: false, visible: true } + } + for (const provider of ORDER) { + Object.assign(base[provider], overrides[provider]) + } + return base +} + +describe('task-source-setup-state', () => { + it('counts Linear readiness as three steps', () => { + expect( + getTaskProviderCompletedSteps({ + connected: true, + checking: false, + skillInstalled: false, + visible: true + }) + ).toEqual({ completed: 2, total: 3 }) + }) + + it('counts code-host readiness as two steps', () => { + expect( + getTaskProviderCompletedSteps({ connected: true, checking: false, visible: true }) + ).toEqual({ completed: 2, total: 2 }) + }) + + it('marks Linear ready only when connected, skill installed, and visible', () => { + expect( + isTaskProviderReady({ + connected: true, + checking: false, + skillInstalled: true, + visible: true + }) + ).toBe(true) + expect( + isTaskProviderReady({ + connected: true, + checking: false, + skillInstalled: false, + visible: true + }) + ).toBe(false) + }) + + it('never reports ready while a check is in flight', () => { + expect( + isTaskProviderReady({ + connected: true, + checking: false, + skillInstalled: true, + skillChecking: true, + visible: true + }) + ).toBe(false) + expect(isTaskProviderReady({ connected: true, checking: true, visible: true })).toBe(false) + }) + + it('reports the first unmet step as the status', () => { + expect(getTaskProviderSetupStatus({ connected: false, checking: true, visible: true })).toBe( + 'checking' + ) + expect(getTaskProviderSetupStatus({ connected: false, checking: false, visible: true })).toBe( + 'connect-required' + ) + expect( + getTaskProviderSetupStatus({ + connected: false, + checking: false, + unavailable: true, + visible: true + }) + ).toBe('unavailable') + expect( + getTaskProviderSetupStatus({ + connected: true, + checking: false, + skillInstalled: false, + visible: true + }) + ).toBe('skill-required') + expect(getTaskProviderSetupStatus({ connected: true, checking: false, visible: false })).toBe( + 'hidden' + ) + expect(getTaskProviderSetupStatus({ connected: true, checking: false, visible: true })).toBe( + 'ready' + ) + }) + + it('never reports an unavailable provider as ready', () => { + expect( + isTaskProviderReady({ + connected: true, + checking: false, + unavailable: true, + visible: true + }) + ).toBe(false) + }) + + it('treats hidden providers as deliberately disabled regardless of connection state', () => { + expect(getTaskProviderSetupStatus({ connected: false, checking: false, visible: false })).toBe( + 'hidden' + ) + expect(getTaskProviderSetupStatus({ connected: false, checking: true, visible: false })).toBe( + 'hidden' + ) + }) + + it('excludes hidden and still-checking providers from the incomplete list', () => { + const readiness = buildReadiness({ + github: { connected: false, visible: false }, + gitlab: { connected: false, checking: true }, + linear: { skillInstalled: false } + }) + + expect(getIncompleteVisibleTaskProviders(ORDER, readiness)).toEqual(['linear']) + }) + + it('auto-expands only the first incomplete visible provider', () => { + const readiness = buildReadiness({ + gitlab: { connected: false }, + linear: { skillInstalled: false } + }) + + expect(getIncompleteVisibleTaskProviders(ORDER, readiness)).toEqual(['gitlab', 'linear']) + expect(getAutoExpandedTaskProvider(ORDER, readiness)).toBe('gitlab') + }) + + it('auto-expands nothing once every visible provider is ready', () => { + expect(getAutoExpandedTaskProvider(ORDER, buildReadiness())).toBeNull() + }) + + it('does not warn about providers nobody has connected yet', () => { + // Settings ship with every provider visible, so an untouched provider is the + // default state rather than something to flag on a fresh install. + const untouched = buildReadiness({ + github: { connected: false }, + gitlab: { connected: false }, + linear: { connected: false, skillInstalled: false }, + jira: { connected: false } + }) + + expect(getIncompleteVisibleTaskProviders(ORDER, untouched)).toEqual([ + 'github', + 'gitlab', + 'linear', + 'jira' + ]) + expect(getStalledVisibleTaskProviders(ORDER, untouched)).toEqual([]) + }) + + it('warns about setup that started and stalled partway', () => { + const stalled = buildReadiness({ + github: { connected: false }, + linear: { skillInstalled: false } + }) + + // GitHub was never connected; Linear has a key but no skill. + expect(getStalledVisibleTaskProviders(ORDER, stalled)).toEqual(['linear']) + }) + + it('counts an installed skill as started even without an API key', () => { + const stalled = buildReadiness({ + linear: { connected: false, skillInstalled: true } + }) + + expect(getStalledVisibleTaskProviders(ORDER, stalled)).toEqual(['linear']) + }) + + it('keeps still-checking and hidden providers out of the warning', () => { + const readiness = buildReadiness({ + gitlab: { connected: true, checking: true, visible: true }, + linear: { skillInstalled: false, visible: false } + }) + + expect(getStalledVisibleTaskProviders(ORDER, readiness)).toEqual([]) + }) + + it('keeps the previous auto-expanded provider open while a recheck is in flight', () => { + const whileChecking = buildReadiness({ + linear: { skillInstalled: false, skillChecking: true } + }) + + // Fresh incomplete list excludes checking providers (banner path). + expect(getAutoExpandedTaskProvider(ORDER, whileChecking)).toBeNull() + // Sticky path keeps Linear open so install UI is not unmounted mid-scan. + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: whileChecking, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) + + it('does not switch to a later incomplete provider during the previous provider recheck', () => { + const whileChecking = buildReadiness({ + linear: { skillInstalled: false, skillChecking: true }, + jira: { connected: false } + }) + + expect(getAutoExpandedTaskProvider(ORDER, whileChecking)).toBe('jira') + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: whileChecking, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) + + it('does not hand the expansion to a provider whose check landed later', () => { + // gh/glab preflight is slower than the Linear status + skill scan, so GitHub + // only becomes eligible after Linear already auto-expanded. + const afterPreflight = buildReadiness({ + github: { connected: false }, + gitlab: { connected: false }, + linear: { skillInstalled: false } + }) + + expect(getAutoExpandedTaskProvider(ORDER, afterPreflight)).toBe('github') + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: afterPreflight, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) + + it('keeps the auto-expand slot after the chosen provider finishes', () => { + // Releasing it would let the next render pop a still-incomplete card open. + const afterLinearCompletes = buildReadiness({ github: { connected: false } }) + + expect(getAutoExpandedTaskProvider(ORDER, afterLinearCompletes)).toBe('github') + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: afterLinearCompletes, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) + + it('keeps the auto-expand slot when the chosen provider is hidden', () => { + // Hiding an unfinished provider is the banner's own advice, so it must not + // hand the expansion to another card. + const afterHidingLinear = buildReadiness({ + github: { connected: false }, + linear: { skillInstalled: false, visible: false } + }) + + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: afterHidingLinear, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) +}) diff --git a/src/renderer/src/components/settings/task-source-setup-state.ts b/src/renderer/src/components/settings/task-source-setup-state.ts new file mode 100644 index 00000000000..3ad27d48fa9 --- /dev/null +++ b/src/renderer/src/components/settings/task-source-setup-state.ts @@ -0,0 +1,147 @@ +import type { TaskProvider } from '../../../../shared/types' +import type { IntegrationStatusTone } from '@/components/integration-status-pill' + +export type TaskProviderReadiness = { + connected: boolean + checking: boolean + unavailable?: boolean + /** Linear only — agent skill install. Other providers leave this undefined. */ + skillInstalled?: boolean + skillChecking?: boolean + visible: boolean +} + +export type TaskProviderSetupStatus = + | 'checking' + | 'ready' + | 'connect-required' + | 'skill-required' + | 'unavailable' + | 'hidden' + | 'incomplete' + +export const TASK_PROVIDER_SETUP_STATUS_TONE: Record< + TaskProviderSetupStatus, + IntegrationStatusTone +> = { + checking: 'neutral', + ready: 'connected', + // Why: hiding a provider is a deliberate choice, not something to warn about. + hidden: 'neutral', + 'connect-required': 'attention', + 'skill-required': 'attention', + unavailable: 'attention', + incomplete: 'attention' +} + +export function isTaskProviderChecking(readiness: TaskProviderReadiness): boolean { + return readiness.checking || readiness.skillChecking === true +} + +export function getTaskProviderCompletedSteps(readiness: TaskProviderReadiness): { + completed: number + total: number +} { + const skillRequired = readiness.skillInstalled !== undefined + const total = skillRequired ? 3 : 2 + let completed = 0 + if (readiness.connected) { + completed += 1 + } + if (skillRequired && readiness.skillInstalled) { + completed += 1 + } + if (readiness.visible) { + completed += 1 + } + return { completed, total } +} + +export function isTaskProviderReady(readiness: TaskProviderReadiness): boolean { + if (isTaskProviderChecking(readiness) || readiness.unavailable) { + return false + } + const { completed, total } = getTaskProviderCompletedSteps(readiness) + return completed === total +} + +export function getTaskProviderSetupStatus( + readiness: TaskProviderReadiness +): TaskProviderSetupStatus { + if (!readiness.visible) { + return 'hidden' + } + if (isTaskProviderChecking(readiness)) { + return 'checking' + } + if (readiness.unavailable) { + return 'unavailable' + } + if (isTaskProviderReady(readiness)) { + return 'ready' + } + if (!readiness.connected) { + return 'connect-required' + } + if (readiness.skillInstalled === false) { + return 'skill-required' + } + // Fallback if a future readiness field can leave connected+skill true but not ready. + return 'incomplete' +} + +// Exclude in-flight checks so a cold settings open does not flash a warning. +export function getIncompleteVisibleTaskProviders( + providers: readonly TaskProvider[], + readinessByProvider: Record +): TaskProvider[] { + return providers.filter((provider) => { + const readiness = readinessByProvider[provider] + if (!readiness.visible || isTaskProviderChecking(readiness)) { + return false + } + return !isTaskProviderReady(readiness) + }) +} + +// A provider nobody has touched is the shipped default, not a problem: settings +// expose all four, so treating "never connected" as a warning would fire on +// every fresh install. Setup counts as started once a credential or the skill +// landed. +export function hasStartedTaskProviderSetup(readiness: TaskProviderReadiness): boolean { + return readiness.connected || readiness.skillInstalled === true +} + +// Warn only about setup that started and stalled partway. +export function getStalledVisibleTaskProviders( + providers: readonly TaskProvider[], + readinessByProvider: Record +): TaskProvider[] { + return getIncompleteVisibleTaskProviders(providers, readinessByProvider).filter((provider) => + hasStartedTaskProviderSetup(readinessByProvider[provider]) + ) +} + +// Expand one unfinished provider because defaults expose every provider. +export function getAutoExpandedTaskProvider( + providers: readonly TaskProvider[], + readinessByProvider: Record +): TaskProvider | null { + return getIncompleteVisibleTaskProviders(providers, readinessByProvider)[0] ?? null +} + +// Auto-expand is a one-time first-open affordance. Once a card has claimed it, +// it keeps it for the life of the pane: releasing the slot when that provider +// finishes (or gets hidden) would let a later gh/glab preflight pop a different +// card open under the user, and could unmount an open install terminal. +export function resolveStickyAutoExpandedTaskProvider({ + providers, + readinessByProvider, + previousAutoExpanded +}: { + providers: readonly TaskProvider[] + readinessByProvider: Record + previousAutoExpanded: TaskProvider | null +}): TaskProvider | null { + return previousAutoExpanded ?? getAutoExpandedTaskProvider(providers, readinessByProvider) +} diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 9fce3fc84fc..1b3610f8466 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -126,6 +126,7 @@ describe('LinearIntegrationCard account scope', () => { const rendered = await renderCard() + expect(rendered.querySelector('[data-settings-section="integrations-linear"]')).not.toBeNull() expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`) expect(rendered.textContent).toContain( 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx index 6c2cc1b9575..e40c864a7ca 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx @@ -11,6 +11,7 @@ import { useIntegrationSubordinateRowClass } from './integration-card-presentati import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta' import { getProviderAccountScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' +import { LINEAR_INTEGRATION_SECTION_ID } from './task-provider-integration-section-ids' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -68,6 +69,7 @@ export function LinearIntegrationCard(): React.JSX.Element { return ( } name="Linear" description={ diff --git a/src/renderer/src/components/settings/tasks-search.ts b/src/renderer/src/components/settings/tasks-search.ts index 0f5dd9a1a20..6115874f36e 100644 --- a/src/renderer/src/components/settings/tasks-search.ts +++ b/src/renderer/src/components/settings/tasks-search.ts @@ -2,24 +2,31 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +// Share keywords between the pane and settings-search index. +export const getTasksPaneSearchKeywords = createLocalizedCatalog(() => [ + ...translateSearchKeyword('auto.components.settings.tasks.search.2ec54bee51', 'tasks'), + ...translateSearchKeyword('auto.components.settings.tasks.search.cf0e3e0c2f', 'provider'), + ...translateSearchKeyword('auto.components.settings.tasks.search.3d81c26d78', 'source'), + ...translateSearchKeyword('auto.components.settings.tasks.search.c10ac2125e', 'github'), + ...translateSearchKeyword('auto.components.settings.tasks.search.11f001cdd4', 'gitlab'), + ...translateSearchKeyword('auto.components.settings.tasks.search.412ec3c702', 'linear'), + ...translateSearchKeyword('auto.components.settings.tasks.search.5430396e11', 'jira'), + ...translateSearchKeyword('auto.components.settings.tasks.search.604d8e4089', 'atlassian'), + ...translateSearchKeyword('auto.components.settings.tasks.search.44083ae418', 'display'), + ...translateSearchKeyword('auto.components.settings.tasks.search.58cda6f9c0', 'hide'), + ...translateSearchKeyword('auto.components.settings.tasks.search.setup', 'setup'), + ...translateSearchKeyword('auto.components.settings.tasks.search.apiKey', 'api key'), + ...translateSearchKeyword('auto.components.settings.tasks.search.skill', 'skill'), + ...translateSearchKeyword('auto.components.settings.tasks.search.connect', 'connect') +]) + export const getTasksPaneSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.tasks.search.5b8e4aace5', 'Task Providers'), description: translate( - 'auto.components.settings.tasks.search.765f0c544d', - 'Choose which task providers appear in the Tasks page and sidebar shortcuts.' + 'auto.components.settings.tasks.search.providersDescription', + 'Connect task providers, install the Linear agent skill, and choose what appears in Tasks.' ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.tasks.search.2ec54bee51', 'tasks'), - ...translateSearchKeyword('auto.components.settings.tasks.search.cf0e3e0c2f', 'provider'), - ...translateSearchKeyword('auto.components.settings.tasks.search.3d81c26d78', 'source'), - ...translateSearchKeyword('auto.components.settings.tasks.search.c10ac2125e', 'github'), - ...translateSearchKeyword('auto.components.settings.tasks.search.11f001cdd4', 'gitlab'), - ...translateSearchKeyword('auto.components.settings.tasks.search.412ec3c702', 'linear'), - ...translateSearchKeyword('auto.components.settings.tasks.search.5430396e11', 'jira'), - ...translateSearchKeyword('auto.components.settings.tasks.search.604d8e4089', 'atlassian'), - ...translateSearchKeyword('auto.components.settings.tasks.search.44083ae418', 'display'), - ...translateSearchKeyword('auto.components.settings.tasks.search.58cda6f9c0', 'hide') - ] + keywords: getTasksPaneSearchKeywords() } ]) diff --git a/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts new file mode 100644 index 00000000000..21a378a1b37 --- /dev/null +++ b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from 'react' +import { + AGENT_SKILL_CLI_PREREQUISITE_NOTICE, + ensureOrcaCliAvailableForAgentSkillTerminal +} from '@/lib/agent-skill-cli-prerequisite' +import { + LINEAR_AGENT_SKILL_NAMES, + ORCA_LINEAR_SKILL_INSTALL_COMMAND +} from '@/lib/agent-feature-install-commands' +import { getLinearAgentSkillUpdateTarget } from '@/lib/linear-agent-skill-update-command' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkillNames +} from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { + buildSkillCommandForRuntime, + ensureWslCliAvailableForAgentSkillTerminal, + getWslCliDistroRequest +} from './CliSkillRuntimeSetup' + +// Shared install/update wiring for Task Sources + Linear settings. +export function useLinearAgentSkillSetup(): { + installCommand: string + updateCommand: string + freshnessSkillName: string | undefined + skillInstalled: boolean + skillLoading: boolean + // Status surfaces (step badges, checklist pills) read this so a focus-triggered + // rescan does not flip a known result back to "checking". + skillChecking: boolean + installDisabled: boolean + error: string | null + terminalShellOverride: string | undefined + preInstallNotice: string + refreshSkill: () => Promise + getPrerequisiteStatus: () => Promise>> + onBeforeOpenTerminal: () => Promise +} { + const activeSkillRuntime = useActiveProjectSkillRuntime() + const { + installed: skillInstalled, + loading: skillLoading, + settled: skillSettled, + error: skillError, + skills: linearSkills, + refresh: refreshSkill + } = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { + discoveryTarget: activeSkillRuntime.discoveryTarget, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + + // Why: the built command also depends on the focused runtime environment, so + // memoizing it on the runtime alone can serve a stale Windows host command. + const installCommand = activeSkillRuntime.installDisabledReason + ? ORCA_LINEAR_SKILL_INSTALL_COMMAND + : buildSkillCommandForRuntime( + ORCA_LINEAR_SKILL_INSTALL_COMMAND, + activeSkillRuntime.agentRuntime + ) + const updateTarget = useMemo( + () => getLinearAgentSkillUpdateTarget(linearSkills, skillInstalled), + [linearSkills, skillInstalled] + ) + const updateCommand = activeSkillRuntime.installDisabledReason + ? updateTarget.command + : buildSkillCommandForRuntime(updateTarget.command, activeSkillRuntime.agentRuntime) + + // Freshness cannot verify WSL, so report presence there. + const freshnessSkillName = + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : updateTarget.skillName + + const getPrerequisiteStatus = useCallback( + () => + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus( + getWslCliDistroRequest(activeSkillRuntime.agentRuntime) + ) + : window.api.cli.getInstallStatus(), + [activeSkillRuntime.agentRuntime] + ) + + const onBeforeOpenTerminal = useCallback(async () => { + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) + }, [activeSkillRuntime.agentRuntime]) + + const installDisabled = Boolean(activeSkillRuntime.installDisabledReason) + + return { + installCommand, + updateCommand, + freshnessSkillName, + skillInstalled, + skillLoading, + skillChecking: skillLoading && !skillSettled, + installDisabled, + error: activeSkillRuntime.installDisabledReason ?? skillError, + terminalShellOverride: activeSkillRuntime.terminalShellOverride, + preInstallNotice: AGENT_SKILL_CLI_PREREQUISITE_NOTICE, + refreshSkill, + getPrerequisiteStatus, + onBeforeOpenTerminal + } +} diff --git a/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx b/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx new file mode 100644 index 00000000000..cb2cb636692 --- /dev/null +++ b/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskProvider } from '../../../../shared/types' +import type { TaskProviderReadiness } from './task-source-setup-state' +import { useTaskSourceProviderReadiness } from './use-task-source-provider-readiness' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + skill: { + installed: false, + loading: false, + settled: true, + error: null, + skills: [], + refresh: vi.fn() + } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => selector(mocks.state) +})) + +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalPreflightContext: () => null, + localPreflightContextKey: () => 'local' +})) + +vi.mock('@/lib/provider-runtime-context', () => ({ + getProviderRuntimeContextKey: () => 'local' +})) + +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ installDisabledReason: null }) +})) + +vi.mock('@/hooks/useLinearProviderConnected', () => ({ + useLinearProviderConnected: () => Boolean(mocks.state.linearConnected) +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'], + useInstalledAgentSkillNames: () => mocks.skill +})) + +const ALL_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'linear', 'jira'] + +let root: Root | null = null +let container: HTMLDivElement | null = null +let latest: Record | null = null + +function Probe({ visibleProviders }: { visibleProviders: readonly TaskProvider[] }): null { + latest = useTaskSourceProviderReadiness(visibleProviders) + return null +} + +async function renderProbe( + visibleProviders: readonly TaskProvider[] = ALL_PROVIDERS +): Promise { + if (!container) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + } + await act(async () => { + root?.render() + }) +} + +beforeEach(() => { + mocks.state = { + settings: {}, + preflightStatus: { + gh: { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true } + }, + preflightStatusChecked: true, + preflightStatusContextKey: 'local', + preflightStatusError: null, + preflightStatusLoading: false, + jiraStatus: { connected: true }, + jiraStatusChecked: true, + jiraStatusContextKey: 'local', + linearStatusChecked: true, + linearStatusContextKey: 'local', + linearConnected: true + } + mocks.skill = { + installed: true, + loading: false, + settled: true, + error: null, + skills: [], + refresh: vi.fn() + } +}) + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + latest = null +}) + +describe('useTaskSourceProviderReadiness', () => { + it('reports every provider connected once checks land cleanly', async () => { + await renderProbe() + + expect(latest?.github).toMatchObject({ connected: true, checking: false }) + expect(latest?.gitlab).toMatchObject({ connected: true, checking: false }) + expect(latest?.jira).toMatchObject({ connected: true, checking: false }) + expect(latest?.linear).toMatchObject({ + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false + }) + }) + + it('does not read code-host connection facts out of a failed preflight snapshot', async () => { + // A failed refresh keeps the previous status object, so Integrations reports + // GitHub as disconnected; Task Sources must not disagree. + mocks.state.preflightStatusError = 'preflight failed' + + await renderProbe() + + expect(latest?.github.connected).toBe(false) + expect(latest?.gitlab.connected).toBe(false) + expect(latest?.github.unavailable).toBe(true) + expect(latest?.gitlab.unavailable).toBe(true) + }) + + it('keeps the known skill result while a focus-triggered rescan is in flight', async () => { + mocks.skill = { ...mocks.skill, loading: true, settled: true } + + await renderProbe() + + expect(latest?.linear).toMatchObject({ skillInstalled: true, skillChecking: false }) + }) + + it('reports the skill as checking until the first scan settles', async () => { + mocks.skill = { ...mocks.skill, installed: false, loading: true, settled: false } + + await renderProbe() + + expect(latest?.linear).toMatchObject({ skillInstalled: false, skillChecking: true }) + }) + + it('marks providers hidden when they are not in the visible list', async () => { + await renderProbe(['github', 'linear']) + + expect(latest?.github.visible).toBe(true) + expect(latest?.linear.visible).toBe(true) + expect(latest?.gitlab.visible).toBe(false) + expect(latest?.jira.visible).toBe(false) + }) + + it('recomputes visibility when the provider list changes', async () => { + await renderProbe(['github', 'linear']) + expect(latest?.jira.visible).toBe(false) + + await renderProbe(['github', 'linear', 'jira']) + expect(latest?.jira.visible).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/use-task-source-provider-readiness.ts b/src/renderer/src/components/settings/use-task-source-provider-readiness.ts new file mode 100644 index 00000000000..97db089a677 --- /dev/null +++ b/src/renderer/src/components/settings/use-task-source-provider-readiness.ts @@ -0,0 +1,108 @@ +import { useMemo } from 'react' +import type { TaskProvider } from '../../../../shared/types' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkillNames +} from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { useLinearProviderConnected } from '@/hooks/useLinearProviderConnected' +import { LINEAR_AGENT_SKILL_NAMES } from '@/lib/agent-feature-install-commands' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { useAppStore } from '@/store' +import type { TaskProviderReadiness } from './task-source-setup-state' + +export function useTaskSourceProviderReadiness( + visibleProviders: readonly TaskProvider[] +): Record { + const settings = useAppStore((s) => s.settings) + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) + const preflightStatusError = useAppStore((s) => s.preflightStatusError) + const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) + const jiraStatus = useAppStore((s) => s.jiraStatus) + const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked) + const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey) + const linearConnected = useLinearProviderConnected() + const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) + const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey) + const providerRuntimeContextKey = getProviderRuntimeContextKey(settings) + const activeSkillRuntime = useActiveProjectSkillRuntime() + + const { + installed: linearSkillInstalled, + loading: linearSkillLoading, + settled: linearSkillSettled + } = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { + discoveryTarget: activeSkillRuntime.discoveryTarget, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + + const preflightCurrent = preflightStatusContextKey === expectedPreflightContextKey + const reviewChecking = preflightStatusLoading || !preflightStatusChecked || !preflightCurrent + // A failed preflight leaves the previous status object in place, so mirror + // Integrations and refuse to read connection facts out of a stale snapshot. + const reviewReadyForConnection = !reviewChecking && preflightStatusError === null + const reviewUnavailable = !reviewChecking && preflightStatusError !== null + const githubConnected = + reviewReadyForConnection && + preflightStatus?.gh?.installed === true && + preflightStatus.gh.authenticated === true + const gitlabConnected = + reviewReadyForConnection && + preflightStatus?.glab?.installed === true && + preflightStatus.glab.authenticated === true + const jiraChecking = jiraStatusContextKey !== providerRuntimeContextKey || !jiraStatusChecked + const jiraConnected = !jiraChecking && jiraStatus.connected === true + const linearChecking = + linearStatusContextKey !== providerRuntimeContextKey || !linearStatusChecked + // Normalization returns a new array, so memoize by provider contents. + const visibleProvidersKey = visibleProviders.join(',') + + return useMemo(() => { + const visible = new Set(visibleProvidersKey.split(',') as TaskProvider[]) + return { + github: { + connected: githubConnected, + checking: reviewChecking, + unavailable: reviewUnavailable, + visible: visible.has('github') + }, + gitlab: { + connected: gitlabConnected, + checking: reviewChecking, + unavailable: reviewUnavailable, + visible: visible.has('gitlab') + }, + linear: { + connected: linearConnected, + checking: linearChecking, + skillInstalled: linearSkillInstalled, + skillChecking: linearSkillLoading && !linearSkillSettled, + visible: visible.has('linear') + }, + jira: { + connected: jiraConnected, + checking: jiraChecking, + visible: visible.has('jira') + } + } + }, [ + githubConnected, + gitlabConnected, + jiraChecking, + jiraConnected, + linearChecking, + linearConnected, + linearSkillInstalled, + linearSkillLoading, + linearSkillSettled, + reviewChecking, + reviewUnavailable, + visibleProvidersKey + ]) +} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 7f1576c720b..3106deda4c7 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -42,6 +42,10 @@ vi.mock('@/store', () => ({ ) })) +vi.mock('@/store/selectors', () => ({ + useAllWorktrees: () => mocks.state.allWorktrees() +})) + vi.mock('@/components/ui/dialog', () => ({ Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => open ?
    {children}
    : null, diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 598f5bedd65..b89f3fef3fa 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle } from '@/components/ui/dialog' import { useAppStore } from '@/store' +import { useAllWorktrees } from '@/store/selectors' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' @@ -36,7 +37,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const closeModal = useAppStore((s) => s.closeModal) const removeWorktree = useAppStore((s) => s.removeWorktree) const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) - const allWorktrees = useAppStore((s) => s.allWorktrees) + const allWorktrees = useAllWorktrees() const repos = useAppStore((s) => s.repos) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) const updateSettings = useAppStore((s) => s.updateSettings) @@ -62,7 +63,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { ? (modalData.onDeleted as (worktreeIds: string[]) => void) : null const worktree = useMemo( - () => (worktreeId ? (allWorktrees().find((item) => item.id === worktreeId) ?? null) : null), + () => (worktreeId ? (allWorktrees.find((item) => item.id === worktreeId) ?? null) : null), [allWorktrees, worktreeId] ) const worktrees = useMemo(() => { @@ -70,7 +71,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { return [] } const selected = new Set(worktreeIds) - return allWorktrees().filter((item) => selected.has(item.id)) + return allWorktrees.filter((item) => selected.has(item.id)) }, [allWorktrees, worktreeIds]) const repoMap = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) const isBatchDelete = worktreeIds.length > 1 @@ -90,7 +91,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const lineageDelete = useMemo( () => !isBatchDelete && worktree - ? getWorkspaceDeleteLineage(worktree, allWorktrees(), worktreeLineageById) + ? getWorkspaceDeleteLineage(worktree, allWorktrees, worktreeLineageById) : { descendants: [], deleteAllTargets: [] }, [allWorktrees, isBatchDelete, worktree, worktreeLineageById] ) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx index 601bef497d6..cd7b94dd725 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx @@ -7,6 +7,9 @@ import { translate } from '@/i18n/i18n' type WorkspaceKanbanCardProps = { worktree: Worktree + // Why: the lane virtualizes, so DOM order no longer implies list position. + // Drop-index math reads this instead of counting rendered siblings. + laneIndex: number repo: Repo | undefined isActive: boolean isSelected: boolean @@ -23,6 +26,7 @@ type WorkspaceKanbanCardProps = { function WorkspaceKanbanCard({ worktree, + laneIndex, repo, isActive, isSelected, @@ -40,6 +44,7 @@ function WorkspaceKanbanCard({
    (null) const [dragOverStatus, setDragOverStatus] = useState(null) const [pinDragOver, setPinDragOver] = useState(false) + const [renderCards, setRenderCards] = useState(false) const { canCreateWorktree, createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree() const visibleWorktreeIdSet = useVisibleWorkspaceKanbanWorktreeIds({ allWorktrees, @@ -191,6 +201,12 @@ export default function WorkspaceKanbanDrawer({ })), [worktreesByStatus, workspaceStatuses] ) + useLayoutEffect(() => { + if (!open) { + return + } + return registerWorkspaceKanbanSidebarDropGroups(boardDragGroups) + }, [boardDragGroups, open]) const laneFullWorktreeIds = useMemo( () => new Map(boardDragGroups.map((group) => [group.key, group.worktreeIds])), [boardDragGroups] @@ -692,6 +708,28 @@ export default function WorkspaceKanbanDrawer({ } ) + // Why: mounting every lane's cards in the same commit that opens the sheet + // blocks the slide-in for the whole card render. Paint the board chrome + // first, then fill the lanes in a non-blocking transition. + useEffect(() => { + if (!open) { + setRenderCards(false) + return + } + let cancelled = false + const frameId = window.requestAnimationFrame(() => { + startTransition(() => { + if (!cancelled) { + setRenderCards(true) + } + }) + }) + return () => { + cancelled = true + window.cancelAnimationFrame(frameId) + } + }, [open]) + useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef) useWorkspaceKanbanOutsideDismiss({ open, boardRef, preserveOpenForMenu, onOpenChange }) useContextualTour('workspace-board', open && !dragPreview, 'workspace_board_visible') @@ -861,6 +899,7 @@ export default function WorkspaceKanbanDrawer({ className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-sleek" > ({ + useVirtualizer: ({ count }: { count: number }) => { + const start = count === 0 ? 0 : Math.min(WINDOW_START, count - 1) + const end = count === 0 ? -1 : Math.min(WINDOW_END, count - 1) + return { + getTotalSize: () => (count === 0 ? 0 : count * 44 - 8), + measurementsCache: Array.from({ length: count }, (_, index) => ({ + index, + start: index * 44, + end: index * 44 + 36 + })), + getVirtualItems: () => + Array.from({ length: Math.max(0, end - start + 1) }, (_, offset) => { + const index = start + offset + return { index, key: `w${index}`, start: index * 44 } + }), + measureElement: () => {} + } + } +})) + +vi.mock('./WorkspaceKanbanCard', () => ({ + default: ({ worktree, laneIndex }: { worktree: Worktree; laneIndex: number }) => ( +
    + ) +})) + +const { default: WorkspaceKanbanLaneCardList } = await import('./WorkspaceKanbanLaneCardList') + +const REPO_MAP = new Map() + +function renderLane(count: number): HTMLElement { + const scrollRef = createRef() + const { container } = render( +
    + ({ id: `w${index}` }) as Worktree)} + repoMap={REPO_MAP} + activeWorktreeId={null} + scrollRef={scrollRef} + selectedWorktreeIds={new Set()} + selectedWorktrees={[]} + nativeDragEnabled={false} + onActivate={() => {}} + onSelectionGesture={() => false} + onContextMenuSelect={() => []} + /> +
    + ) + return container +} + +afterEach(cleanup) + +describe('WorkspaceKanbanLaneCardList', () => { + it('mounts only the virtual window, not the whole lane', () => { + const container = renderLane(500) + + const ids = Array.from(container.querySelectorAll('[data-workspace-board-card-id]')).map( + (card) => card.getAttribute('data-workspace-board-card-id') + ) + expect(ids).toEqual(['w20', 'w21', 'w22', 'w23', 'w24', 'w25']) + }) + + it('gives every rendered card its lane index, not its rendered position', () => { + const container = renderLane(500) + + const indexes = Array.from(container.querySelectorAll('[data-workspace-board-card-index]')).map( + (card) => Number(card.getAttribute('data-workspace-board-card-index')) + ) + expect(indexes).toEqual([20, 21, 22, 23, 24, 25]) + }) + + it('reserves the full lane height so the scrollbar spans the whole list', () => { + const container = renderLane(500) + + const spacer = container.querySelector('[data-workspace-board-card-id]') + ?.parentElement?.parentElement + expect(spacer?.style.height).toBe(`${TOTAL_SIZE}px`) + }) + + it('positions each card at its virtual offset', () => { + const container = renderLane(500) + + const first = container.querySelector( + '[data-workspace-board-card-id]' + )?.parentElement + expect(first?.style.transform).toBe(`translateY(${WINDOW_START * 44}px)`) + }) + + it('renders nothing for an empty lane and a single card at index 0', () => { + const empty = renderLane(0) + expect(empty.querySelectorAll('[data-workspace-board-card-id]')).toHaveLength(0) + + const single = renderLane(1) + const card = single.querySelector('[data-workspace-board-card-id]') + expect(card?.getAttribute('data-workspace-board-card-id')).toBe('w0') + expect(card?.getAttribute('data-workspace-board-card-index')).toBe('0') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx new file mode 100644 index 00000000000..49b9232839b --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import WorkspaceKanbanCard from './WorkspaceKanbanCard' +import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout' + +// Why: board cards are uniform one-line rows; a close estimate keeps the first +// virtual window right so the lane does not reflow once cards measure. +const WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT = 36 +// Matches the `space-y-2` rhythm the lane used before virtualization. +const WORKSPACE_BOARD_CARD_GAP = 8 +const WORKSPACE_BOARD_CARD_OVERSCAN = 6 + +function estimateWorkspaceBoardCardSize(): number { + return WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT +} + +type WorkspaceKanbanLaneCardListProps = { + items: readonly Worktree[] + repoMap: Map + activeWorktreeId: string | null + scrollRef: React.RefObject + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + nativeDragEnabled: boolean + onActivate: () => void + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void +} + +function WorkspaceKanbanLaneCardList({ + items, + repoMap, + activeWorktreeId, + scrollRef, + selectedWorktreeIds, + selectedWorktrees, + nativeDragEnabled, + onActivate, + onSelectionGesture, + onContextMenuSelect, + onAssignWorkspaceStatus +}: WorkspaceKanbanLaneCardListProps): React.JSX.Element { + const spacerRef = useRef(null) + const itemIds = useMemo(() => items.map((item) => item.id), [items]) + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: estimateWorkspaceBoardCardSize, + getItemKey: useCallback((index: number) => items[index]?.id ?? index, [items]), + overscan: WORKSPACE_BOARD_CARD_OVERSCAN, + gap: WORKSPACE_BOARD_CARD_GAP, + // Why: sync-flushing rich card renders inside the scroll listener stalls the + // wheel; async + overscan keeps the lane filled without blocking input. + useFlushSync: false + }) + + useLayoutEffect(() => { + const scrollElement = scrollRef.current + const spacerElement = spacerRef.current + if (!scrollElement || !spacerElement) { + return + } + return registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => itemIds, + getMeasurements: () => virtualizer.measurementsCache + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- virtualizer is a stable instance (useVirtualizer holds it in useState), and getMeasurements reads measurementsCache off it live. + }, [itemIds, scrollRef]) + + return ( +
    + {virtualizer.getVirtualItems().map((virtualItem) => { + const worktree = items[virtualItem.index] + if (!worktree) { + return null + } + const isSelected = selectedWorktreeIds.has(worktree.id) + return ( +
    + 0 ? selectedWorktrees : undefined + } + onActivate={onActivate} + onSelectionGesture={onSelectionGesture} + onContextMenuSelect={onContextMenuSelect} + onAssignWorkspaceStatus={onAssignWorkspaceStatus} + /> +
    + ) + })} +
    + ) +} + +export default React.memo(WorkspaceKanbanLaneCardList) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx new file mode 100644 index 00000000000..161bb9ff597 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx @@ -0,0 +1,229 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import React, { createRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo, WorkspaceStatusDefinition, Worktree } from '../../../../shared/types' + +const virtualWindow = vi.hoisted(() => ({ startIndex: 0, visibleCount: 4 })) +const animationFrames = new Map() +let nextAnimationFrameId = 1 + +vi.mock('@tanstack/react-virtual', () => { + const defaultRangeExtractor = (range: { + startIndex: number + endIndex: number + overscan: number + count: number + }): number[] => { + const start = Math.max(0, range.startIndex - range.overscan) + const end = Math.min(range.count - 1, range.endIndex + range.overscan) + return Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => start + index) + } + return { + defaultRangeExtractor, + useVirtualizer: (options: { + count: number + estimateSize: (index: number) => number + getItemKey: (index: number) => string | number + gap: number + rangeExtractor: (range: { + startIndex: number + endIndex: number + overscan: number + count: number + }) => number[] + }) => { + const endIndex = Math.min( + options.count - 1, + virtualWindow.startIndex + virtualWindow.visibleCount - 1 + ) + const indexes = + options.count === 0 + ? [] + : options.rangeExtractor({ + startIndex: virtualWindow.startIndex, + endIndex, + overscan: 1, + count: options.count + }) + const size = options.estimateSize(0) + return { + getTotalSize: () => Math.max(0, options.count * size + (options.count - 1) * options.gap), + getVirtualItems: () => + indexes.map((index) => ({ + index, + key: options.getItemKey(index), + start: index * (size + options.gap) + })), + measureElement: () => {}, + measure: () => {} + } + } + } +}) + +vi.mock('./WorkspaceKanbanStatusLane', () => ({ + default: ({ + status, + items, + renderCards + }: { + status: WorkspaceStatusDefinition + items: readonly Worktree[] + renderCards: boolean + }) => ( +
    + +
    + ) +})) + +const { default: WorkspaceKanbanLaneGrid } = await import('./WorkspaceKanbanLaneGrid') +const { extractWorkspaceKanbanLaneRange } = await import('./workspace-kanban-lane-range') + +const STATUSES = Array.from({ length: 21 }, (_, index) => ({ + id: `state-${String(index + 1).padStart(2, '0')}`, + label: `State ${index + 1}` +})) +const REPO_MAP = new Map() + +function makeGrid(): React.JSX.Element { + return ( + {}} + onDragLeave={() => {}} + onDrop={() => {}} + onActivate={() => {}} + onSelectionGesture={() => false} + onContextMenuSelect={() => []} + onCreateWorktree={() => {}} + onColumnResizeStart={() => {}} + onColumnResizeKeyDown={() => {}} + /> + ) +} + +function renderGrid(): ReturnType { + return render(makeGrid()) +} + +function mountedStatusIds(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-workspace-status]')).map( + (lane) => lane.dataset.workspaceStatus ?? '' + ) +} + +function flushNextAnimationFrame(): void { + const next = animationFrames.entries().next().value as [number, FrameRequestCallback] | undefined + expect(next).toBeDefined() + if (!next) { + return + } + animationFrames.delete(next[0]) + act(() => next[1](performance.now())) +} + +beforeEach(() => { + animationFrames.clear() + nextAnimationFrameId = 1 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + const id = nextAnimationFrameId + nextAnimationFrameId += 1 + animationFrames.set(id, callback) + return id + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + animationFrames.delete(id) + }) +}) + +afterEach(() => { + virtualWindow.startIndex = 0 + cleanup() + vi.restoreAllMocks() +}) + +describe('WorkspaceKanbanLaneGrid', () => { + it('reserves the full workflow width while mounting only the horizontal window', () => { + const { container } = renderGrid() + + expect(mountedStatusIds(container)).toEqual([ + 'state-01', + 'state-02', + 'state-03', + 'state-04', + 'state-05' + ]) + expect( + container.querySelector('[data-workspace-board-lane-grid]')?.style.width + ).toBe(`${21 * 308 + 20 * 12}px`) + }) + + it('mounts later ordered lanes and releases distant lanes after horizontal scroll', () => { + const rendered = renderGrid() + virtualWindow.startIndex = 17 + rendered.rerender(makeGrid()) + + expect(mountedStatusIds(rendered.container)).toEqual([ + 'state-17', + 'state-18', + 'state-19', + 'state-20', + 'state-21' + ]) + expect(rendered.container.querySelector('[data-workspace-status="state-01"]')).toBeNull() + }) + + it('keeps one focused lane mounted without unbounding the virtual window', () => { + const rendered = renderGrid() + fireEvent.focus(rendered.getByRole('button', { name: 'State 1' })) + + virtualWindow.startIndex = 17 + rendered.rerender(makeGrid()) + + expect(mountedStatusIds(rendered.container)).toEqual([ + 'state-01', + 'state-17', + 'state-18', + 'state-19', + 'state-20', + 'state-21' + ]) + }) + + it('adds only the focused lane to the normal overscanned range', () => { + expect( + extractWorkspaceKanbanLaneRange({ startIndex: 4, endIndex: 7, overscan: 1, count: 21 }, 18) + ).toEqual([3, 4, 5, 6, 7, 8, 18]) + }) + + it('hydrates at most one mounted lane per animation frame', () => { + const { container } = renderGrid() + const renderedLaneCount = (): number => + container.querySelectorAll('[data-render-cards="true"]').length + + expect(renderedLaneCount()).toBe(0) + flushNextAnimationFrame() + expect(renderedLaneCount()).toBe(1) + flushNextAnimationFrame() + expect(renderedLaneCount()).toBe(2) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx index 0b258d4dcf7..48d77c9b7f7 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx @@ -1,4 +1,13 @@ -import React from 'react' +import React, { + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { useVirtualizer, type Range } from '@tanstack/react-virtual' import type { Repo, WorkspaceStatus, @@ -6,9 +15,17 @@ import type { Worktree } from '../../../../shared/types' import type { WorkspaceKanbanLaneView } from './workspace-kanban-search' +import { extractWorkspaceKanbanLaneRange } from './workspace-kanban-lane-range' import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane' +// Why: a fresh [] per render would defeat the memoized lane on empty lanes. +const EMPTY_LANE_ITEMS: readonly Worktree[] = [] +const EMPTY_RENDERED_LANE_IDS: ReadonlySet = new Set() +const WORKSPACE_BOARD_LANE_GAP = 12 +const WORKSPACE_BOARD_LANE_OVERSCAN = 1 + type WorkspaceKanbanLaneGridProps = { + laneScrollerRef: React.RefObject statuses: readonly WorkspaceStatusDefinition[] laneViews: ReadonlyMap laneFullWorktreeIds: ReadonlyMap @@ -19,6 +36,7 @@ type WorkspaceKanbanLaneGridProps = { isResizingColumn: boolean dragOverStatus: WorkspaceStatus | null canCreateWorktree: boolean + renderCards: boolean selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] onDragOver: (event: React.DragEvent, statusId: string) => void @@ -37,6 +55,7 @@ type WorkspaceKanbanLaneGridProps = { } export default function WorkspaceKanbanLaneGrid({ + laneScrollerRef, statuses, laneViews, laneFullWorktreeIds, @@ -47,6 +66,7 @@ export default function WorkspaceKanbanLaneGrid({ isResizingColumn, dragOverStatus, canCreateWorktree, + renderCards, selectedWorktreeIds, selectedWorktrees, onDragOver, @@ -60,43 +80,155 @@ export default function WorkspaceKanbanLaneGrid({ onColumnResizeStart, onColumnResizeKeyDown }: WorkspaceKanbanLaneGridProps): React.JSX.Element { + const [focusedStatusId, setFocusedStatusId] = useState(null) + const [renderedLaneIds, setRenderedLaneIds] = + useState>(EMPTY_RENDERED_LANE_IDS) + const renderedLaneIdsRef = useRef(renderedLaneIds) + const renderCardsRef = useRef(renderCards) + useLayoutEffect(() => { + renderedLaneIdsRef.current = renderedLaneIds + renderCardsRef.current = renderCards + }, [renderCards, renderedLaneIds]) + const focusedIndex = useMemo( + () => + focusedStatusId === null + ? null + : statuses.findIndex((status) => status.id === focusedStatusId), + [focusedStatusId, statuses] + ) + const estimateLaneSize = useCallback(() => columnWidth, [columnWidth]) + const getLaneKey = useCallback((index: number) => statuses[index]?.id ?? index, [statuses]) + const rangeExtractor = useCallback( + (range: Range) => extractWorkspaceKanbanLaneRange(range, focusedIndex), + [focusedIndex] + ) + const laneVirtualizer = useVirtualizer({ + count: statuses.length, + getScrollElement: () => laneScrollerRef.current, + estimateSize: estimateLaneSize, + getItemKey: getLaneKey, + horizontal: true, + overscan: WORKSPACE_BOARD_LANE_OVERSCAN, + gap: WORKSPACE_BOARD_LANE_GAP, + rangeExtractor, + useFlushSync: false + }) + useLayoutEffect(() => { + laneVirtualizer.measure() + }, [columnWidth, laneVirtualizer]) + const virtualLanes = laneVirtualizer.getVirtualItems() + const virtualStatusIds = useMemo( + () => + virtualLanes.flatMap((virtualLane) => { + const status = statuses[virtualLane.index] + return status ? [status.id] : [] + }), + [statuses, virtualLanes] + ) + const mountedLaneIds = useMemo(() => new Set(virtualStatusIds), [virtualStatusIds]) + const mountedLaneIdsRef = useRef>(mountedLaneIds) + useLayoutEffect(() => { + mountedLaneIdsRef.current = mountedLaneIds + }, [mountedLaneIds]) + useEffect(() => { + if (!renderCards) { + setRenderedLaneIds(EMPTY_RENDERED_LANE_IDS) + return + } + const missingIds = virtualStatusIds.filter((id) => !renderedLaneIdsRef.current.has(id)) + setRenderedLaneIds((current) => { + const retained = new Set(Array.from(current).filter((id) => mountedLaneIds.has(id))) + return retained.size === current.size ? current : retained + }) + let nextIndex = 0 + let frameId = 0 + const renderNextLane = (): void => { + const statusId = missingIds[nextIndex] + nextIndex += 1 + if (!statusId) { + return + } + startTransition(() => { + setRenderedLaneIds((current) => { + if (!renderCardsRef.current || !mountedLaneIdsRef.current.has(statusId)) { + return current + } + return new Set(current).add(statusId) + }) + }) + if (nextIndex < missingIds.length) { + frameId = window.requestAnimationFrame(renderNextLane) + } + } + if (missingIds.length > 0) { + frameId = window.requestAnimationFrame(renderNextLane) + } + return () => window.cancelAnimationFrame(frameId) + }, [mountedLaneIds, renderCards, virtualStatusIds]) + return (
    { + const lane = (event.target as Element).closest('[data-workspace-status]') + setFocusedStatusId(lane?.dataset.workspaceStatus ?? null) + }} + onBlurCapture={(event) => { + const nextTarget = event.relatedTarget + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setFocusedStatusId(null) + } }} > - {statuses.map((status) => ( - - ))} + {virtualLanes.map((virtualLane) => { + const status = statuses[virtualLane.index] + if (!status) { + return null + } + return ( +
    + +
    + ) + })}
    ) } diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx index 35e9f8a0c92..bba46379d77 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx @@ -25,14 +25,22 @@ import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu' let root: Root | null = null let container: HTMLDivElement | null = null -function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void { +function renderMenu({ + workspaceStatuses = statuses, + onSyncTaskStatusFromWorkspaceBoardChange = vi.fn<(enabled: boolean) => void>(), + onAddStatus = vi.fn<() => void>() +}: { + workspaceStatuses?: WorkspaceStatusDefinition[] + onSyncTaskStatusFromWorkspaceBoardChange?: (enabled: boolean) => void + onAddStatus?: () => void +} = {}): void { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { root?.render( ) }) @@ -59,7 +67,7 @@ afterEach(() => { describe('WorkspaceKanbanSettingsMenu', () => { it('renders the task status sync switch and forwards changes', async () => { const onChange = vi.fn() - renderMenu(onChange) + renderMenu({ onSyncTaskStatusFromWorkspaceBoardChange: onChange }) const toggle = document.querySelector( 'button[role="switch"][aria-label="Sync board and issue status"]' @@ -74,4 +82,23 @@ describe('WorkspaceKanbanSettingsMenu', () => { expect(onChange).toHaveBeenCalledWith(true) }) + + it('keeps adding available for workflows above the former board limit', () => { + const onAddStatus = vi.fn() + renderMenu({ + workspaceStatuses: Array.from({ length: 21 }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })), + onAddStatus + }) + + const addStatus = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Add status' + ) + + expect(addStatus?.disabled).toBe(false) + addStatus?.click() + expect(onAddStatus).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx index 523e59c6974..91987985d59 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx @@ -9,9 +9,13 @@ import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true -vi.mock('./WorkspaceKanbanCard', () => ({ - default: ({ worktree }: { worktree: Worktree }) => ( -
    +vi.mock('./WorkspaceKanbanLaneCardList', () => ({ + default: ({ items }: { items: readonly Worktree[] }) => ( + <> + {items.map((item) => ( +
    + ))} + ) })) @@ -51,6 +55,7 @@ function renderLane(props: { isResizingColumn={false} isDragTarget={false} canCreateWorktree={true} + renderCards={true} selectedWorktreeIds={new Set()} selectedWorktrees={[]} onDragOver={vi.fn()} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx index 40e9f1e8360..cceda5e12ee 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useRef } from 'react' import { Plus } from 'lucide-react' import type { Repo, @@ -13,7 +13,7 @@ import { import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import WorkspaceKanbanCard from './WorkspaceKanbanCard' +import WorkspaceKanbanLaneCardList from './WorkspaceKanbanLaneCardList' import { serializeWorkspaceLaneFullIds } from './workspace-kanban-filtered-drop-index' import { getWorkspaceStatusVisualMeta } from './workspace-status' import { translate } from '@/i18n/i18n' @@ -32,6 +32,7 @@ type WorkspaceKanbanStatusLaneProps = { isDragTarget: boolean canCreateWorktree: boolean nativeDragEnabled?: boolean + renderCards: boolean selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] onDragOver: (event: React.DragEvent, statusId: string) => void @@ -49,7 +50,7 @@ type WorkspaceKanbanStatusLaneProps = { onColumnResizeKeyDown: (event: React.KeyboardEvent) => void } -export default function WorkspaceKanbanStatusLane({ +function WorkspaceKanbanStatusLane({ status, items, totalCount, @@ -62,6 +63,7 @@ export default function WorkspaceKanbanStatusLane({ isDragTarget, canCreateWorktree, nativeDragEnabled = true, + renderCards, selectedWorktreeIds, selectedWorktrees, onDragOver, @@ -75,6 +77,7 @@ export default function WorkspaceKanbanStatusLane({ onColumnResizeStart, onColumnResizeKeyDown }: WorkspaceKanbanStatusLaneProps): React.JSX.Element { + const laneScrollRef = useRef(null) const meta = getWorkspaceStatusVisualMeta(status) // Why: a lane that is empty on its own merits is still "Empty" under a query — // only a lane whose cards were filtered away has anything to say about matches. @@ -180,32 +183,26 @@ export default function WorkspaceKanbanStatusLane({
    {items.length > 0 ? ( -
    - {items.map((worktree) => { - const isSelected = selectedWorktreeIds.has(worktree.id) - return ( - 0 ? selectedWorktrees : undefined - } - onActivate={onActivate} - onSelectionGesture={onSelectionGesture} - onContextMenuSelect={onContextMenuSelect} - onAssignWorkspaceStatus={onAssignWorkspaceStatus} - /> - ) - })} -
    + renderCards ? ( + + ) : null ) : (
    {isFiltered @@ -241,3 +238,5 @@ export default function WorkspaceKanbanStatusLane({
    ) } + +export default React.memo(WorkspaceKanbanStatusLane) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 3d315cb616f..8cc18bb43f3 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -95,9 +95,11 @@ import { type PinnedWorktreeDisplayPolicy } from './worktree-list-groups' import { + buildLineageRowRekeyMap, estimateRenderRowSize, extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, + getRenderRowKey, getStickyHeaderIndexes, getVirtualRowTransform, pruneStaleVirtualRowElementCache, @@ -1210,30 +1212,9 @@ function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { return renderRows } -export function getRenderRowKey(row: RenderRow): string { - if (row.type === 'host-header') { - return `host:${row.hostId}` - } - if (row.type === 'header') { - return `hdr:${row.key}` - } - if (row.type === 'lineage-group') { - return `lineage-group:${row.key}` - } - if (row.type === 'imported-worktrees-card') { - return `imported:${row.key}` - } - if (row.type === 'new-external-worktrees-inbox') { - return `inbox:${row.key}` - } - if (row.type === 'pending-creation') { - return `pending:${row.creationId}` - } - if (row.type === 'folder-workspace') { - return `folder-workspace:${row.folderWorkspace.id}` - } - return `wt:${row.rowKey}` -} +// Why: getRenderRowKey lives with the other virtual-row helpers now; keep the +// long-standing import path working for callers that reach for it here. +export { getRenderRowKey } export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { const groups: WorktreeDragGroup[] = [] @@ -2432,6 +2413,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [renderRows] ) const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) + const lineageRowRekeys = useMemo(() => buildLineageRowRekeyMap(renderRows), [renderRows]) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() const activeStickyIndexes = getActiveStickyIndexesForScroll({ @@ -2489,6 +2471,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp getItemElementKey: getVirtualRowKey, getRowKey: getRenderRowKey, itemElementSelector: '[data-worktree-virtual-row]', + rekeyedRowKeys: lineageRowRekeys, rows: renderRows, scrollElementRef: scrollRef, scrollOffsetRef, diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index 69771ef31f1..a188f16c83b 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -53,6 +53,7 @@ vi.mock('@/store', () => ({ })) vi.mock('@/store/selectors', () => ({ + getAllWorktreesFromState: () => Array.from(mocks.state.worktreeMap.values()), getWorktreeMapFromState: () => mocks.state.worktreeMap })) @@ -141,6 +142,16 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' }) }) + it('treats duplicate selected ids as one delete target', () => { + setWorktrees([{ id: 'wt-1' }]) + + const started = runWorktreeBatchDelete(['wt-1', 'wt-1']) + + expect(started).toBe(true) + expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledTimes(1) + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' }) + }) + it('keeps batch deletes behind confirmation when confirmation is skipped', () => { mocks.state.settings = { skipDeleteWorktreeConfirm: true } setWorktrees([ diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index f5a653a42b9..b2dc1dbc1a6 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -1,6 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import { getWorktreeMapFromState } from '@/store/selectors' +import { getAllWorktreesFromState, getWorktreeMapFromState } from '@/store/selectors' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' @@ -46,16 +46,18 @@ export async function runWorktreeDeletesInParallel( targets: readonly Pick[], options: WorktreeDeleteWithToastOptions = {} ): Promise { + // Why: refresh races can leave duplicate rows, but a destructive command must run once per identity. + const uniqueTargets = Array.from(new Map(targets.map((target) => [target.id, target])).values()) // Why: capture the viewed workspace before any delete so we can focus one survivor after the batch settles, not per delete. const activeWorktreeIdBefore = useAppStore.getState().activeWorktreeId const commitBatchFocus = activeWorktreeIdBefore ? prepareActiveWorktreeFocusAfterDelete(activeWorktreeIdBefore) : null // Why: mark every target deleting up front for immediate in-flight feedback, even though deletes serialize per repo. - useAppStore.getState().markWorktreesDeleting(targets.map((target) => target.id)) + useAppStore.getState().markWorktreesDeleting(uniqueTargets.map((target) => target.id)) // Why: worktree remove/prune/branch -D race on shared ref locks; group by repoId to serialize per repo (cross-repo stays parallel). - const groups = new Map() - for (const target of targets) { + const groups = new Map() + for (const target of uniqueTargets) { const group = groups.get(target.repoId) if (group) { group.push(target) @@ -95,7 +97,7 @@ export async function runWorktreeDeletesInParallel( if (activeWorktreeIdBefore && deletedSet.has(activeWorktreeIdBefore)) { commitBatchFocus?.() } - return targets.filter((target) => deletedSet.has(target.id)).map((target) => target.id) + return uniqueTargets.filter((target) => deletedSet.has(target.id)).map((target) => target.id) } /** @@ -249,8 +251,8 @@ export function runWorktreeDelete(worktreeId: string): void { } const hasLineageChildren = - getWorkspaceDeleteLineage(target, state.allWorktrees(), state.worktreeLineageById).descendants - .length > 0 + getWorkspaceDeleteLineage(target, getAllWorktreesFromState(state), state.worktreeLineageById) + .descendants.length > 0 const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false if (skipConfirm && !hasLineageChildren) { void runWorktreeDeleteWithToast(worktreeId, target.displayName) @@ -268,7 +270,7 @@ export function runWorktreeBatchDelete( ): boolean { const state = useAppStore.getState() const worktreeMap = getWorktreeMapFromState(state) - const targets = worktreeIds + const targets = Array.from(new Set(worktreeIds)) .map((id) => worktreeMap.get(id) ?? null) .filter((worktree): worktree is Worktree => worktree != null && !worktree.isMainWorktree) @@ -295,8 +297,11 @@ export function runWorktreeBatchDelete( // Why: bulk cleanup can destroy many directories at once, so batch/Space deletes keep an explicit confirmation step. const singleTargetHasLineageChildren = targets.length === 1 && - getWorkspaceDeleteLineage(targets[0], state.allWorktrees(), state.worktreeLineageById) - .descendants.length > 0 + getWorkspaceDeleteLineage( + targets[0], + getAllWorktreesFromState(state), + state.worktreeLineageById + ).descendants.length > 0 const skipConfirm = !options.forceConfirm && targets.length === 1 && diff --git a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts index e20214d6633..817a02f2040 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts @@ -31,6 +31,7 @@ vi.mock('@/store', () => ({ })) vi.mock('@/store/selectors', () => ({ + getAllWorktreesFromState: () => Array.from(mocks.state.worktreeMap.values()), getWorktreeMapFromState: () => mocks.state.worktreeMap })) @@ -145,6 +146,25 @@ describe('runWorktreeDeletesInParallel', () => { expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'wt-2', true) }) + it('deletes a duplicated target identity only once', async () => { + const target = { + id: 'wt-1', + displayName: 'one', + repoId: 'repo-a', + path: '/workspaces/one' + } + mocks.state.removeWorktree + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: false, error: 'selector_not_found' }) + + await expect(runWorktreeDeletesInParallel([target, target])).resolves.toEqual(['wt-1']) + + expect(mocks.state.markWorktreesDeleting).toHaveBeenCalledWith(['wt-1']) + expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) + expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false) + expect(toast.error).not.toHaveBeenCalled() + }) + it('clears a pending ancestor when a nested descendant delete fails', async () => { mocks.state.removeWorktree.mockImplementationOnce(async (worktreeId: string) => { mocks.state.deleteStateByWorktreeId[worktreeId] = { diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts index a767e73fe95..36f4d5af69c 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts @@ -1,18 +1,20 @@ /* eslint-disable max-lines -- Why: marquee selection coordinates pointer capture, lane-scroll refresh, auto-scroll, preview cleanup, and final commit against one drag state. Splitting those phases would make the interaction easier to desynchronize. */ import React, { useCallback, useEffect, useRef } from 'react' +import { + getAreaSelectionCardRects, + type AreaSelectionCardRect +} from './workspace-kanban-area-selection-card-rects' import { clearPreviewSelection, getAreaSelectionAutoScrollDelta, getAreaSelectionCardIds, - getAreaSelectionCardRects, getAreaSelectionRect, getAreaSelectionScrollContainer, getAreaSelectionScrollStartContentYByElement, isScrollbarPointerDown, setOverlayRect, shouldIgnoreAreaSelectionStart, - updatePreviewSelection, - type AreaSelectionCardRect + updatePreviewSelection } from './workspace-kanban-area-selection-dom' type AreaSelectionDragState = { @@ -110,6 +112,20 @@ export function useWorkspaceKanbanAreaSelection({ state.started = true + // Why: deferred card mount can start a marquee against empty rects; pick up + // virtual layout once lanes register without waiting for a scroll event. + if (state.cardRects.length === 0) { + const board = boardRef.current + if (board) { + state.boardRect = board.getBoundingClientRect() + state.cardRects = getAreaSelectionCardRects(board) + state.scrollStartContentYByElement = getAreaSelectionScrollStartContentYByElement( + board, + state.startY + ) + } + } + const viewportRect = getAreaSelectionRect( state.startX, state.startY, @@ -153,7 +169,7 @@ export function useWorkspaceKanbanAreaSelection({ state.additive, areaIds ) - }, [overlayRef]) + }, [boardRef, overlayRef]) const refreshAreaSelectionMeasurements = useCallback(() => { const state = dragRef.current @@ -233,6 +249,7 @@ export function useWorkspaceKanbanAreaSelection({ window.cancelAnimationFrame(state.scrollFrameId) state.scrollFrameId = null } + refreshAreaSelectionMeasurements() flushAreaSelectionDrag() if (shouldCommitWorkspaceKanbanAreaSelection(state)) { updateSelectionForAreaRef.current( @@ -246,7 +263,7 @@ export function useWorkspaceKanbanAreaSelection({ dragRef.current = null setOverlayRect(overlayRef.current, null) }, - [flushAreaSelectionDrag, overlayRef] + [flushAreaSelectionDrag, overlayRef, refreshAreaSelectionMeasurements] ) const handleAreaSelectionPointerDown = useCallback( diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts index 14f635896fc..19a67a31663 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts @@ -11,7 +11,6 @@ import { } from './workspace-kanban-card-pointer-drag-dom' import { createDragPreview, - getDraggedCards, setDragDocumentStyles, setDraggedCardsDragging, updateDragPreviewPosition @@ -33,7 +32,6 @@ type DragState = { currentY: number worktreeIds: string[] sourceCard: HTMLElement - draggedCards: HTMLElement[] preview: HTMLElement | null previewOffsetX: number previewOffsetY: number @@ -117,7 +115,11 @@ export function useWorkspaceKanbanCardPointerDrag({ if (state.frameId !== null) { window.cancelAnimationFrame(state.frameId) } - setDraggedCardsDragging(state.draggedCards, false) + setDraggedCardsDragging({ + board: boardRef.current, + worktreeIds: state.worktreeIds, + enabled: false + }) removeCardDropIndicator() state.preview?.remove() setDragDocumentStyles(false) @@ -146,13 +148,20 @@ export function useWorkspaceKanbanCardPointerDrag({ [boardRef, clearDragTarget] ) - const startPointerDrag = useCallback((state: DragState) => { - state.started = true - isPointerDragActiveRef.current = true - setDraggedCardsDragging(state.draggedCards, true) - state.preview = createDragPreview(state) - setDragDocumentStyles(true) - }, []) + const startPointerDrag = useCallback( + (state: DragState) => { + state.started = true + isPointerDragActiveRef.current = true + setDraggedCardsDragging({ + board: boardRef.current, + worktreeIds: state.worktreeIds, + enabled: true + }) + state.preview = createDragPreview(state) + setDragDocumentStyles(true) + }, + [boardRef] + ) const updatePointerDragTarget = useCallback( (state: DragState) => { @@ -289,10 +298,9 @@ export function useWorkspaceKanbanCardPointerDrag({ return } - const selectedIds = selectedWorktreeIdsRef.current const selectedWorktrees = selectedWorktreesRef.current const worktreeIds = - selectedIds.has(worktreeId) && selectedWorktrees.length > 1 + selectedWorktreeIdsRef.current.has(worktreeId) && selectedWorktrees.length > 1 ? selectedWorktrees.map((worktree) => worktree.id) : [worktreeId] dragRef.current = { @@ -303,7 +311,6 @@ export function useWorkspaceKanbanCardPointerDrag({ currentY: event.clientY, worktreeIds, sourceCard: card, - draggedCards: getDraggedCards(board, worktreeIds, card), preview: null, previewOffsetX: 0, previewOffsetY: 0, diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts new file mode 100644 index 00000000000..95d315966f2 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts @@ -0,0 +1,99 @@ +import { getWorkspaceKanbanVirtualLaneItemRects } from './workspace-kanban-virtual-lane-layout' + +type AreaSelectionCardContentRect = { + top: number + bottom: number + containerTop: number + scrollTop: number +} + +export type AreaSelectionViewportRect = { + left: number + top: number + right: number + bottom: number +} + +export type AreaSelectionCardRect = { + id: string + element: HTMLElement | null + rect: AreaSelectionViewportRect + scrollContainer: HTMLElement | null + contentRect: AreaSelectionCardContentRect | null +} + +export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]' + +export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] { + const cardRects = new Map() + const scrollMetrics = new Map() + const scrollContainers = board.querySelectorAll( + AREA_SELECTION_SCROLL_CONTAINER_SELECTOR + ) + for (const scrollContainer of scrollContainers) { + const virtualRects = getWorkspaceKanbanVirtualLaneItemRects(scrollContainer) + if (!virtualRects) { + continue + } + const containerTop = scrollContainer.getBoundingClientRect().top + const scrollTop = scrollContainer.scrollTop + scrollMetrics.set(scrollContainer, { containerTop, scrollTop }) + for (const virtualRect of virtualRects) { + cardRects.set(virtualRect.id, { + id: virtualRect.id, + element: null, + rect: { + left: virtualRect.left, + top: virtualRect.top, + right: virtualRect.right, + bottom: virtualRect.bottom + }, + scrollContainer, + contentRect: { + top: virtualRect.contentTop, + bottom: virtualRect.contentBottom, + containerTop, + scrollTop + } + }) + } + } + + const cards = board.querySelectorAll('[data-workspace-board-card-id]') + for (const card of cards) { + const id = card.dataset.workspaceBoardCardId + if (!id) { + continue + } + const rect = card.getBoundingClientRect() + const scrollContainer = card.closest(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR) + let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined + if (scrollContainer && !metrics) { + metrics = { + containerTop: scrollContainer.getBoundingClientRect().top, + scrollTop: scrollContainer.scrollTop + } + scrollMetrics.set(scrollContainer, metrics) + } + cardRects.set(id, { + id, + element: card, + rect: { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom + }, + scrollContainer, + contentRect: metrics + ? { + top: rect.top - metrics.containerTop + metrics.scrollTop, + bottom: rect.bottom - metrics.containerTop + metrics.scrollTop, + containerTop: metrics.containerTop, + scrollTop: metrics.scrollTop + } + : null + }) + } + return Array.from(cardRects.values()) +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts index a0a5c9d46b3..d023c468fbd 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts @@ -1,3 +1,8 @@ +import { + AREA_SELECTION_SCROLL_CONTAINER_SELECTOR, + type AreaSelectionCardRect +} from './workspace-kanban-area-selection-card-rects' + export type AreaSelectionRect = { left: number top: number @@ -5,21 +10,6 @@ export type AreaSelectionRect = { height: number } -export type AreaSelectionCardRect = { - id: string - element: HTMLElement - rect: DOMRect - scrollContainer: HTMLElement | null - contentRect: AreaSelectionCardContentRect | null -} - -type AreaSelectionCardContentRect = { - top: number - bottom: number - containerTop: number - scrollTop: number -} - type AreaSelectionAutoScrollParams = { pointerY: number containerTop: number @@ -37,7 +27,6 @@ type AreaSelectionCardIdOptions = { } const AREA_SELECTED_ATTR = 'data-workspace-board-card-area-selected' -export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]' export const AREA_SELECTION_AUTO_SCROLL_EDGE_SIZE = 48 export const AREA_SELECTION_AUTO_SCROLL_MAX_DELTA = 22 @@ -93,45 +82,6 @@ export function isScrollbarPointerDown( return hitsVerticalScrollbar || hitsHorizontalScrollbar } -export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] { - const cardRects: AreaSelectionCardRect[] = [] - const seen = new Set() - const scrollMetrics = new Map() - const cards = board.querySelectorAll('[data-workspace-board-card-id]') - for (const card of cards) { - const id = card.dataset.workspaceBoardCardId - if (!id || seen.has(id)) { - continue - } - const rect = card.getBoundingClientRect() - const scrollContainer = card.closest(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR) - let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined - if (scrollContainer && !metrics) { - metrics = { - containerTop: scrollContainer.getBoundingClientRect().top, - scrollTop: scrollContainer.scrollTop - } - scrollMetrics.set(scrollContainer, metrics) - } - cardRects.push({ - id, - element: card, - rect, - scrollContainer, - contentRect: metrics - ? { - top: rect.top - metrics.containerTop + metrics.scrollTop, - bottom: rect.bottom - metrics.containerTop + metrics.scrollTop, - containerTop: metrics.containerTop, - scrollTop: metrics.scrollTop - } - : null - }) - seen.add(id) - } - return cardRects -} - export function getAreaSelectionCardIds( cardRects: readonly AreaSelectionCardRect[], selectionRect: AreaSelectionRect, @@ -256,7 +206,8 @@ export function clearPreviewSelection( previewIds: Set ): void { for (const card of cardRects) { - if (previewIds.has(card.id)) { + // Why: virtual remounts replace the element; clear via live connected node when present. + if (card.element?.isConnected && previewIds.has(card.id)) { card.element.removeAttribute(AREA_SELECTED_ATTR) } } @@ -276,16 +227,29 @@ export function updatePreviewSelection( } for (const card of cardRects) { + const element = card.element?.isConnected ? card.element : null + if (!element) { + if (!nextIds.has(card.id)) { + previewIds.delete(card.id) + } + continue + } const shouldPreview = nextIds.has(card.id) - const isPreviewed = previewIds.has(card.id) + // Why: virtualization can remount the same card id; trust the live attr, not previewIds alone. + const isPreviewed = element.getAttribute(AREA_SELECTED_ATTR) === 'true' if (shouldPreview === isPreviewed) { + if (shouldPreview) { + previewIds.add(card.id) + } else { + previewIds.delete(card.id) + } continue } if (shouldPreview) { - card.element.setAttribute(AREA_SELECTED_ATTR, 'true') + element.setAttribute(AREA_SELECTED_ATTR, 'true') previewIds.add(card.id) } else { - card.element.removeAttribute(AREA_SELECTED_ATTR) + element.removeAttribute(AREA_SELECTED_ATTR) previewIds.delete(card.id) } } diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts index d04f34ca121..86a51e1e8a2 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts @@ -1,8 +1,10 @@ +// @vitest-environment happy-dom import { describe, expect, it } from 'vitest' +import type { AreaSelectionCardRect } from './workspace-kanban-area-selection-card-rects' import { getAreaSelectionAutoScrollDelta, getAreaSelectionCardIds, - type AreaSelectionCardRect + updatePreviewSelection } from './workspace-kanban-area-selection-dom' import { shouldCommitWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection' @@ -166,16 +168,52 @@ function makeRect({ top: number right: number bottom: number -}): DOMRect { - return { - left, - top, - right, - bottom, - width: right - left, - height: bottom - top, - x: left, - y: top, - toJSON: () => ({}) - } as DOMRect +}): AreaSelectionCardRect['rect'] { + return { left, top, right, bottom } } + +describe('workspace kanban area selection preview remount', () => { + it('re-applies the preview attribute when a card remounts under the same id', () => { + const first = document.createElement('div') + first.setAttribute('data-workspace-board-card-id', 'card-a') + Object.defineProperty(first, 'isConnected', { value: true }) + const previewIds = new Set() + updatePreviewSelection( + [ + { + id: 'card-a', + element: first, + rect: makeRect({ left: 0, top: 0, right: 100, bottom: 40 }), + scrollContainer: null, + contentRect: null + } + ], + previewIds, + new Set(), + false, + ['card-a'] + ) + expect(first.getAttribute('data-workspace-board-card-area-selected')).toBe('true') + expect(previewIds.has('card-a')).toBe(true) + + const remounted = document.createElement('div') + remounted.setAttribute('data-workspace-board-card-id', 'card-a') + Object.defineProperty(remounted, 'isConnected', { value: true }) + updatePreviewSelection( + [ + { + id: 'card-a', + element: remounted, + rect: makeRect({ left: 0, top: 0, right: 100, bottom: 40 }), + scrollContainer: null, + contentRect: null + } + ], + previewIds, + new Set(), + false, + ['card-a'] + ) + expect(remounted.getAttribute('data-workspace-board-card-area-selected')).toBe('true') + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts index 887d7ebcac2..58dafc5db3f 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts @@ -37,12 +37,27 @@ export function getDraggedCards( return cards.length > 0 ? cards : [fallbackCard] } -export function setDraggedCardsDragging(cards: readonly HTMLElement[], enabled: boolean): void { - for (const card of cards) { - if (enabled) { - card.setAttribute(POINTER_CARD_DRAGGING_ATTR, 'true') - } else { +export function setDraggedCardsDragging(args: { + board: HTMLElement | null + worktreeIds: readonly string[] + enabled: boolean +}): void { + const { board, worktreeIds, enabled } = args + if (!board) { + return + } + // Why: virtual lanes remount cards during scroll; query live nodes by id + // instead of holding HTMLElement references across unmounts. + if (!enabled) { + board.querySelectorAll(`[${POINTER_CARD_DRAGGING_ATTR}]`).forEach((card) => { card.removeAttribute(POINTER_CARD_DRAGGING_ATTR) + }) + return + } + const ids = new Set(worktreeIds) + for (const card of board.querySelectorAll(CARD_SELECTOR)) { + if (ids.has(card.dataset.workspaceBoardCardId ?? '')) { + card.setAttribute(POINTER_CARD_DRAGGING_ATTR, 'true') } } } diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts index 5a8698a1cb8..f8ab1df0f94 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveWorkspaceKanbanCardDropCommitTarget, resolveWorkspaceCardDropIndexFromRects, + resolveWorkspaceCardDropIndicatorY, resolveWorkspaceStatusDropTargetFromRects } from './workspace-kanban-card-pointer-drag-dom' @@ -41,6 +42,47 @@ describe('workspace kanban pointer drag card drop index', () => { it('inserts at the end when the pointer is below every card midpoint', () => { expect(resolveWorkspaceCardDropIndexFromRects(cardRects, 140)).toBe(3) }) + + // Why: a virtualized lane only renders its window, so rendered position is + // not lane position. Without the carried index a drop into a scrolled lane + // would land near the top of the list. + it('uses the lane index of a rendered card rather than its rendered position', () => { + const virtualized = [ + { top: 0, bottom: 40, index: 20 }, + { top: 48, bottom: 88, index: 21 }, + { top: 96, bottom: 136, index: 22 } + ] + expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 10)).toBe(20) + expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 70)).toBe(22) + expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 140)).toBe(23) + }) +}) + +describe('workspace kanban drop indicator placement', () => { + const cardRects = [ + { top: 100, bottom: 140 }, + { top: 148, bottom: 188 } + ] + + it('places the line above, between, and below rendered cards', () => { + expect(resolveWorkspaceCardDropIndicatorY(cardRects, 0, 90)).toBe(95) + expect(resolveWorkspaceCardDropIndicatorY(cardRects, 1, 90)).toBe(144) + expect(resolveWorkspaceCardDropIndicatorY(cardRects, 2, 90)).toBe(193) + }) + + it('falls back to the lane top when the lane renders no cards', () => { + expect(resolveWorkspaceCardDropIndicatorY([], 0, 90)).toBe(104) + }) + + it('anchors to the rendered card that owns the lane index', () => { + const virtualized = [ + { top: 100, bottom: 140, index: 20 }, + { top: 148, bottom: 188, index: 21 } + ] + expect(resolveWorkspaceCardDropIndicatorY(virtualized, 20, 90)).toBe(95) + expect(resolveWorkspaceCardDropIndicatorY(virtualized, 21, 90)).toBe(144) + expect(resolveWorkspaceCardDropIndicatorY(virtualized, 22, 90)).toBe(193) + }) }) describe('workspace kanban pointer drag commit target', () => { diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts index 9114a47a881..dd59d430ab9 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts @@ -1,4 +1,8 @@ import type { WorkspaceStatus } from '../../../../shared/types' +import { + resolveWorkspaceKanbanVirtualLaneDropIndex, + resolveWorkspaceKanbanVirtualLaneDropIndicatorY +} from './workspace-kanban-virtual-lane-layout' export const CARD_SELECTOR = '[data-workspace-board-card-id]' export const STATUS_DROP_TARGET = '[data-workspace-status-drop-target]' @@ -19,6 +23,9 @@ export type WorkspaceKanbanStatusDropRect = { export type WorkspaceKanbanCardDropRect = { top: number bottom: number + // Why: lanes virtualize, so a rendered card's DOM position is not its index + // in the lane. Carry the lane index when the card advertises one. + index?: number } export type WorkspaceKanbanLaneDropRect = { @@ -31,6 +38,7 @@ export type WorkspaceKanbanCardDropTarget = { status: WorkspaceStatus | null isPinDrop: boolean dropIndex: number + dropIndicatorY?: number laneRect?: WorkspaceKanbanLaneDropRect cardRects?: readonly WorkspaceKanbanCardDropRect[] } @@ -75,10 +83,51 @@ export function resolveWorkspaceCardDropIndexFromRects( for (let index = 0; index < rects.length; index++) { const rect = rects[index]! if (y < (rect.top + rect.bottom) / 2) { - return index + return rect.index ?? index } } - return rects.length + const last = rects.at(-1) + return last?.index !== undefined ? last.index + 1 : rects.length +} + +function getRenderedCardLanePosition(rect: WorkspaceKanbanCardDropRect, fallback: number): number { + return rect.index ?? fallback +} + +export function resolveWorkspaceCardDropIndicatorY( + rects: readonly WorkspaceKanbanCardDropRect[], + dropIndex: number, + laneTop: number +): number { + if (rects.length === 0) { + return laneTop + 14 + } + // Why: with a virtualized lane the drop index counts unrendered cards too, + // so place the line relative to the rendered card that owns that slot. + const slot = rects.findIndex( + (rect, index) => getRenderedCardLanePosition(rect, index) >= dropIndex + ) + if (slot === -1) { + return rects.at(-1)!.bottom + 5 + } + if (slot === 0) { + return rects[0]!.top - 5 + } + return (rects[slot - 1]!.bottom + rects[slot]!.top) / 2 +} + +function getLaneCardDropRects(lane: HTMLElement): WorkspaceKanbanCardDropRect[] { + return Array.from(lane.querySelectorAll(CARD_SELECTOR)) + .filter((card) => card.offsetParent !== null) + .map((card) => { + const rect = card.getBoundingClientRect() + const index = Number.parseInt(card.dataset.workspaceBoardCardIndex ?? '', 10) + return { + top: rect.top, + bottom: rect.bottom, + ...(Number.isInteger(index) ? { index } : {}) + } + }) } function hasWorkspaceKanbanCardDropTarget(target: WorkspaceKanbanCardDropTarget): boolean { @@ -176,16 +225,18 @@ export function getCardDropTarget( const laneContent = lane.querySelector('[data-workspace-board-lane-scroll]') const laneClientRect = (laneContent ?? lane).getBoundingClientRect() - const cardRects = Array.from(lane.querySelectorAll(CARD_SELECTOR)) - .filter((card) => card.offsetParent !== null) - .map((card) => { - const rect = card.getBoundingClientRect() - return { top: rect.top, bottom: rect.bottom } - }) - const dropIndex = resolveWorkspaceCardDropIndexFromRects(cardRects, y) + const cardRects = getLaneCardDropRects(lane) + const virtualDropIndex = laneContent + ? resolveWorkspaceKanbanVirtualLaneDropIndex(laneContent, y) + : null + const dropIndex = virtualDropIndex ?? resolveWorkspaceCardDropIndexFromRects(cardRects, y) + const dropIndicatorY = laneContent + ? resolveWorkspaceKanbanVirtualLaneDropIndicatorY(laneContent, dropIndex) + : null return { ...target, dropIndex, + ...(dropIndicatorY !== null ? { dropIndicatorY } : {}), laneRect: { left: laneClientRect.left, top: laneClientRect.top, @@ -245,23 +296,10 @@ export function updateCardDropIndicator( width: fallbackLaneRect.width } : { left: 0, top: 0, width: 0 }) - const cardRects = - target.cardRects ?? - Array.from(lane.querySelectorAll(CARD_SELECTOR)) - .filter((card) => card.offsetParent !== null) - .map((card) => { - const rect = card.getBoundingClientRect() - return { top: rect.top, bottom: rect.bottom } - }) - const boundedDropIndex = Math.max(0, Math.min(cardRects.length, target.dropIndex)) + const cardRects = target.cardRects ?? getLaneCardDropRects(lane) const y = - cardRects.length === 0 - ? laneRect.top + 14 - : boundedDropIndex === 0 - ? cardRects[0]!.top - 5 - : boundedDropIndex >= cardRects.length - ? cardRects.at(-1)!.bottom + 5 - : (cardRects[boundedDropIndex - 1]!.bottom + cardRects[boundedDropIndex]!.top) / 2 + target.dropIndicatorY ?? + resolveWorkspaceCardDropIndicatorY(cardRects, Math.max(0, target.dropIndex), laneRect.top) const horizontalInset = 8 const indicator = getOrCreateDropIndicator() indicator.dataset.workspaceStatus = target.status diff --git a/src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts b/src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts new file mode 100644 index 00000000000..84acef4a4bc --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts @@ -0,0 +1,17 @@ +import { defaultRangeExtractor, type Range } from '@tanstack/react-virtual' + +export function extractWorkspaceKanbanLaneRange( + range: Range, + focusedIndex: number | null +): number[] { + const indexes = defaultRangeExtractor(range) + if ( + focusedIndex === null || + focusedIndex < 0 || + focusedIndex >= range.count || + indexes.includes(focusedIndex) + ) { + return indexes + } + return [...indexes, focusedIndex].sort((left, right) => left - right) +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index 3feefdd62da..08f627cc0b4 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -7,9 +7,11 @@ import { getWorkspaceKanbanSidebarDropGroups, getWorkspaceKanbanSidebarDropTarget, isWorkspaceKanbanSidebarDropPointInBoard, + registerWorkspaceKanbanSidebarDropGroups, resolveWorkspaceKanbanSidebarFullLaneDropIndex, updateWorkspaceKanbanSidebarDropTargetVisual } from './workspace-kanban-sidebar-drop' +import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout' const workspaceStatuses: WorkspaceStatusDefinition[] = [ { id: 'todo', label: 'Todo' }, @@ -29,6 +31,7 @@ class FakeElement extends FakeNode { } } offsetParent: FakeElement | null = null + scrollTop = 0 private readonly attributes = new Map() private rect: Pick = { left: 0, @@ -159,6 +162,7 @@ class FakeDocument { } let fakeDocument: FakeDocument +let unregisterSidebarDropGroups: (() => void) | null = null function setRect( element: HTMLElement, @@ -221,6 +225,8 @@ function worktree(args: { } afterEach(() => { + unregisterSidebarDropGroups?.() + unregisterSidebarDropGroups = null clearWorkspaceKanbanSidebarDropTargetVisual() vi.unstubAllGlobals() vi.restoreAllMocks() @@ -249,6 +255,71 @@ describe('workspace kanban sidebar drop DOM bridge', () => { ]) }) + it('prefers complete registered groups and restores the DOM fallback on cleanup', () => { + appendBoard() + unregisterSidebarDropGroups = registerWorkspaceKanbanSidebarDropGroups([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] } + ]) + + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] } + ]) + + unregisterSidebarDropGroups() + unregisterSidebarDropGroups = null + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-b'] } + ]) + }) + + it('uses the full virtual layout when the mounted card window is stale', () => { + const { lane } = appendBoard() + const laneScroll = document.createElement('div') + laneScroll.setAttribute('data-workspace-board-lane-scroll', '') + setRect(laneScroll, { + left: 0, + top: 0, + right: 200, + bottom: 220, + width: 200, + height: 220 + }) + const spacer = document.createElement('div') + setRect(spacer, { + left: 8, + top: -4260, + right: 192, + bottom: 220, + width: 184, + height: 4480 + }) + laneScroll.append(spacer) + lane.append(laneScroll) + setElementFromPoint(lane) + const unregister = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement: laneScroll, + spacerElement: spacer, + getItemIds: () => Array.from({ length: 102 }, (_, index) => `doing-${index}`), + getMeasurements: () => + Array.from({ length: 102 }, (_, index) => ({ + index, + start: index * 44, + end: index * 44 + 36 + })) + }) + + expect(getWorkspaceKanbanSidebarDropTarget(24, 210)).toMatchObject({ + status: 'doing', + dropIndex: 102, + dropIndicatorY: 225, + cardRects: [ + { top: 8, bottom: 48 }, + { top: 56, bottom: 96 } + ] + }) + unregister() + }) + it('prefers the published full lane membership over the rendered card scan', () => { const { lane } = appendBoard() lane.dataset.workspaceLaneFullIds = diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index c48d5312ad1..363038bd529 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -29,6 +29,7 @@ const BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]' const EXTERNAL_DRAG_TARGET_ATTR = 'data-workspace-board-external-drag-target' let externalDragTargetElement: HTMLElement | null = null +let logicalDropGroupsRegistration: { groups: readonly WorktreeDragGroup[] } | null = null function getWorkspaceKanbanBoardElement(): HTMLElement | null { return document.querySelector(BOARD_SELECTOR) @@ -97,7 +98,26 @@ export function clearWorkspaceKanbanSidebarDropTargetVisual(): void { removeCardDropIndicator() } +export function registerWorkspaceKanbanSidebarDropGroups( + groups: readonly WorktreeDragGroup[] +): () => void { + const registration = { groups } + logicalDropGroupsRegistration = registration + return () => { + if (logicalDropGroupsRegistration === registration) { + logicalDropGroupsRegistration = null + } + } +} + export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] { + if (logicalDropGroupsRegistration) { + return logicalDropGroupsRegistration.groups.map((group) => ({ + key: group.key, + worktreeIds: [...group.worktreeIds] + })) + } + const board = getWorkspaceKanbanBoardElement() if (!board) { return [] diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts new file mode 100644 index 00000000000..003cdf73784 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import { getAreaSelectionCardRects } from './workspace-kanban-area-selection-card-rects' +import { getAreaSelectionCardIds } from './workspace-kanban-area-selection-dom' +import { + getWorkspaceKanbanVirtualLaneItemRects, + registerWorkspaceKanbanVirtualLaneLayout, + resolveWorkspaceKanbanVirtualLaneDropIndex, + resolveWorkspaceKanbanVirtualLaneDropIndicatorY +} from './workspace-kanban-virtual-lane-layout' + +const ITEM_COUNT = 102 +const ITEM_STRIDE = 44 +const ITEM_HEIGHT = 36 + +function createLayoutElements(): { + scrollElement: HTMLElement + spacerElement: HTMLElement +} { + const scrollElement = { + scrollTop: 0, + getBoundingClientRect: () => ({ + left: 20, + top: 100, + right: 220, + bottom: 600, + width: 200, + height: 500 + }) + } as unknown as HTMLElement + const spacerElement = { + getBoundingClientRect: () => ({ + left: 26, + top: 108 - scrollElement.scrollTop, + right: 214, + bottom: 108 - scrollElement.scrollTop + ITEM_COUNT * ITEM_STRIDE, + width: 188, + height: ITEM_COUNT * ITEM_STRIDE + }) + } as unknown as HTMLElement + return { scrollElement, spacerElement } +} + +function registerLayout(scrollElement: HTMLElement, spacerElement: HTMLElement): () => void { + return registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => Array.from({ length: ITEM_COUNT }, (_, index) => `w${index}`), + getMeasurements: () => + Array.from({ length: ITEM_COUNT }, (_, index) => ({ + index, + start: index * ITEM_STRIDE, + end: index * ITEM_STRIDE + ITEM_HEIGHT + })) + }) +} + +describe('workspace kanban virtual lane layout', () => { + it('resolves mid-lane and bottom drops immediately after a large scroll jump', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregister = registerLayout(scrollElement, spacerElement) + scrollElement.scrollTop = 3_980 + + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 370)).toBe(97) + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 590)).toBe(102) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 102)).toBe(613) + unregister() + }) + + it('exposes every logical item even when no card is mounted', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregister = registerLayout(scrollElement, spacerElement) + scrollElement.scrollTop = 3_980 + + const rects = getWorkspaceKanbanVirtualLaneItemRects(scrollElement) + expect(rects).toHaveLength(ITEM_COUNT) + expect(rects?.[50]).toMatchObject({ + id: 'w50', + index: 50, + contentTop: 2_208, + contentBottom: 2_244 + }) + unregister() + }) + + it('selects intermediate never-mounted cards across a jumped lane range', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregister = registerLayout(scrollElement, spacerElement) + scrollElement.scrollTop = 3_980 + const board = { + querySelectorAll: (selector: string) => + selector === '[data-workspace-board-lane-scroll]' ? [scrollElement] : [] + } as unknown as HTMLElement + + const cardRects = getAreaSelectionCardRects(board) + const selectedIds = getAreaSelectionCardIds( + cardRects, + { left: 0, top: 110, width: 300, height: 480 }, + { + scrollStartContentYByElement: new Map([[scrollElement, 18]]), + currentY: 590 + } + ) + + expect(cardRects.every((card) => card.element === null)).toBe(true) + expect(selectedIds).toHaveLength(ITEM_COUNT) + expect(selectedIds).toContain('w50') + unregister() + }) + + it('does not let stale cleanup remove a newer lane registration', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregisterFirst = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['old'], + getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }] + }) + const unregisterSecond = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['new'], + getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }] + }) + + unregisterFirst() + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)?.map((item) => item.id)).toEqual([ + 'new' + ]) + unregisterSecond() + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() + }) + + it('places empty-lane and boundary drop indicators from the spacer', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregisterEmpty = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => [], + getMeasurements: () => [] + }) + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 200)).toBe(0) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 0)).toBe(114) + unregisterEmpty() + + const unregister = registerLayout(scrollElement, spacerElement) + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 90)).toBe(0) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 0)).toBe(103) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 1)).toBe(148) + unregister() + }) + + it('returns null when measurements are incomplete or mismatched', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregisterShort = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['a', 'b'], + getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }] + }) + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 120)).toBeNull() + unregisterShort() + + const unregisterBadIndex = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['a'], + getMeasurements: () => [{ index: 3, start: 0, end: ITEM_HEIGHT }] + }) + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() + unregisterBadIndex() + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts new file mode 100644 index 00000000000..e142b3617d6 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts @@ -0,0 +1,159 @@ +import type { VirtualItem } from '@tanstack/react-virtual' + +type VirtualLaneLayoutRegistration = { + spacerElement: HTMLElement + getItemIds: () => readonly string[] + getMeasurements: () => readonly Pick[] +} + +type VirtualLaneLayoutSnapshot = { + registration: VirtualLaneLayoutRegistration + itemIds: readonly string[] + measurements: readonly Pick[] +} + +export type WorkspaceKanbanVirtualLaneItemRect = { + id: string + index: number + left: number + top: number + right: number + bottom: number + contentTop: number + contentBottom: number +} + +const virtualLaneLayouts = new WeakMap() + +export function registerWorkspaceKanbanVirtualLaneLayout(args: { + scrollElement: HTMLElement + spacerElement: HTMLElement + getItemIds: () => readonly string[] + getMeasurements: () => readonly Pick[] +}): () => void { + const registration = { + spacerElement: args.spacerElement, + getItemIds: args.getItemIds, + getMeasurements: args.getMeasurements + } + virtualLaneLayouts.set(args.scrollElement, registration) + return () => { + if (virtualLaneLayouts.get(args.scrollElement) === registration) { + virtualLaneLayouts.delete(args.scrollElement) + } + } +} + +export function getWorkspaceKanbanVirtualLaneItemRects( + scrollElement: HTMLElement +): WorkspaceKanbanVirtualLaneItemRect[] | null { + const snapshot = getVirtualLaneLayoutSnapshot(scrollElement) + if (!snapshot) { + return null + } + + const spacerRect = snapshot.registration.spacerElement.getBoundingClientRect() + const containerRect = scrollElement.getBoundingClientRect() + const contentOffset = spacerRect.top - containerRect.top + scrollElement.scrollTop + const rects: WorkspaceKanbanVirtualLaneItemRect[] = [] + for (let index = 0; index < snapshot.itemIds.length; index++) { + const measurement = snapshot.measurements[index] + if (!isValidMeasurement(measurement, index)) { + return null + } + rects.push({ + id: snapshot.itemIds[index]!, + index, + left: spacerRect.left, + top: spacerRect.top + measurement.start, + right: spacerRect.right, + bottom: spacerRect.top + measurement.end, + contentTop: contentOffset + measurement.start, + contentBottom: contentOffset + measurement.end + }) + } + return rects +} + +export function resolveWorkspaceKanbanVirtualLaneDropIndex( + scrollElement: HTMLElement, + pointerY: number +): number | null { + const snapshot = getVirtualLaneLayoutSnapshot(scrollElement) + if (!snapshot) { + return null + } + + const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top + let low = 0 + let high = snapshot.itemIds.length + while (low < high) { + const middle = Math.floor((low + high) / 2) + const measurement = snapshot.measurements[middle] + if (!isValidMeasurement(measurement, middle)) { + return null + } + if (pointerY < spacerTop + (measurement.start + measurement.end) / 2) { + high = middle + } else { + low = middle + 1 + } + } + return low +} + +export function resolveWorkspaceKanbanVirtualLaneDropIndicatorY( + scrollElement: HTMLElement, + dropIndex: number +): number | null { + const snapshot = getVirtualLaneLayoutSnapshot(scrollElement) + if (!snapshot) { + return null + } + if (snapshot.itemIds.length === 0) { + return scrollElement.getBoundingClientRect().top + 14 + } + + const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top + const boundedIndex = Math.max(0, Math.min(snapshot.itemIds.length, dropIndex)) + if (boundedIndex === 0) { + const first = snapshot.measurements[0] + return isValidMeasurement(first, 0) ? spacerTop + first.start - 5 : null + } + if (boundedIndex === snapshot.itemIds.length) { + const lastIndex = snapshot.itemIds.length - 1 + const last = snapshot.measurements[lastIndex] + return isValidMeasurement(last, lastIndex) ? spacerTop + last.end + 5 : null + } + const previous = snapshot.measurements[boundedIndex - 1] + const next = snapshot.measurements[boundedIndex] + if (!isValidMeasurement(previous, boundedIndex - 1) || !isValidMeasurement(next, boundedIndex)) { + return null + } + return spacerTop + (previous.end + next.start) / 2 +} + +function getVirtualLaneLayoutSnapshot( + scrollElement: HTMLElement +): VirtualLaneLayoutSnapshot | null { + const registration = virtualLaneLayouts.get(scrollElement) + if (!registration) { + return null + } + const itemIds = registration.getItemIds() + const measurements = registration.getMeasurements() + return measurements.length >= itemIds.length ? { registration, itemIds, measurements } : null +} + +function isValidMeasurement( + measurement: Pick | undefined, + index: number +): measurement is Pick { + return Boolean( + measurement && + measurement.index === index && + Number.isFinite(measurement.start) && + Number.isFinite(measurement.end) && + measurement.end >= measurement.start + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts index 6544948c02c..04d2857ed64 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest' import type { VirtualItem } from '@tanstack/react-virtual' import { HOST_STICKY_PINNED_HEIGHT, + buildLineageRowRekeyMap, extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, + getRenderRowKey, getStickyHeaderIndexes, pruneStaleVirtualRowElementCache, type RenderRow @@ -185,6 +187,91 @@ describe('extractWorktreeVirtualRowIndexes', () => { }) }) +describe('buildLineageRowRekeyMap', () => { + // Mirrors buildWorktreeRow: rowKey is `${sectionKey}:${worktree.id}`. + function worktreeRow(sectionKey: string, worktreeId: string): RenderRow { + return { + type: 'item', + rowKey: `${sectionKey}:${worktreeId}`, + sectionKey, + worktree: { id: worktreeId }, + depth: 0, + groupDepth: 0, + lineageTrail: [], + isLastLineageChild: true, + lineageChildCount: 0 + } as unknown as RenderRow + } + + function lineageGroupRow(sectionKey: string, parentId: string, childIds: string[]): RenderRow { + return { + type: 'lineage-group', + key: `${sectionKey}:lineage:${parentId}`, + rows: [parentId, ...childIds].map( + (id) => worktreeRow(sectionKey, id) as Extract + ) + } + } + + it('folds every lineage-group member onto the group key', () => { + const group = lineageGroupRow('all', 'p', ['c1', 'c2']) + const rekeys = buildLineageRowRekeyMap([group]) + + // The parent's own row key is what an anchor recorded before the child + // existed; all members now live inside the single group row. + expect(rekeys.get('wt:all:p')).toBe('lineage-group:all:lineage:p') + expect(rekeys.get('wt:all:c1')).toBe('lineage-group:all:lineage:p') + expect(rekeys.get('wt:all:c2')).toBe('lineage-group:all:lineage:p') + expect(getRenderRowKey(group)).toBe('lineage-group:all:lineage:p') + }) + + it('dissolves a group key back onto the plain item row', () => { + // Last child deleted: lineageChildCount is already 0, but an anchor still + // holds the group key, so the reverse mapping must be unguarded. + const rekeys = buildLineageRowRekeyMap([worktreeRow('all', 'p')]) + + expect(rekeys.get('lineage-group:all:lineage:p')).toBe('wt:all:p') + }) + + it('round-trips the fold and dissolve directions for the same worktree', () => { + const folded = buildLineageRowRekeyMap([lineageGroupRow('all', 'p', ['c1'])]) + const dissolved = buildLineageRowRekeyMap([worktreeRow('all', 'p')]) + + expect(dissolved.get(folded.get('wt:all:p') as string)).toBe('wt:all:p') + }) + + it('keeps the same worktree distinct across sections', () => { + // The same worktree renders in both Pinned and All; rowKey embeds the + // section so a pinned anchor must never follow the All copy. + const rekeys = buildLineageRowRekeyMap([ + lineageGroupRow('pinned', 'p', ['c1']), + lineageGroupRow('all', 'p', ['c1']) + ]) + + expect(rekeys.get('wt:pinned:p')).toBe('lineage-group:pinned:lineage:p') + expect(rekeys.get('wt:all:p')).toBe('lineage-group:all:lineage:p') + expect(rekeys.get('wt:pinned:c1')).toBe('lineage-group:pinned:lineage:p') + expect(rekeys.get('wt:all:c1')).toBe('lineage-group:all:lineage:p') + + const dissolvedRekeys = buildLineageRowRekeyMap([ + worktreeRow('pinned', 'p'), + worktreeRow('all', 'p') + ]) + expect(dissolvedRekeys.get('lineage-group:pinned:lineage:p')).toBe('wt:pinned:p') + expect(dissolvedRekeys.get('lineage-group:all:lineage:p')).toBe('wt:all:p') + }) + + it('contributes nothing for non-lineage row types', () => { + expect( + buildLineageRowRekeyMap([hostRow('a'), groupRow('a1'), hostRow('b'), groupRow('b1')]).size + ).toBe(0) + }) + + it('is empty for an empty row list', () => { + expect(buildLineageRowRekeyMap([]).size).toBe(0) + }) +}) + describe('pruneStaleVirtualRowElementCache', () => { it('removes stale measured row elements before they retain old WorktreeCard scopes', () => { const activeElement = { diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 8d38d95935f..1ec2e07a8a1 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -1,7 +1,7 @@ import { defaultRangeExtractor } from '@tanstack/react-virtual' import type { Range, VirtualItem } from '@tanstack/react-virtual' import type { HostSectionRow } from './host-section-rows' -import { PINNED_GROUP_KEY } from './worktree-list-groups' +import { getLineageGroupKey, PINNED_GROUP_KEY } from './worktree-list-groups' export const GROUP_HEADER_ROW_HEIGHT = 28 export const HOST_HEADER_ROW_HEIGHT = 32 @@ -15,6 +15,63 @@ export type RenderRow = | HostSectionRow | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } +export function getRenderRowKey(row: RenderRow): string { + if (row.type === 'host-header') { + return `host:${row.hostId}` + } + if (row.type === 'header') { + return `hdr:${row.key}` + } + if (row.type === 'lineage-group') { + return `lineage-group:${row.key}` + } + if (row.type === 'imported-worktrees-card') { + return `imported:${row.key}` + } + if (row.type === 'new-external-worktrees-inbox') { + return `inbox:${row.key}` + } + if (row.type === 'pending-creation') { + return `pending:${row.creationId}` + } + if (row.type === 'folder-workspace') { + return `folder-workspace:${row.folderWorkspace.id}` + } + return `wt:${row.rowKey}` +} + +/** + * Old row key -> current row key for rows that were re-keyed without moving. + * + * A parent's key flips between `wt:` and `lineage-group:` the moment it gains + * its first child (and back when it loses its last), even though the row still + * starts at the same pixel. Without this, a scroll anchor recorded under the + * old key resolves a fallback row below it and the sidebar visibly jumps. + */ +export function buildLineageRowRekeyMap(rows: readonly RenderRow[]): ReadonlyMap { + const rekeyed = new Map() + for (const row of rows) { + if (row.type === 'lineage-group') { + const groupKey = getRenderRowKey(row) + for (const member of row.rows) { + rekeyed.set(`wt:${member.rowKey}`, groupKey) + } + continue + } + if (row.type !== 'item') { + continue + } + // Why: deliberately unguarded by lineageChildCount — the dissolve case (last + // child deleted) is exactly when the count is already 0 but an anchor still + // holds the group key. + rekeyed.set( + `lineage-group:${row.sectionKey}:${getLineageGroupKey(row.worktree.id)}`, + getRenderRowKey(row) + ) + } + return rekeyed +} + export function shouldUseHeaderTopSpacing(args: { rows: readonly RenderRow[] index: number diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx index ea84e02c1d6..d32f6a017a8 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -223,6 +223,59 @@ describe('SkillFreshnessNudge', () => { expect(mocks.toastInfo).not.toHaveBeenCalled() }) + it('keeps a project copy out of the dismissal key it persists', async () => { + // Why: the global update never reaches a project copy, so its identity must not enter + // the dismissal tuple — re-checking out that repo mints a new inode, which would + // re-raise a nudge the user already dismissed for the same global revision. + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement(), + placement({ + id: 'repo-copy', + topology: 'repo-scope', + sourceKind: 'repo', + unresolvedPath: '/home/projects/work/.agents/skills/orca-cli', + resolvedPath: '/home/projects/work/.agents/skills/orca-cli', + physicalIdentity: 'physical-repo-orca-cli' + }) + ], + eligibleUpdateNames: ['orca-cli'], + scanIssues: [], + scannedAt: 1 + } + + await renderNudge() + mocks.toastInfo.mock.calls[0]?.[1].onDismiss() + + expect(mocks.updateSettings).toHaveBeenCalledWith({ + dismissedSkillFreshnessNudges: [DISMISSAL_KEY] + }) + }) + + it('stays dismissed when only a project copy changed identity', async () => { + mocks.dismissed = [DISMISSAL_KEY] + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement(), + placement({ + id: 'repo-copy', + topology: 'repo-scope', + sourceKind: 'repo', + physicalIdentity: 'physical-repo-orca-cli-after-checkout' + }) + ], + eligibleUpdateNames: ['orca-cli'], + scanIssues: [], + scannedAt: 2 + } + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) + it('waits for persisted settings before deciding whether to nudge', async () => { mocks.settingsLoaded = false diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx index 135d860656b..eb29629812a 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' +import { skillPlacementParticipatesInGlobalFreshness } from '../../../../shared/skill-freshness' import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' const MAX_DISMISSED_FRESHNESS_NUDGES = 512 @@ -52,6 +53,10 @@ export function SkillFreshnessNudge(): null { } const eligibleNames = new Set(inventory.eligibleUpdateNames) const candidates = inventory.installations.flatMap((installation) => + // Why: a project copy the global update never touches must not enter the dismissal + // fingerprint, or re-checking out that repo re-raises a nudge the user already + // dismissed — and spends the bounded dismissal budget on copies Orca cannot update. + skillPlacementParticipatesInGlobalFreshness(installation) && installation.status === 'outdated' && eligibleNames.has(installation.name) && installation.physicalIdentity diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index 7c00fb26d53..1a53ecdcf10 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -400,7 +400,9 @@ describe('SkillFreshnessUpdateDialog', () => { it('shows why a skill was skipped without needing the disclosure', async () => { mocks.inventory = { schemaVersion: 1, - installations: [placement('computer-use', { topology: 'repo-scope' })], + // Why: a read-only copy rather than a project one — a project-owned copy raises no + // row at all now, so it cannot carry this assertion about how a row renders. + installations: [placement('computer-use', { topology: 'read-only' })], eligibleUpdateNames: [], scanIssues: [], scannedAt: 3 @@ -415,14 +417,73 @@ describe('SkillFreshnessUpdateDialog', () => { // outside the disclosure — visible with the row still collapsed, and not // dependent on a mount-time `defaultOpen` a later re-scan could never re-fire. expect(row?.getAttribute('data-collapsible-open')).toBe('false') - expect(container?.textContent).toContain('This is a project skill, not a global one') + expect(container?.textContent).toContain('This copy is in a read-only location') // Assert the placement, not just the presence: moving it back inside the // disclosure would hide it in production but still satisfy `textContent`. expect(row?.querySelector('[data-collapsible-content]')?.textContent).not.toContain( - 'This is a project skill, not a global one' + 'This copy is in a read-only location' ) }) + it('raises no row for a skill whose only finding is a project-owned copy', async () => { + // The reported bug: a pristine global install plus a drifted copy inside a work + // directory. Orca only ever updates global skills, so a "Skipped" row here asserts + // it considered an update it could never perform. + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('computer-use', { status: 'current' }), + placement('computer-use', { + rootId: 'repo-work', + sourceKind: 'repo', + topology: 'repo-scope', + status: 'unrecognized', + unresolvedPath: '/home/projects/work/.agents/skills/computer-use', + resolvedPath: '/home/projects/work/.agents/skills/computer-use', + physicalIdentity: 'physical-repo-computer-use' + }) + ], + eligibleUpdateNames: [], + scanIssues: [], + scannedAt: 4 + } + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('All installed Orca skills are up to date.') + expect(container?.querySelector('[data-skill-row="computer-use"]')).toBeNull() + }) + + it('still lists a project copy when another placement earns the group', async () => { + // The converse: ownership suppresses the group, never the visibility of a location + // that exists. An outdated global copy earns the row, and the project copy has to + // stay listed there so the user can see every place the skill lives. + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('computer-use', { topology: 'read-only' }), + placement('computer-use', { + rootId: 'repo-work', + sourceKind: 'repo', + topology: 'repo-scope', + status: 'unrecognized', + unresolvedPath: '/home/projects/work/.agents/skills/computer-use', + resolvedPath: '/home/projects/work/.agents/skills/computer-use', + physicalIdentity: 'physical-repo-computer-use' + }) + ], + eligibleUpdateNames: [], + scanIssues: [], + scannedAt: 5 + } + await renderDialog() + await openViaRequest() + + const row = container?.querySelector('[data-skill-row="computer-use"]') + expect(row).not.toBeNull() + expect(row?.textContent).toContain('/home/projects/work/.agents/skills/computer-use') + }) + it('names the skill in the stale-record remedy so the command is runnable as-is', async () => { // Why: the reinstall advice only fires when the row hands its group name through // to skippedReason — dropping that argument silently degrades every stale-record @@ -442,6 +503,40 @@ describe('SkillFreshnessUpdateDialog', () => { ) }) + it('keeps the stale-record remedy when a project copy is listed beside it', async () => { + // The same stale-record row as above, plus the user's own project copy. That copy is + // listed but was never judged, so letting it explain the skip replaced the only + // runnable command with advice about a copy the user never asked Orca to update. + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orchestration'), + placement('orchestration', { + rootId: 'repo-work', + sourceKind: 'repo', + topology: 'repo-scope', + status: 'unrecognized', + unresolvedPath: '/home/projects/work/.agents/skills/orchestration', + resolvedPath: '/home/projects/work/.agents/skills/orchestration', + physicalIdentity: 'physical-repo-orchestration' + }) + ], + eligibleUpdateNames: [], + scanIssues: [], + scannedAt: 6 + } + await renderDialog() + await openViaRequest() + + const row = container?.querySelector('[data-skill-row="orchestration"]') + expect(row?.textContent).toContain( + 'npx skills add https://github.com/stablyai/orca --skill orchestration --global' + ) + expect(row?.textContent).not.toContain('This is a project skill, not a global one') + // Still listed, though — ownership silences the explanation, never the location. + expect(row?.textContent).toContain('/home/projects/work/.agents/skills/orchestration') + }) + it('stays coherent while an idle re-scan is in flight', async () => { await renderDialog() await openViaRequest() diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.test.ts b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts index 63c2243b71f..4c9afaed59b 100644 --- a/src/renderer/src/components/skills/skill-freshness-grouping.test.ts +++ b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts @@ -35,7 +35,12 @@ describe('groupSkillFreshness', () => { expect(groups).toHaveLength(1) expect(groups[0]).toMatchObject({ name: 'orca-cli', status: 'update-available' }) expect(groups[0]?.locations).toEqual([ - { id: expect.any(String), path: '/home/.agents/skills/orca-cli', chip: null } + { + id: expect.any(String), + path: '/home/.agents/skills/orca-cli', + chip: null, + participatesInGlobalFreshness: true + } ]) }) @@ -75,7 +80,12 @@ describe('groupSkillFreshness', () => { name: 'dataviz', status: 'cannot-update', locations: [ - { id: expect.any(String), path: '/home/.agents/skills/dataviz', chip: 'unrecognized' } + { + id: expect.any(String), + path: '/home/.agents/skills/dataviz', + chip: 'unrecognized', + participatesInGlobalFreshness: true + } ] }, { @@ -85,7 +95,8 @@ describe('groupSkillFreshness', () => { { id: expect.any(String), path: '/home/.agents/skills/linear-tickets', - chip: 'inaccessible' + chip: 'inaccessible', + participatesInGlobalFreshness: true } ] } @@ -124,8 +135,18 @@ describe('groupSkillFreshness', () => { expect(groups[0]?.status).toBe('cannot-update') // Why: the out-of-date main copy is bare; only the poisoning copy carries a chip. expect(groups[0]?.locations).toEqual([ - { id: expect.any(String), path: '/home/.agents/skills/orchestration', chip: null }, - { id: expect.any(String), path: '/home/.claude/skills/orchestration', chip: 'unrecognized' } + { + id: expect.any(String), + path: '/home/.agents/skills/orchestration', + chip: null, + participatesInGlobalFreshness: true + }, + { + id: expect.any(String), + path: '/home/.claude/skills/orchestration', + chip: 'unrecognized', + participatesInGlobalFreshness: true + } ]) }) @@ -146,12 +167,22 @@ describe('groupSkillFreshness', () => { expect(groups).toHaveLength(1) expect(groups[0]?.status).toBe('cannot-update') expect(groups[0]?.locations).toEqual([ - { id: expect.any(String), path: '/home/.agents/skills/orchestration', chip: 'current' }, - { id: expect.any(String), path: '/home/.factory/skills/orchestration', chip: 'duplicate' } + { + id: expect.any(String), + path: '/home/.agents/skills/orchestration', + chip: 'current', + participatesInGlobalFreshness: true + }, + { + id: expect.any(String), + path: '/home/.factory/skills/orchestration', + chip: 'duplicate', + participatesInGlobalFreshness: true + } ]) }) - it('prefers a location status over its topology and maps every topology to a chip', () => { + it('ranks a read failure over ownership, ownership over byte status, and maps every topology', () => { const chipFor = (overrides: Partial): string | null => groupSkillFreshness( [placement('s', { status: 'outdated' }), placement('s', overrides)], @@ -178,5 +209,79 @@ describe('groupSkillFreshness', () => { expect(chipFor(at('j', { status: 'unrecognized', topology: 'plugin-cache' }))).toBe( 'plugin-cache' ) + // Ownership outranks byte status: a project copy whose bytes match nothing known is + // the repo's content, not the user's drift, so it must not read "may be modified". + expect(chipFor(at('l', { status: 'unrecognized', topology: 'repo-scope' }))).toBe('in-a-repo') + // But a read failure outranks ownership, or that same rule would hide a real fault. + expect(chipFor(at('m', { status: 'inaccessible', topology: 'repo-scope' }))).toBe( + 'inaccessible' + ) + }) + + it('raises no group when every finding is project-owned', () => { + // Orca's updater only passes --global, so a project copy has no remedy; a row here + // would claim Orca considered an update it could never perform. + expect( + groupSkillFreshness( + [placement('computer-use', { status: 'unrecognized', topology: 'repo-scope' })], + [] + ) + ).toEqual([]) + expect( + groupSkillFreshness( + [placement('computer-use', { status: 'outdated', topology: 'repo-scope' })], + [] + ) + ).toEqual([]) + }) + + it('still lists a project copy inside a group another placement earned', () => { + const groups = groupSkillFreshness( + [ + placement('computer-use', { status: 'outdated', topology: 'read-only' }), + placement('computer-use', { + status: 'unrecognized', + topology: 'repo-scope', + unresolvedPath: '/home/projects/work/.agents/skills/computer-use' + }) + ], + [] + ) + + expect(groups).toHaveLength(1) + expect(groups[0]?.locations.map((location) => location.chip)).toContain('in-a-repo') + }) + + it('marks only the project copy as one the global update never judged', () => { + // Why the flag rather than the chip: the skip sentence reads this to keep a listed-only + // copy from explaining a skip it had no part in, and a plugin copy — which does earn + // groups of its own — has to stay on the judged side of that line. + const groups = groupSkillFreshness( + [ + placement('computer-use', { status: 'outdated', topology: 'read-only' }), + placement('computer-use', { + status: 'outdated', + topology: 'plugin-cache', + unresolvedPath: '/home/.claude/plugins/cache/pack/skills/computer-use' + }), + placement('computer-use', { + status: 'unrecognized', + topology: 'repo-scope', + unresolvedPath: '/home/projects/work/.agents/skills/computer-use' + }) + ], + [] + ) + + expect( + groups[0]?.locations.map((location) => [ + location.chip, + location.participatesInGlobalFreshness + ]) + ).toEqual([ + ['read-only', true], + ['plugin-cache', true], + ['in-a-repo', false] + ]) }) }) diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.ts b/src/renderer/src/components/skills/skill-freshness-grouping.ts index 79d39b31aff..08241eda6a4 100644 --- a/src/renderer/src/components/skills/skill-freshness-grouping.ts +++ b/src/renderer/src/components/skills/skill-freshness-grouping.ts @@ -1,5 +1,7 @@ import { + isOwnerManagedSkillScope, isSkillCopyNeedingAttention, + skillPlacementParticipatesInGlobalFreshness, type SkillFreshnessInstallation } from '../../../../shared/skill-freshness' @@ -21,6 +23,9 @@ export type SkillLocationRow = { id: string path: string chip: SkillLocationChip | null + /** False for a project-owned copy: listed for visibility, but never judged, so it + * cannot be the reason a skill was skipped. */ + participatesInGlobalFreshness: boolean } export type SkillFreshnessGroupModel = { @@ -29,16 +34,16 @@ export type SkillFreshnessGroupModel = { locations: SkillLocationRow[] } +// Why: an owner-managed scope outranks 'unrecognized', or a project copy reads "may be +// modified — remove it" over content its own repo owns. A read failure outranks both, so +// that rule cannot hide a genuine fault. export function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null { - if (installation.status === 'unrecognized' && installation.topology === 'plugin-cache') { - return 'plugin-cache' - } - if (installation.status === 'unrecognized') { - return 'unrecognized' - } if (installation.status === 'inaccessible') { return 'inaccessible' } + if (!isOwnerManagedSkillScope(installation.topology) && installation.status === 'unrecognized') { + return 'unrecognized' + } switch (installation.topology) { case 'independent-copy': return 'duplicate' @@ -74,7 +79,8 @@ export function locationChip(installation: SkillFreshnessInstallation): SkillLoc * Details, and Details opens this modal. Returning only out-of-date skills left that * modal saying every skill was up to date over an empty list, contradicting the badge * that sent the user there. Skills where every copy is current are still omitted, as - * is a plugin's own copy of a same-named skill, which is not the user's to fix. + * is a plugin's own copy of a same-named skill, and a project's own copy — neither is the + * user's to fix through the global update. * * `alwaysIncludeNames` overrides that filter. A successful update makes every * targeted skill current, which would otherwise drop its row the instant the @@ -96,14 +102,26 @@ export function groupSkillFreshness( } const groups: SkillFreshnessGroupModel[] = [] for (const [name, entries] of byName) { + // Why: the inclusion test only consults placements the global update can reach, so a + // skill whose only finding is a project-owned copy raises no row at all — a "Skipped" + // row would assert Orca considered an update it never could. Locations below are still + // listed in full, so a project copy stays visible whenever some other placement earns + // the group. if ( !pinned.has(name) && - !entries.some((entry) => entry.status === 'outdated' || isSkillCopyNeedingAttention(entry)) + !entries + .filter(skillPlacementParticipatesInGlobalFreshness) + .some((entry) => entry.status === 'outdated' || isSkillCopyNeedingAttention(entry)) ) { continue } const locations = entries - .map((entry) => ({ id: entry.id, path: entry.unresolvedPath, chip: locationChip(entry) })) + .map((entry) => ({ + id: entry.id, + path: entry.unresolvedPath, + chip: locationChip(entry), + participatesInGlobalFreshness: skillPlacementParticipatesInGlobalFreshness(entry) + })) .sort((left, right) => left.path.localeCompare(right.path, 'en')) groups.push({ name, diff --git a/src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts b/src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts index 664a4883f84..5f04d122dd6 100644 --- a/src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts +++ b/src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts @@ -6,7 +6,15 @@ function row( chip: SkillLocationRow['chip'], path = `/home/.agents/skills/${chip}` ): SkillLocationRow { - return { id: `row-${chip}-${path}`, path, chip } + return { id: `row-${chip}-${path}`, path, chip, participatesInGlobalFreshness: true } +} + +/** A project's own copy: listed, but never judged by the global update. */ +function projectRow(chip: SkillLocationRow['chip'] = 'in-a-repo'): SkillLocationRow { + return { + ...row(chip, '/home/projects/work/.agents/skills/orchestration'), + participatesInGlobalFreshness: false + } } describe('skippedReason', () => { @@ -49,6 +57,35 @@ describe('skippedReason', () => { ) }) + it('keeps the reinstall remedy when a project copy sits beside the blocked global copy', () => { + // The reported shape: a bare out-of-date global copy the updater's record cannot + // converge, plus the user's own project copy. Letting the project copy explain the + // skip swapped the one runnable command for advice about a copy Orca never judged. + const reason = skippedReason([row(null), projectRow()], 'orchestration') + expect(reason).toContain('reports the skill as already up to date') + expect(reason).toContain('skills add') + expect(reason).not.toContain('This is a project skill') + }) + + it('does not let a project copy outrank the placement that blocked the update', () => { + // Why by row and not by chip: the scan-limit sentinel is also project-scoped and + // carries 'inaccessible', which would otherwise claim a read failure Orca never hit. + expect(skippedReason([row('duplicate'), projectRow()], 'orchestration')).toContain( + 'separate copy' + ) + expect( + skippedReason([row('duplicate'), projectRow('inaccessible')], 'orchestration') + ).toContain('separate copy') + }) + + it('explains a project copy when that is the only location there is', () => { + // Why the fallback exists: the sentence stays total. With nothing else to point at, + // "Orca only updates your global skills" is the honest reason. + expect(skippedReason([projectRow()], 'orchestration')).toContain( + 'This is a project skill, not a global one' + ) + }) + it('falls back to the generic sentence when no skill name is available', () => { expect(skippedReason([row('current')])).toContain('left this skill out of the update') expect(skippedReason([])).toContain('left this skill out of the update') diff --git a/src/renderer/src/components/skills/skill-freshness-skipped-reason.ts b/src/renderer/src/components/skills/skill-freshness-skipped-reason.ts index 0e9c01c01f1..84ef6fc3f29 100644 --- a/src/renderer/src/components/skills/skill-freshness-skipped-reason.ts +++ b/src/renderer/src/components/skills/skill-freshness-skipped-reason.ts @@ -21,8 +21,12 @@ const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [ 'duplicate' ] +// Why: a location the global update never judged cannot be why the skill was skipped, so it +// must not outrank the placement that was — nor the ABSENCE of a chip, which is how the +// stale-record remedy below is reached. The fallback keeps the sentence total. function blockingChip(locations: readonly SkillLocationRow[]): SkillLocationChip | undefined { - const present = new Set(locations.map((location) => location.chip)) + const judged = locations.filter((location) => location.participatesInGlobalFreshness) + const present = new Set((judged.length > 0 ? judged : locations).map((location) => location.chip)) return SKIPPED_REASON_PRIORITY.find((candidate) => present.has(candidate)) } diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 02978a47912..9d7534e8c3b 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -968,7 +968,10 @@ function MiniBar({ display: UsagePercentageDisplay }): React.JSX.Element { return ( -
    +
    0) { @@ -1232,13 +1233,8 @@ function VerboseProviderUsage({ return window !== null }) - const meterWindow = p.session ?? p.weekly - return ( <> - {meterWindow && !compact ? ( - - ) : null} {visibleWindows.map((window, index) => ( {index > 0 ? · : null} @@ -1312,22 +1308,20 @@ export function ProviderSegment({ {mode === 'verbose' ? ( - - ) : ( <> {tightest && !compact ? ( ) : null} - {tightest ? ( - - ) : null} + - )} + ) : tightest ? ( + + ) : null} {isStale && } ) diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx index b1addca4dab..de4f5a0e06f 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -134,6 +134,119 @@ describe('UsageRow', () => { expect(markup).toContain('wk') expect(markup).toContain('10%') }) + + it('renders the tightest window inline in compact mode', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-usage-mode="compact"') + expect(markup.match(/data-usage-window=/g)).toHaveLength(1) + expect(markup).not.toContain('data-usage-bar') + expect(markup).toContain('60%') + expect(markup).not.toContain('25%') + expect(markup).not.toContain('Resets in') + }) + + it('uses the same compact selection for Claude subscription windows', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup.match(/data-usage-window=/g)).toHaveLength(1) + expect(markup).not.toContain('data-usage-bar') + expect(markup).toContain('Fable') + expect(markup).toContain('75%') + expect(markup).not.toContain('25%') + expect(markup).not.toContain('60%') + }) + + it('renders every window below the header in verbose mode', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-usage-mode="verbose"') + expect(markup.match(/data-usage-window=/g)).toHaveLength(2) + expect(markup.match(/data-usage-bar/g)).toHaveLength(2) + expect(markup).toContain('25%') + expect(markup).toContain('60%') + }) }) describe('UsageRosterPanel density picker', () => { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index 1e675232228..feb066fadca 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -80,31 +80,60 @@ function soonestResetLabel(sections: UsageSection[], now: number): string | null return formatResetCountdown(Math.min(...resets) - now) } -// Presentational row: a compact header (icon · name · plan · reset) with -// the per-window metrics beneath, so the reset stays visible and multi-window -// agents stay short. The wrapper supplies padding + interaction (drill-in -// submenu or plain clickable row). +function UsageMetric({ + section, + label, + display, + showBar = true +}: { + section: UsageSection + label: string + display: UsagePercentageDisplay + showBar?: boolean +}): React.JSX.Element { + const used = clampUsedPercent(section.window.usedPercent) + const shown = getDisplayedUsagePercentage(section.window.usedPercent, display) + + return ( + + {label} + {showBar ? ( + + + + ) : null} + {shown}% + + ) +} + export function UsageRow({ p, display, state, showSignInAction, - now + now, + mode = 'verbose' }: { p: ProviderRateLimits display: UsagePercentageDisplay state: UsageRosterRowState showSignInAction: boolean now: number + mode?: StatusBarUsageMode }): React.JSX.Element { const sections = usedSections(p) const hasUsage = sections.length > 0 const name = getProviderDisplayName(p.provider) const plan = formatPlanLabel(p.planType) const reset = hasUsage ? soonestResetLabel(sections, now) : null + const tightest = mode === 'compact' ? getTightestUsageSection(p) : null return ( -
    +
    @@ -124,30 +153,29 @@ export function UsageRow({ ) : null} + ) : tightest ? ( + + + ) : reset ? ( {reset} ) : null}
    - {hasUsage ? ( + {hasUsage && mode === 'verbose' ? (
    - {sections.map((s) => { - const used = clampUsedPercent(s.window.usedPercent) - const shown = getDisplayedUsagePercentage(s.window.usedPercent, display) - return ( - - {shortLabel(p, s)} - - - - - {shown}% - - - ) - })} + {sections.map((section) => ( + + ))}
    ) : null}
    @@ -268,6 +296,7 @@ export function UsageRosterPanel({ state={state} showSignInAction={showSignInAction} now={now} + mode={statusBarUsageMode} /> ) if (showSignInAction) { diff --git a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx index 6e69a482d95..3bab0e1bfcb 100644 --- a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx +++ b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx @@ -145,6 +145,21 @@ describe('ProviderSegment monthly window', () => { } }) + it('shows the footer bar only in verbose mode', async () => { + const { ProviderSegment } = await import('./StatusBar') + const limits = grokMonthlyLimits('ok') + + const verbose = renderToStaticMarkup( + + ) + const compact = renderToStaticMarkup( + + ) + + expect(verbose).toContain('data-usage-bar') + expect(compact).not.toContain('data-usage-bar') + }) + it('restores every inline window in verbose mode', async () => { const { ProviderSegment } = await import('./StatusBar') const limits: ProviderRateLimits = { diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index b1c9251251c..d06bf9ee623 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -46,6 +46,32 @@ describe('task page cache selectors', () => { }) }) + it('reconciles a changed neutral check count', () => { + const current = { + ...workItem('pr-1', 'repo-1'), + checksSummary: { + state: 'neutral' as const, + total: 1, + passed: 1, + failed: 0, + pending: 0, + neutral: 0 + } + } + const refreshed = { + ...current, + checksSummary: { + state: 'neutral' as const, + total: 2, + passed: 1, + failed: 0, + pending: 0, + neutral: 1 + } + } + expect(reconcileTaskPageItemsAfterLandingRefresh([current], [refreshed])).toEqual([refreshed]) + }) + it('keeps the selected work-item cache slice shallow-equal across unrelated cache writes', () => { const repo = { id: 'repo-1', path: '/repo/one' } const selectedEntry = entry([workItem('issue-1', 'repo-1')]) diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 37644ce76db..5256701003f 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -1,12 +1,22 @@ -/* eslint-disable max-lines -- Why: TaskPage cache reconciliation helpers are - kept together so list refresh and drawer lookup behavior stay consistent. */ import { workItemsCacheKey, type CacheEntry, type WorkItemsCacheError, type WorkItemsCacheSources } from '@/store/slices/github' -import type { GitHubWorkItem, LinearCollectionResult, LinearIssue } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/types' +import { + taskPageWorkItemKey, + taskPageWorkItemStatusSignature, + taskPageWorkItemKeyOrderSignature, + taskPageWorkItemPaginationBoundary +} from './task-page-work-item-signatures' +export { + findTaskPageLinearDrawerIssue, + findTaskPageLinearIssue, + reconcileTaskPageLinearIssuesAfterLandingRefresh, + shouldReplaceTaskPageLinearIssuesAfterRefresh +} from './task-page-linear-cache-selectors' export type TaskPageRepoCacheInput = { id: string @@ -34,9 +44,6 @@ export type TaskPageWorkItemsFetchOptions = { } type WorkItemsCache = Record> -type LinearIssueCache = Record> -type LinearSearchCache = Record> -type LinearListCache = Record>> export type TaskPageWorkItemPages = readonly (GitHubWorkItem[] | null)[] export function deriveTaskPageGitHubWorkItemsFetchOptions( @@ -149,56 +156,6 @@ export function reconcileTaskPagePagesWithWorkItemsCache( return changed ? nextPages : (pages as (GitHubWorkItem[] | null)[]) } -function taskPageWorkItemKey(item: GitHubWorkItem): string { - return `${item.repoId}\u0000${item.id}` -} - -function sortedStrings(values: readonly string[] | undefined): string { - return [...(values ?? [])].sort().join('\u0000') -} - -function sortedLogins(users: readonly { login: string | null | undefined }[] | undefined): string { - return [...(users ?? [])] - .map((user) => user.login ?? '') - .sort() - .join('\u0000') -} - -function taskPageWorkItemStatusSignature(item: GitHubWorkItem): string { - return JSON.stringify([ - item.type, - item.number, - item.title, - item.state, - item.url, - item.author, - item.branchName ?? null, - item.baseRefName ?? null, - sortedStrings(item.labels), - sortedLogins(item.assignees), - sortedLogins(item.reviewRequests), - item.reviewDecision ?? null, - item.checksSummary?.state ?? null, - item.checksSummary?.total ?? null, - item.checksSummary?.failed ?? null, - item.checksSummary?.pending ?? null, - item.mergeable ?? null, - item.autoMergeEnabled ?? null, - item.autoMergeAllowed ?? null, - item.mergeQueueRequired ?? null, - item.mergeStateStatus ?? null, - item.updatedAt - ]) -} - -function taskPageWorkItemKeyOrderSignature(items: readonly GitHubWorkItem[]): string { - return items.map(taskPageWorkItemKey).join('\u0000') -} - -function taskPageWorkItemPaginationBoundary(items: readonly GitHubWorkItem[]): string | null { - return items.at(-1)?.updatedAt ?? null -} - export function shouldReplaceTaskPageItemsAfterRefresh( currentItems: readonly GitHubWorkItem[], refreshedItems: readonly GitHubWorkItem[] @@ -273,66 +230,6 @@ export function reconcileTaskPagePagesAfterLandingRefresh( return [nextFirstPage, ...pages.slice(1)] } -function linearIssueKey(issue: LinearIssue): string { - return issue.id -} - -function linearIssueStatusSignature(issue: LinearIssue): string { - return JSON.stringify([ - issue.identifier, - issue.title, - issue.url, - issue.state.name, - issue.state.type, - issue.state.color, - issue.team.id, - issue.team.name, - issue.team.key, - sortedStrings(issue.labels), - issue.assignee?.id ?? null, - issue.assignee?.displayName ?? null, - issue.priority, - issue.updatedAt - ]) -} - -export function shouldReplaceTaskPageLinearIssuesAfterRefresh( - currentIssues: readonly LinearIssue[], - refreshedIssues: readonly LinearIssue[] -): boolean { - if (currentIssues.length !== refreshedIssues.length) { - return true - } - const currentKeys = new Set(currentIssues.map(linearIssueKey)) - for (const issue of refreshedIssues) { - if (!currentKeys.has(linearIssueKey(issue))) { - return true - } - } - return false -} - -export function reconcileTaskPageLinearIssuesAfterLandingRefresh( - currentIssues: readonly LinearIssue[], - refreshedIssues: readonly LinearIssue[] -): LinearIssue[] { - if (shouldReplaceTaskPageLinearIssuesAfterRefresh(currentIssues, refreshedIssues)) { - return [...refreshedIssues] - } - - const refreshedByKey = new Map(refreshedIssues.map((issue) => [linearIssueKey(issue), issue])) - let changed = false - const next = currentIssues.map((issue) => { - const refreshed = refreshedByKey.get(linearIssueKey(issue)) - if (!refreshed || linearIssueStatusSignature(issue) === linearIssueStatusSignature(refreshed)) { - return issue - } - changed = true - return refreshed - }) - return changed ? next : (currentIssues as LinearIssue[]) -} - export function findTaskPageDialogWorkItem( workItemsCache: WorkItemsCache, dialogWorkItemKey: TaskPageDialogWorkItemKey @@ -351,34 +248,3 @@ export function findTaskPageDialogWorkItem( } return null } - -export function findTaskPageLinearIssue( - linearIssueCache: LinearIssueCache, - linearSearchCache: LinearSearchCache, - linearListCache: LinearListCache, - linearIssueId: string | null -): LinearIssue | null { - if (!linearIssueId) { - return null - } - for (const entry of Object.values(linearIssueCache)) { - if (entry?.data?.id === linearIssueId) { - return entry.data - } - } - for (const entry of Object.values(linearSearchCache)) { - const found = entry?.data?.find((issue) => issue.id === linearIssueId) - if (found) { - return found - } - } - for (const entry of Object.values(linearListCache)) { - const found = entry?.data?.items.find((issue) => issue.id === linearIssueId) - if (found) { - return found - } - } - return null -} - -export const findTaskPageLinearDrawerIssue = findTaskPageLinearIssue diff --git a/src/renderer/src/components/task-page-linear-cache-selectors.ts b/src/renderer/src/components/task-page-linear-cache-selectors.ts new file mode 100644 index 00000000000..d13536ea93f --- /dev/null +++ b/src/renderer/src/components/task-page-linear-cache-selectors.ts @@ -0,0 +1,92 @@ +import type { CacheEntry } from '@/store/slices/github' +import type { LinearCollectionResult, LinearIssue } from '../../../shared/types' +import { sortedStrings } from './task-page-work-item-signatures' + +type LinearIssueCache = Record> +type LinearSearchCache = Record> +type LinearListCache = Record>> + +function linearIssueKey(issue: LinearIssue): string { + return issue.id +} + +function linearIssueStatusSignature(issue: LinearIssue): string { + return JSON.stringify([ + issue.identifier, + issue.title, + issue.url, + issue.state.name, + issue.state.type, + issue.state.color, + issue.team.id, + issue.team.name, + issue.team.key, + sortedStrings(issue.labels), + issue.assignee?.id ?? null, + issue.assignee?.displayName ?? null, + issue.priority, + issue.updatedAt + ]) +} + +export function shouldReplaceTaskPageLinearIssuesAfterRefresh( + currentIssues: readonly LinearIssue[], + refreshedIssues: readonly LinearIssue[] +): boolean { + if (currentIssues.length !== refreshedIssues.length) { + return true + } + const currentKeys = new Set(currentIssues.map(linearIssueKey)) + return refreshedIssues.some((issue) => !currentKeys.has(linearIssueKey(issue))) +} + +export function reconcileTaskPageLinearIssuesAfterLandingRefresh( + currentIssues: readonly LinearIssue[], + refreshedIssues: readonly LinearIssue[] +): LinearIssue[] { + if (shouldReplaceTaskPageLinearIssuesAfterRefresh(currentIssues, refreshedIssues)) { + return [...refreshedIssues] + } + const refreshedByKey = new Map(refreshedIssues.map((issue) => [linearIssueKey(issue), issue])) + let changed = false + const next = currentIssues.map((issue) => { + const refreshed = refreshedByKey.get(linearIssueKey(issue)) + if (!refreshed || linearIssueStatusSignature(issue) === linearIssueStatusSignature(refreshed)) { + return issue + } + changed = true + return refreshed + }) + return changed ? next : (currentIssues as LinearIssue[]) +} + +export function findTaskPageLinearIssue( + linearIssueCache: LinearIssueCache, + linearSearchCache: LinearSearchCache, + linearListCache: LinearListCache, + linearIssueId: string | null +): LinearIssue | null { + if (!linearIssueId) { + return null + } + for (const entry of Object.values(linearIssueCache)) { + if (entry?.data?.id === linearIssueId) { + return entry.data + } + } + for (const entry of Object.values(linearSearchCache)) { + const found = entry?.data?.find((issue) => issue.id === linearIssueId) + if (found) { + return found + } + } + for (const entry of Object.values(linearListCache)) { + const found = entry?.data?.items.find((issue) => issue.id === linearIssueId) + if (found) { + return found + } + } + return null +} + +export const findTaskPageLinearDrawerIssue = findTaskPageLinearIssue diff --git a/src/renderer/src/components/task-page-pr-check-summary.test.ts b/src/renderer/src/components/task-page-pr-check-summary.test.ts index bf774bcf721..2258d89fdbb 100644 --- a/src/renderer/src/components/task-page-pr-check-summary.test.ts +++ b/src/renderer/src/components/task-page-pr-check-summary.test.ts @@ -20,7 +20,8 @@ describe('deriveTaskPagePRCheckSummary', () => { total: 0, passed: 0, failed: 0, - pending: 0 + pending: 0, + neutral: 0 }) }) @@ -36,11 +37,12 @@ describe('deriveTaskPagePRCheckSummary', () => { total: 3, passed: 1, failed: 1, - pending: 1 + pending: 1, + neutral: 0 }) }) - it('treats neutral and skipped checks as passed for the compact PR table label', () => { + it('keeps neutral checks out of passed while skipped checks pass', () => { expect( deriveTaskPagePRCheckSummary([ check({ conclusion: 'success' }), @@ -48,11 +50,12 @@ describe('deriveTaskPagePRCheckSummary', () => { check({ conclusion: 'skipped' }) ]) ).toEqual({ - state: 'success', + state: 'neutral', total: 3, - passed: 3, + passed: 2, failed: 0, - pending: 0 + pending: 0, + neutral: 1 }) }) @@ -67,7 +70,23 @@ describe('deriveTaskPagePRCheckSummary', () => { total: 2, passed: 1, failed: 1, - pending: 0 + pending: 0, + neutral: 0 + }) + }) + + it('keeps unknown completed outcomes neutral', () => { + expect( + deriveTaskPagePRCheckSummary([ + check({ conclusion: 'future_state' as PRCheckDetail['conclusion'] }) + ]) + ).toEqual({ + state: 'neutral', + total: 1, + passed: 0, + failed: 0, + pending: 0, + neutral: 1 }) }) }) diff --git a/src/renderer/src/components/task-page-pr-check-summary.ts b/src/renderer/src/components/task-page-pr-check-summary.ts index 4f9fd2be5db..b0bc88091ff 100644 --- a/src/renderer/src/components/task-page-pr-check-summary.ts +++ b/src/renderer/src/components/task-page-pr-check-summary.ts @@ -1,4 +1,4 @@ -import type { GitHubPRCheckSummary, PRCheckDetail } from '../../../shared/types' +import type { PRCheckDetail, ProviderCheckSummary } from '../../../shared/types' function getCheckConclusion(check: PRCheckDetail): NonNullable { return check.conclusion ?? 'pending' @@ -12,19 +12,22 @@ function isPendingCheck(check: PRCheckDetail): boolean { ) } -export function deriveTaskPagePRCheckSummary(checks: PRCheckDetail[]): GitHubPRCheckSummary { +export function deriveTaskPagePRCheckSummary(checks: PRCheckDetail[]): ProviderCheckSummary { if (checks.length === 0) { - return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0 } + return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0, neutral: 0 } } let passed = 0 let failed = 0 let pending = 0 + let neutral = 0 for (const check of checks) { const conclusion = getCheckConclusion(check) - if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { + if (conclusion === 'success' || conclusion === 'skipped') { passed += 1 + } else if (conclusion === 'neutral') { + neutral += 1 } else if ( conclusion === 'failure' || conclusion === 'timed_out' || @@ -37,15 +40,16 @@ export function deriveTaskPagePRCheckSummary(checks: PRCheckDetail[]): GitHubPRC } else if (isPendingCheck(check)) { pending += 1 } else { - passed += 1 + neutral += 1 } } return { - state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success', + state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : neutral > 0 ? 'neutral' : 'success', total: checks.length, passed, failed, - pending + pending, + neutral } } diff --git a/src/renderer/src/components/task-page-work-item-signatures.ts b/src/renderer/src/components/task-page-work-item-signatures.ts new file mode 100644 index 00000000000..67875e2e15e --- /dev/null +++ b/src/renderer/src/components/task-page-work-item-signatures.ts @@ -0,0 +1,54 @@ +import type { GitHubWorkItem } from '../../../shared/types' + +export function taskPageWorkItemKey(item: GitHubWorkItem): string { + return `${item.repoId}\u0000${item.id}` +} + +export function sortedStrings(values: readonly string[] | undefined): string { + return [...(values ?? [])].sort().join('\u0000') +} + +function sortedLogins(users: readonly { login: string | null | undefined }[] | undefined): string { + return [...(users ?? [])] + .map((user) => user.login ?? '') + .sort() + .join('\u0000') +} + +export function taskPageWorkItemStatusSignature(item: GitHubWorkItem): string { + return JSON.stringify([ + item.type, + item.number, + item.title, + item.state, + item.url, + item.author, + item.branchName ?? null, + item.baseRefName ?? null, + sortedStrings(item.labels), + sortedLogins(item.assignees), + sortedLogins(item.reviewRequests), + item.reviewDecision ?? null, + item.checksSummary?.state ?? null, + item.checksSummary?.total ?? null, + item.checksSummary?.failed ?? null, + item.checksSummary?.pending ?? null, + item.checksSummary?.neutral ?? null, + item.mergeable ?? null, + item.autoMergeEnabled ?? null, + item.autoMergeAllowed ?? null, + item.mergeQueueRequired ?? null, + item.mergeStateStatus ?? null, + item.updatedAt + ]) +} + +export function taskPageWorkItemKeyOrderSignature(items: readonly GitHubWorkItem[]): string { + return items.map(taskPageWorkItemKey).join('\u0000') +} + +export function taskPageWorkItemPaginationBoundary( + items: readonly GitHubWorkItem[] +): string | null { + return items.at(-1)?.updatedAt ?? null +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index c2479267e24..24991e01914 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -271,7 +271,7 @@ describe('createParkedTerminalCommandStatusPolicy', () => { ssh.dispose() expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledTimes(2) - expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID) + expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID, 0) }) it('drops a same-turn status row on command finished for SSH PTYs only', async () => { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index ff422e55de5..781625318bd 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -126,13 +126,13 @@ export function createParkedTerminalCommandStatusPolicy(options: { ) return { - onCommandFinished: (): void => { + onCommandFinished: (bestEffortExitCode: number | null): void => { if (disposed) { return } // Why: the finished command may have moved HEAD or the index (an agent running // `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll. - dispatchTerminalCommandFinishedEvent(worktreeId) + dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode) // Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted // path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need // pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index db6030d00fd..e80c19f1a1f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -305,6 +305,7 @@ type ConnectCallbacks = { onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void onWriteUnavailable?: () => void + onOutputPauseChanged?: (paused: boolean, supported: boolean) => void } type MockTransport = { @@ -320,6 +321,7 @@ type MockTransport = { sendInputImmediate: ReturnType sendInputAccepted?: ReturnType claimViewport: ReturnType + setOutputPaused?: ReturnType resize: ReturnType getPtyId: ReturnType getConnectionId: ReturnType @@ -5510,6 +5512,7 @@ describe('connectPanePty', () => { // Once replay completes (guard cleared), real keystrokes flow through. replayingPanesRef.current.delete(1) setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) + transport.claimViewport.mockClear() ;(onDataHandler as (data: string) => void)('a') expect(transport.sendInput).toHaveBeenCalledWith('a') expect(transport.claimViewport).toHaveBeenCalledTimes(1) @@ -6412,7 +6415,7 @@ describe('connectPanePty', () => { } }) - it('orders a startup draft behind xterm focus input when Codex renders its composer', async () => { + it('orders a startup draft behind existing user input when Codex renders its composer', async () => { const { connectPanePty } = await import('./pty-connection') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -6451,6 +6454,11 @@ describe('connectPanePty', () => { mock: { calls: [(data: string) => void][] } } ).mock.calls[0]?.[0]('\x1b[I') + ;( + pane.terminal.onData as unknown as { + mock: { calls: [(data: string) => void][] } + } + ).mock.calls[0]?.[0]('USER_DRAFT') ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') await flushAsyncTicks() @@ -6460,6 +6468,7 @@ describe('connectPanePty', () => { ) expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ '\x1b[I', + 'USER_DRAFT', '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' ]) expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -14003,6 +14012,137 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('pauses capable paired output while hidden and restores exactly on reveal', async () => { + const { connectPanePty } = await import('./pty-connection') + const remotePtyId = 'remote:env-1@@terminal-1' + const transport = createMockTransport(remotePtyId) + let callbacks: ConnectCallbacks = {} + transport.connect.mockImplementation(async (options: { callbacks?: ConnectCallbacks }) => { + callbacks = options.callbacks ?? {} + return remotePtyId + }) + transport.setOutputPaused = vi.fn((paused: boolean) => { + callbacks.onOutputPauseChanged?.(paused, true) + return true + }) + const hidden = 'paired hidden flood\r\n' + const snapshot = 'paired snapshot with hidden flood\r\n' + const live = 'paired visible marker\r\n' + transport.serializeBuffer = vi.fn().mockResolvedValue({ + data: snapshot, + cols: 100, + rows: 30, + seq: hidden.length, + source: 'headless' + }) + transportFactoryQueue.push(transport) + mockStoreState.repos = [ + { id: 'repo1', connectionId: null, displayName: 'orca', executionHostId: 'runtime:env-1' } + ] + mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'env-1' + + const pane = createPane(1) + const manager = createManager(1) + const recordPaneMode2031Subscription = vi.fn() + const deps = createDeps({ + isVisibleRef: { current: false }, + recordPaneMode2031Subscription + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(true) + expect( + (transport.sendInput as unknown as (data: string) => boolean)('hidden input marker') + ).toBe(true) + expect(transport.sendInput).toHaveBeenCalledWith('hidden input marker') + callbacks.onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) + + const factsHandler = await import('./terminal-side-effect-facts-handler') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + mockStoreState.ptyIdsByTabId = { 'tab-1': [remotePtyId] } + mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { + [LEAF_1]: remotePtyId + } + const visibleStatusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'working'; prompt: string; agentType: 'claude' }) => void) + | undefined + expect(visibleStatusHandler).toBeTypeOf('function') + visibleStatusHandler?.({ + state: 'working', + prompt: 'visible control', + agentType: 'claude' + }) + expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1) + mockStoreState.setAgentStatus.mockClear() + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: remotePtyId, + seq: hidden.length, + facts: [ + { + kind: 'agent-status', + payload: { state: 'working', prompt: 'paired task', agentType: 'claude' } + }, + { kind: 'title', normalizedTitle: 'remote working', rawTitle: 'remote working' }, + { kind: '2031-subscribe' } + ] + }) + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'remote working') + expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1) + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1), + { state: 'working', prompt: 'paired task', agentType: 'claude' }, + undefined, + undefined, + { connectionId: null } + ) + expect(transport.sendInput).toHaveBeenCalledWith(expect.stringContaining('\x1b[')) + expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, expect.any(String)) + deps.paneMode2031Ref.current.set(1, true) + deps.paneLastThemeModeRef.current.set(1, 'dark') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: remotePtyId, + seq: hidden.length + 1, + facts: [{ kind: '2031-unsubscribe' }] + }) + expect(deps.paneMode2031Ref.current.has(1)).toBe(false) + + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + await flushAsyncTicks(20) + + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(transport.setOutputPaused.mock.invocationCallOrder.at(-1)).toBeLessThan( + transport.serializeBuffer.mock.invocationCallOrder[0] + ) + callbacks.onData?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + const countWritten = (marker: string): number => written.split(marker).length - 1 + expect(countWritten(snapshot)).toBe(1) + expect(countWritten(live)).toBe(1) + expect(countWritten(hidden)).toBe(0) + + binding.dispose() + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) + deps.setRuntimePaneTitle.mockClear() + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: remotePtyId, + seq: hidden.length + live.length + 1, + facts: [{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }] + }) + expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() + }) + it('restores overflowed hidden remote runtime output from its serialized snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') @@ -23564,6 +23704,72 @@ describe('connectPanePty', () => { expect(transport.resize).not.toHaveBeenCalled() }) + it('claims a focused visible remote mirror once when its passive fit hold arrives', async () => { + let documentFocused = true + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => documentFocused) + } as unknown as Document + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { connectPanePty } = await import('./pty-connection') + const ptyId = 'remote:env-1@@terminal-visible' + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: ptyId }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const pane = createPane(2) + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => ({ cols: 132, rows: 42 })) + } as never + const binding = connectPanePty(pane as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + transport.claimViewport.mockClear() + + setFitOverride(ptyId, 'remote-desktop-fit', 10, 4) + + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + setFitOverride(ptyId, 'desktop-fit', 132, 42) + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + documentFocused = false + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + documentFocused = true + binding.reassertPtySizeAfterWindowWake() + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + binding.dispose() + }) + it('does NOT re-assert while a mobile-fit override parks the PTY at phone dims', async () => { const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 188fdf11c55..f4a4da3b9ac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -36,7 +36,7 @@ import { import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' -import type { PtyTransportRecoveryState } from './pty-transport-types' +import type { IpcPtyTransportOptions, PtyTransportRecoveryState } from './pty-transport-types' import { createIpcPtyTransport } from './pty-transport' import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation' @@ -69,7 +69,11 @@ import { type SafeFitContinuationHandle } from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' -import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' +import { + bindPanePtyId, + getFitOverrideForPty, + onOverrideChange +} from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' @@ -1094,6 +1098,11 @@ export function connectPanePty( // flips, exit, dispose) run before/after it exists, so start with no-ops. let syncHiddenRendererPtyDelivery: () => void = () => {} let releaseHiddenRendererPtyDelivery: () => void = () => {} + let handleRemoteOutputPauseChanged: (paused: boolean, supported: boolean) => void = () => {} + let handleRendererOwnedAgentStatus: NonNullable< + IpcPtyTransportOptions['onAgentStatus'] + > = () => {} + let remoteOutputPausedPtyId: string | null = null let suppressViewportClaimTerminalResize = false // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer @@ -2086,13 +2095,13 @@ export function connectPanePty( // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — // routing both through this handler keeps the drop/interrupt semantics // identical across authority modes. - const handleCommandFinished = (_bestEffortExitCode: number | null): void => { + const handleCommandFinished = (bestEffortExitCode: number | null): void => { clearCommandInferredPaneAgentAfterPtySideEffects() visibleForegroundSamplePending = false const shouldDeferStatusDrop = paneForegroundAgentTracker.onCommandFinished() // Why: the finished command may have moved HEAD or the index (e.g. // `git checkout`); nudge git UI now instead of waiting for a poll. - dispatchTerminalCommandFinishedEvent(deps.worktreeId) + dispatchTerminalCommandFinishedEvent(deps.worktreeId, bestEffortExitCode) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[cacheKey] const inferenceResult = flushPendingInterruptInference() @@ -2249,14 +2258,26 @@ export function connectPanePty( terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true }) } + let visibleRemoteViewportClaimPtyId: string | null = null + let pendingVisibleRemoteViewportClaim = false const setPanePtyFitBinding = (ptyId: string): void => { bindPanePtyId(pane.id, ptyId, deps.tabId) pane.container.dataset.ptyId = ptyId + if ( + deps.isVisibleRef.current && + isRemoteRuntimePtyId(ptyId) && + visibleRemoteViewportClaimPtyId !== ptyId + ) { + // Why: the initial fit event consumes this activation arm before later peer ownership changes. + visibleRemoteViewportClaimPtyId = ptyId + pendingVisibleRemoteViewportClaim = true + } // Why: override hydration can arrive before this pane knows its PTY. Once - // data-pty-id is bound, safeFit can park xterm at the held phone grid. + // data-pty-id is bound, safeFit can park xterm at the authoritative grid. if (getFitOverrideForPty(ptyId)) { safeFit(pane) } + claimPendingVisibleRemoteViewport() } let activePanePtyBinding: string | null = null // Why: bind time lets async liveness reconcile ignore a request started @@ -2269,8 +2290,11 @@ export function connectPanePty( // restoreTitleOnRegister replaces the eager-replay title restore: main's // title-only snapshot carries the no-attention-replay rule. let unregisterSideEffectFactConsumer: (() => void) | null = null - const registerSideEffectFactConsumerForPty = (ptyId: string): void => { - if (!mainSideEffectAuthority || disposed) { + const registerSideEffectFactConsumerForPty = ( + ptyId: string, + remoteOutputPaused = false + ): void => { + if ((!mainSideEffectAuthority && !remoteOutputPaused) || disposed) { return } unregisterSideEffectFactConsumer?.() @@ -2290,9 +2314,12 @@ export function connectPanePty( // renderer seeds also write), so main only emits scrape facts. onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, + ...(shouldOwnAgentStatusInRenderer + ? { onAgentStatus: (payload) => handleRendererOwnedAgentStatus(payload) } + : {}), // Why: gated hidden panes never see the subscribe bytes; the fact // replaces the byte scan (and the old post-latch subscribe drop). - ...(hiddenDeliveryGateActive + ...(hiddenDeliveryGateActive || remoteOutputPaused ? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact, onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact @@ -2310,6 +2337,8 @@ export function connectPanePty( // Why: fit bindings live in a module-level map, so pane teardown must // clear them explicitly instead of relying on DOM removal. bindPanePtyId(pane.id, null, deps.tabId) + visibleRemoteViewportClaimPtyId = null + pendingVisibleRemoteViewportClaim = false activePanePtyBinding = null activePanePtyBindingBoundAt = null delete pane.container.dataset.ptyId @@ -3535,6 +3564,53 @@ export function connectPanePty( }) : undefined const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null + handleRendererOwnedAgentStatus = (payload): void => { + if ( + shouldSuppressCodexAutoApprovalStatus(payload, { + paneKey: cacheKey, + tabId: deps.tabId, + ...(launchToken ? { launchToken } : {}) + }) + ) { + return + } + const currentState = useAppStore.getState() + const routing = resolveCurrentAgentStatusRouting() + if (!routing) { + return + } + const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const authoritativePaneAgent = getAuthoritativePaneAgent() + const agentType = resolveCompatibleAgentTypeForOwner(payload.agentType, authoritativePaneAgent) + const statusPayload = agentType === payload.agentType ? payload : { ...payload, agentType } + const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title) + const statusTitle = resolvedStatusTitle + ? normalizeCompatibleAgentTitleForOwner( + resolvedStatusTitle, + agentType ?? authoritativePaneAgent + ) + : resolvedStatusTitle + if (launchToken) { + currentState.setAgentStatus(cacheKey, statusPayload, statusTitle, undefined, routing, { + launchToken + }) + } else { + currentState.setAgentStatus(cacheKey, statusPayload, statusTitle, undefined, routing) + } + if (payload.state === 'working' && syncAgentTaskCompleteTrackingEnabled()) { + requiresFreshWorkingForAgentTaskCompleteNotification = false + } + const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] + const notificationPayload = + typeof storedStatus?.stateStartedAt === 'number' + ? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt } + : statusPayload + // Why: hook lifecycle owns deferred side effects even when alerts are disabled. + agentCompletionCoordinator.observeHookStatus(notificationPayload) + if (payload.state === 'working' && pendingTerminalBellNotification) { + scheduleTerminalBellNotification() + } + } // Why: when main holds side-effect authority for this PTY's bytes, the // transport must NOT register title/bell/agent byte parsers — the // pty:sideEffect fact consumer below is the single policy consumer. @@ -3681,75 +3757,7 @@ export function connectPanePty( // parses OSC 9999 before renderer delivery and forwards through the hook // server with local/SSH identity. Remote-runtime streams do not pass through // local main, so the renderer remains their status owner for now. - ...(shouldOwnAgentStatusInRenderer - ? { - onAgentStatus: (payload) => { - if ( - shouldSuppressCodexAutoApprovalStatus(payload, { - paneKey: cacheKey, - tabId: deps.tabId, - ...(launchToken ? { launchToken } : {}) - }) - ) { - return - } - // Why: capture the store snapshot once so the title lookup and the - // setAgentStatus call observe the same state. Re-reading getState() - // between the two lines opens a brief window where the title could - // shift (OSC title update landing in between) and the status would - // be stored against a title that was never paired with it. - const currentState = useAppStore.getState() - const routing = resolveCurrentAgentStatusRouting() - if (!routing) { - return - } - const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const authoritativePaneAgent = getAuthoritativePaneAgent() - const agentType = resolveCompatibleAgentTypeForOwner( - payload.agentType, - authoritativePaneAgent - ) - const statusPayload = - agentType === payload.agentType ? payload : { ...payload, agentType } - const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title) - const statusTitle = resolvedStatusTitle - ? normalizeCompatibleAgentTitleForOwner( - resolvedStatusTitle, - agentType ?? authoritativePaneAgent - ) - : resolvedStatusTitle - if (launchToken) { - currentState.setAgentStatus( - cacheKey, - statusPayload, - statusTitle, - undefined, - routing, - { - launchToken - } - ) - } else { - currentState.setAgentStatus(cacheKey, statusPayload, statusTitle, undefined, routing) - } - const trackingEnabled = syncAgentTaskCompleteTrackingEnabled() - if (payload.state === 'working' && trackingEnabled) { - requiresFreshWorkingForAgentTaskCompleteNotification = false - } - const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const notificationPayload = - typeof storedStatus?.stateStartedAt === 'number' - ? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt } - : statusPayload - // Why: hook lifecycle owns deferred terminal side effects even when - // every outward completion alert consumer is disabled. - agentCompletionCoordinator.observeHookStatus(notificationPayload) - if (payload.state === 'working' && pendingTerminalBellNotification) { - scheduleTerminalBellNotification() - } - } - } - : {}) + ...(shouldOwnAgentStatusInRenderer ? { onAgentStatus: handleRendererOwnedAgentStatus } : {}) } if (connectionOwnerHydrating) { // Why: this pane holds an inert transport until its host resolves; register it so @@ -3782,7 +3790,8 @@ export function connectPanePty( // skipped-byte scan are disabled for these panes (same structural // predicate), so exactly one reply goes out. const handleHiddenMode2031SubscribeFact = (): void => { - if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + const ptyId = transport.getPtyId() + if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputPausedPtyId !== ptyId)) { return } const mode = resolveTerminalColorSchemeMode( @@ -3806,7 +3815,8 @@ export function connectPanePty( // into the shell that replaced it (#9993 via maybePushMode2031Flip). No reply is // sent: a withdrawal is not a query. const handleHiddenMode2031UnsubscribeFact = (): void => { - if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + const ptyId = transport.getPtyId() + if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputPausedPtyId !== ptyId)) { return } deps.paneMode2031Ref.current.delete(pane.id) @@ -3847,6 +3857,52 @@ export function connectPanePty( transport.claimViewport?.(cols, rows) } } + const claimPendingVisibleRemoteViewport = (): void => { + if ( + !pendingVisibleRemoteViewportClaim || + !deps.isVisibleRef.current || + typeof document === 'undefined' || + document.visibilityState === 'hidden' || + typeof document.hasFocus !== 'function' || + !document.hasFocus() + ) { + return + } + claimViewportForUserActivity() + } + const armVisibleRemoteViewportClaim = (): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + visibleRemoteViewportClaimPtyId = null + pendingVisibleRemoteViewportClaim = false + return + } + if ( + visibleRemoteViewportClaimPtyId !== ptyId || + pendingVisibleRemoteViewportClaim || + getFitOverrideForPty(ptyId)?.mode === 'remote-desktop-fit' + ) { + visibleRemoteViewportClaimPtyId = ptyId + pendingVisibleRemoteViewportClaim = true + } + } + const unsubscribeRemoteDesktopActivationClaim = onOverrideChange((event) => { + if (event.ptyId !== transport.getPtyId() || !isRemoteRuntimePtyId(event.ptyId)) { + return + } + if (event.mode === 'desktop-fit') { + visibleRemoteViewportClaimPtyId = event.ptyId + pendingVisibleRemoteViewportClaim = false + return + } + if (event.mode === 'remote-desktop-fit') { + if (deps.isVisibleRef.current && visibleRemoteViewportClaimPtyId !== event.ptyId) { + visibleRemoteViewportClaimPtyId = event.ptyId + pendingVisibleRemoteViewportClaim = true + } + claimPendingVisibleRemoteViewport() + } + }) // Why: an unbound transport (detached during a remount/move and never // rebound) silently rejects every keystroke while the PTY stays alive and @@ -5656,6 +5712,11 @@ export function connectPanePty( pane.container.dataset.ptyRecoveryState = state.phase deps.onPtyRecoveryStateRef?.current?.(pane.id, state) } + }, + onOutputPauseChanged: (paused: boolean, supported: boolean): void => { + if (isCurrent()) { + handleRemoteOutputPauseChanged(paused, supported) + } } } } @@ -5911,9 +5972,45 @@ export function connectPanePty( ) } + handleRemoteOutputPauseChanged = (paused, supported): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + return + } + if (!supported || !paused) { + if (remoteOutputPausedPtyId === ptyId) { + remoteOutputPausedPtyId = null + if (!mainSideEffectAuthority) { + dropSideEffectFactConsumer() + } + } + if (supported && !paused && hiddenOutputRestorePtyId === ptyId) { + requestHiddenOutputRestoreIfNeeded() + } + return + } + if (remoteOutputPausedPtyId !== ptyId) { + remoteOutputPausedPtyId = ptyId + registerSideEffectFactConsumerForPty(ptyId, true) + } + markHiddenOutputRestoreNeeded() + } + syncHiddenRendererPtyDelivery = (): void => { const ptyId = transport.getPtyId() syncModelRestoreNeededSubscription(ptyId) + if (remoteOutputPausedPtyId !== null && remoteOutputPausedPtyId !== ptyId) { + remoteOutputPausedPtyId = null + if (!mainSideEffectAuthority) { + dropSideEffectFactConsumer() + } + } + if (isRemoteRuntimePtyId(ptyId) && canUseHiddenOutputSnapshot(ptyId)) { + transport.setOutputPaused?.( + !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) + ) + return + } if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) { releaseHiddenDeliveryClaim?.() releaseHiddenDeliveryClaim = null @@ -5938,6 +6035,13 @@ export function connectPanePty( } } releaseHiddenRendererPtyDelivery = (): void => { + transport.setOutputPaused?.(false) + if (remoteOutputPausedPtyId !== null) { + remoteOutputPausedPtyId = null + if (!mainSideEffectAuthority) { + dropSideEffectFactConsumer() + } + } releaseHiddenDeliveryClaim?.() releaseHiddenDeliveryClaim = null hiddenDeliverySyncedPtyId = null @@ -6228,10 +6332,11 @@ export function connectPanePty( } function shouldSkipHiddenRendererOutput(foreground: boolean, data: string): boolean { + const ptyId = transport.getPtyId() if ( foreground || - !shouldSnapshotHiddenCodexOutput || - !canUseHiddenOutputSnapshot(transport.getPtyId()) + (!shouldSnapshotHiddenCodexOutput && remoteOutputPausedPtyId !== ptyId) || + !canUseHiddenOutputSnapshot(ptyId) ) { return false } @@ -8726,15 +8831,22 @@ export function connectPanePty( agentCompletionCoordinator.startProcessTracking() // Why: the hidden-delivery gate must follow every pane visibility flip. syncHiddenRendererPtyDelivery() + if (!deps.isVisibleRef.current) { + pendingVisibleRemoteViewportClaim = false + } }, // Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback. noteVisibilityResume() { + armVisibleRemoteViewportClaim() + claimPendingVisibleRemoteViewport() ptySizeReassertion.request({ fit: false }) consumeHibernatedAgentWake() requestKnownDroidReconfirmation() sampleVisiblePaneForegroundAgent() }, reassertPtySizeAfterWindowWake() { + armVisibleRemoteViewportClaim() + claimPendingVisibleRemoteViewport() ptySizeReassertion.request({ fit: false }) }, // Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads. @@ -8813,6 +8925,7 @@ export function connectPanePty( // Why: park/reconnect/remount doesn't advance the recovery epoch, so invalidate this xterm or its delayed retry could hit the next instance. terminalRecoveryInstance.unregister() unregisterUndeliverableWriteHandler() + unsubscribeRemoteDesktopActivationClaim() cancelHiddenOutputSnapshotScrollRestore() structuralReplayCoordinator.dispose() cancelFreshSpawnFollowReset() diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index ef7d9974464..f56949a2c16 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -84,6 +84,7 @@ type PtyCallbacks = { onExit?: (code: number) => void onWriteUnavailable?: () => void onRecoveryStateChange?: (state: PtyTransportRecoveryState) => void + onOutputPauseChanged?: (paused: boolean, supported: boolean) => void } export type PtyTransportRecoveryState = { @@ -141,6 +142,8 @@ export type PtyTransport = { sendInputImmediate: (data: string) => boolean sendInputAccepted?: (data: string) => Promise claimViewport?: (cols: number, rows: number) => boolean + /** Capability-negotiated paired-runtime delivery gate; false preserves legacy delivery. */ + setOutputPaused?: (paused: boolean) => boolean resize: ( cols: number, rows: number, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 1a7ca3d7170..9384890b4ea 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -226,7 +226,8 @@ describe('createRemoteRuntimePtyTransport', () => { expect(latestSubscribePayload().capabilities).toEqual({ ackOutput: 1, ackOutputSourceRanges: 1, - desktopViewportClaims: 1 + desktopViewportClaims: 1, + outputPause: 1 }) ) expect(runtimeSubscribe).toHaveBeenCalledWith( @@ -701,6 +702,358 @@ describe('createRemoteRuntimePtyTransport', () => { ) }) + it('retries initial web mirror inventory after a transient runtime close', async () => { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const recoveryPhases: string[] = [] + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + cols: 100, + rows: 30, + callbacks: { + onError, + onRecoveryStateChange: (state) => recoveryPhases.push(state.phase) + } + }) + + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + expect(activateAttempts).toBe(2) + expect(onError).not.toHaveBeenCalled() + expect(recoveryPhases).toContain('backoff') + expect(latestSubscribePayload().terminal).toBe('terminal-1') + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.create')).toBe( + false + ) + }) + + it('keeps web mirror inventory and subscription failures inside one recovery budget', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + if (request.method === 'session.tabs.list') { + return healthyRuntimeCall?.({ + method: 'session.tabs.activate', + params: { tabId: 'host-tab-1', leafId: 'pane:1' } + }) + } + return healthyRuntimeCall?.(request) + }) + runtimeSubscribe.mockRejectedValue( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(60_000) + + const attemptsAtCutoff = runtimeSubscribe.mock.calls.length + expect(attemptsAtCutoff).toBe(8) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(runtimeSubscribe).toHaveBeenCalledTimes(attemptsAtCutoff) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ends web mirror recovery when a retry returns a fatal inventory error', async () => { + vi.useFakeTimers() + try { + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string }) => { + if (request.method !== 'session.tabs.activate') { + throw new Error(`Unexpected method ${request.method}`) + } + activateAttempts += 1 + if (activateAttempts === 1) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + throw Object.assign(new Error('Remote runtime pairing credentials expired.'), { + code: 'unauthorized' + }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: { onError } + }) + await vi.advanceTimersByTimeAsync(250) + + expect(onError).toHaveBeenCalledWith('Remote runtime pairing credentials expired.') + expect(transport.getRecoveryState?.().phase).toBe('offline') + const attemptsAfterFatalError = activateAttempts + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(activateAttempts).toBe(attemptsAfterFatalError) + expect(transport.getRecoveryState?.().phase).toBe('offline') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not restart web mirror recovery when an in-flight request rejects after cutoff', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let rejectInFlight: (error: Error) => void = () => {} + let activateAttempts = 0 + runtimeCall.mockImplementation((request: { method: string }) => { + if (request.method !== 'session.tabs.activate') { + throw new Error(`Unexpected method ${request.method}`) + } + activateAttempts += 1 + if (activateAttempts === 1) { + return Promise.reject( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + } + return new Promise((_, reject) => { + rejectInFlight = reject + }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(250) + expect(activateAttempts).toBe(2) + + await vi.advanceTimersByTimeAsync(60_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + rejectInFlight( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + await vi.advanceTimersByTimeAsync(5 * 60_000) + + expect(activateAttempts).toBe(2) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + runtimeCall.mockImplementation(healthyRuntimeCall!) + expect(transport.retryRecovery?.()).toBe(true) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + expect(latestSubscribePayload().terminal).toBe('terminal-1') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not restart web mirror recovery when subscription rejects after cutoff', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + return healthyRuntimeCall?.(request) + }) + let rejectSubscription: (error: Error) => void = () => {} + runtimeSubscribe.mockImplementation( + () => + new Promise((_, reject) => { + rejectSubscription = reject + }) + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(250) + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + rejectSubscription( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + await vi.advanceTimersByTimeAsync(0) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + await vi.advanceTimersByTimeAsync(5 * 60_000) + + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not subscribe after mirror metadata resolution crosses the recovery cutoff', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + let resolveMetadata: (value: unknown) => void = () => {} + runtimeCall.mockImplementation((request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + return Promise.reject( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + } + if (request.method === 'terminal.resolvePane') { + return new Promise((resolve) => { + resolveMetadata = resolve + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(250) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.resolvePane' }) + ) + + await vi.advanceTimersByTimeAsync(60_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + resolveMetadata({ + ok: true, + result: { + terminal: { + handle: 'terminal-1', + tabId: 'host-tab-1', + leafId: 'pane:1', + worktreeId: 'wt-1' + } + } + }) + await vi.advanceTimersByTimeAsync(0) + + expect(runtimeSubscribe).not.toHaveBeenCalled() + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ignores stale web mirror inventory failure after a newer connect lifecycle', async () => { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let rejectStaleInventory: (error: Error) => void = () => {} + let activateAttempts = 0 + runtimeCall.mockImplementation((request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + return new Promise((_, reject) => { + rejectStaleInventory = reject + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const staleOnError = vi.fn() + const currentOnError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: { onError: staleOnError } + }) + await vi.waitFor(() => expect(activateAttempts).toBe(1)) + await transport.connect({ url: '', callbacks: { onError: currentOnError } }) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + + rejectStaleInventory( + Object.assign(new Error('Remote runtime pairing credentials expired.'), { + code: 'unauthorized' + }) + ) + for (let index = 0; index < 20; index += 1) { + await Promise.resolve() + } + + expect(staleOnError).not.toHaveBeenCalled() + expect(currentOnError).not.toHaveBeenCalled() + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + transport.destroy?.() + }) + it('resolves a HUB-native SSH PTY wake hint to its runtime terminal handle', async () => { const leafId = '11111111-1111-4111-8111-111111111111' runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { @@ -3171,6 +3524,80 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) }) + it('reapplies negotiated output pause across reconnect and resumes exact snapshot plus live data', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onData = vi.fn() + const onOutputPauseChanged = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ + url: '', + callbacks: { onData, onReplayData, onOutputPauseChanged } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(transport.setOutputPaused?.(true)).toBe(false) + const firstStreamId = latestSubscribePayload().streamId + emitSnapshot(firstStreamId, 'INITIAL_SNAPSHOT') + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId: firstStreamId, + capabilities: { outputPause: 1 } + } + }) + await vi.waitFor(() => + expect( + decodeTerminalStreamJson<{ paused?: boolean }>( + latestFrameForOpcode(TerminalStreamOpcode.SetOutputPaused)!.payload + ) + ).toEqual({ paused: true }) + ) + + subscriptionCallbacks?.onClose?.() + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => + expect( + subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) + ).toHaveLength(2) + ) + const reconnectStreamId = latestSubscribePayload().streamId + emitSnapshot(reconnectStreamId, 'RECONNECT_SNAPSHOT') + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId: reconnectStreamId, + capabilities: { outputPause: 1 } + } + }) + await vi.waitFor(() => + expect( + subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SetOutputPaused) + .map((frame) => decodeTerminalStreamJson<{ paused?: boolean }>(frame!.payload)) + ).toEqual([{ paused: true }, { paused: true }]) + ) + + expect(transport.setOutputPaused?.(false)).toBe(true) + emitOutput(reconnectStreamId, 'LIVE_AFTER_RECONNECT') + expect(onReplayData.mock.calls.map((call) => call[0])).toEqual([ + 'INITIAL_SNAPSHOT', + 'RECONNECT_SNAPSHOT' + ]) + expect(onData.mock.calls.map((call) => call[0])).toEqual(['LIVE_AFTER_RECONNECT']) + expect(onOutputPauseChanged).toHaveBeenLastCalledWith(false, true) + transport.destroy?.() + }) + it('backs off before retrying a capacity-rejected terminal stream', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 7048e324e63..615761567ef 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -157,6 +157,7 @@ export function createRemoteRuntimePtyTransport( const runtimeEnvironmentPairingRevision = getRuntimeEnvironmentRevision(runtimeEnvironmentId) let multiplexedStream: RemoteRuntimeMultiplexedTerminal | null = null let multiplexedStreamHandle: string | null = null + let desiredOutputPaused = false let desiredViewport: { cols: number; rows: number } | null = null let storedCallbacks: Parameters[0]['callbacks'] = {} let resubscribeEpoch: number | null = null @@ -164,6 +165,7 @@ export function createRemoteRuntimePtyTransport( let resubscribeRequestedRequiresReplacement = false let recoveryRequiresReplacement = false let stopWaitingForPublishedHandle: (() => void) | null = null + let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null let attachGeneration = 0 let subscriptionGeneration = 0 @@ -213,6 +215,13 @@ export function createRemoteRuntimePtyTransport( subscriptionGeneration += 1 closeMultiplexedStream() } + if ( + recovery.currentPhase === 'disconnected' || + recovery.currentPhase === 'disposed' || + recovery.currentPhase === 'idle' + ) { + settleHostSessionAttachRetry?.(false) + } emitRecoveryState() }) let lastRecoveryStateKey = '' @@ -228,6 +237,7 @@ export function createRemoteRuntimePtyTransport( let agentSessionRequiresHostAuthorityReplay = false let terminalCreateUnknownOutcomeError: unknown = null let lastConnectOptions: Parameters[0] | null = null + let lastAttachOptions: Parameters[0] | null = null let resolvePaneUnavailable = false let recoveringPaneHandle: string | null = null const adoptExecutionMetadata = (terminal: { @@ -397,7 +407,8 @@ export function createRemoteRuntimePtyTransport( } async function waitForHostSessionHandle( - hostTabId: string + hostTabId: string, + isCurrent: () => boolean ): Promise { if (!worktreeId) { return undefined @@ -425,7 +436,7 @@ export function createRemoteRuntimePtyTransport( } const startedAt = Date.now() - while (!destroyed) { + while (isCurrent()) { const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt) if (remainingMs <= 0) { return undefined @@ -457,6 +468,63 @@ export function createRemoteRuntimePtyTransport( return undefined } + function waitForHostSessionAttachRetry(recoveryEpoch: number): Promise { + return new Promise((resolve) => { + let settled = false + const settle = (retry: boolean): void => { + if (settled) { + return + } + settled = true + if (settleHostSessionAttachRetry === settle) { + settleHostSessionAttachRetry = null + } + resolve(retry) + } + settleHostSessionAttachRetry?.(false) + settleHostSessionAttachRetry = settle + if (!recovery.schedule(recoveryEpoch, () => settle(true))) { + settle(false) + } + }) + } + + async function waitForHostSessionHandleWithRecovery( + hostTabId: string, + isCurrent: () => boolean + ): Promise { + let recoveryEpoch = recovery.isActive ? recovery.currentEpoch : undefined + while (isCurrent()) { + try { + const hostHandle = await waitForHostSessionHandle(hostTabId, isCurrent) + if (!isCurrent()) { + return undefined + } + if (recoveryEpoch !== undefined) { + if (!recovery.isCurrent(recoveryEpoch)) { + return undefined + } + } + return hostHandle + } catch (error) { + if ( + !isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) || + !isCurrent() + ) { + throw error + } + if (recoveryEpoch !== undefined && !recovery.isCurrent(recoveryEpoch)) { + return undefined + } + recoveryEpoch ??= recovery.begin() + if (!(await waitForHostSessionAttachRetry(recoveryEpoch)) || !isCurrent()) { + return undefined + } + } + } + return undefined + } + async function waitForResubscribeHostSessionHandle( hostTabId: string, previousHandle: string, @@ -523,29 +591,27 @@ export function createRemoteRuntimePtyTransport( async function attachHostSessionMirror( options: { cols?: number; rows?: number }, notifySpawn = true, - expectedAttachGeneration?: number + expectedAttachGeneration?: number, + expectedLifecycleEpoch?: number ): Promise { if (!tabId || !isWebTerminalSurfaceTabId(tabId)) { return undefined } + const isCurrent = (): boolean => + !destroyed && + (expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration) && + (expectedLifecycleEpoch === undefined || expectedLifecycleEpoch === lifecycleEpoch) const hostTabId = toHostSessionTabId(tabId) - const hostHandle = await waitForHostSessionHandle(hostTabId) - if (hostHandle === undefined || destroyed) { + const hostHandle = await waitForHostSessionHandleWithRecovery(hostTabId, isCurrent) + if (hostHandle === undefined || !isCurrent()) { return undefined } if (hostHandle === null) { storedCallbacks.onError?.('Remote terminal was closed.') return undefined } - if ( - !hostHandle || - destroyed || - (expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration) - ) { - if ( - !destroyed && - (expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration) - ) { + if (!hostHandle || !isCurrent()) { + if (isCurrent()) { storedCallbacks.onError?.('Remote terminal was closed.') } return undefined @@ -573,6 +639,9 @@ export function createRemoteRuntimePtyTransport( } } + if (!isCurrent() || recovery.currentPhase === 'disconnected') { + return undefined + } handle = hostHandle remotePtyId = toRemoteRuntimePtyId(hostHandle, currentRuntimeEnvironmentId) registerShutdownHandlers(remotePtyId) @@ -592,12 +661,7 @@ export function createRemoteRuntimePtyTransport( throw error } } - if ( - destroyed || - !connected || - !remotePtyId || - (expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration) - ) { + if (!connected || !remotePtyId || !isCurrent()) { return undefined } @@ -1216,6 +1280,9 @@ export function createRemoteRuntimePtyTransport( if (!isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error))) { return false } + if (recovery.currentPhase === 'disconnected') { + return true + } scheduleResubscribeAfterTransportClose() return true } @@ -1412,10 +1479,22 @@ export function createRemoteRuntimePtyTransport( }) } }, + onOutputPauseCapability: () => { + if (isCurrentSubscription()) { + storedCallbacks.onOutputPauseChanged?.( + desiredOutputPaused, + nextStream.setOutputPaused(desiredOutputPaused) + ) + } + }, onSubscribed: () => { if (!isCurrentSubscription()) { return } + storedCallbacks.onOutputPauseChanged?.( + desiredOutputPaused, + nextStream.setOutputPaused(desiredOutputPaused) + ) subscriptionAttached = true setAttachmentReady(true) connecting = false @@ -1548,6 +1627,7 @@ export function createRemoteRuntimePtyTransport( const connectLifecycleEpoch = ++lifecycleEpoch const createEnvironmentId = currentRuntimeEnvironmentId lastConnectOptions = options + lastAttachOptions = null storedCallbacks = options.callbacks recoveryRequiresReplacement = false terminalEnded = false @@ -1559,7 +1639,7 @@ export function createRemoteRuntimePtyTransport( try { if (isWebTerminalSurfaceTabId(tabId ?? '')) { - return await attachHostSessionMirror(options) + return await attachHostSessionMirror(options, true, undefined, connectLifecycleEpoch) } if (options.sessionId && !getRemoteRuntimeTerminalHandle(options.sessionId)) { @@ -1762,12 +1842,13 @@ export function createRemoteRuntimePtyTransport( }, attach(options) { - lifecycleEpoch += 1 + const attachLifecycleEpoch = ++lifecycleEpoch const generation = ++attachGeneration cancelTerminalCreateRetryWait() recovery.cancel() recoveryRequiresReplacement = false clearPublishedHandleWait() + lastAttachOptions = options storedCallbacks = options.callbacks terminalEnded = false connecting = true @@ -1798,7 +1879,7 @@ export function createRemoteRuntimePtyTransport( const persistedHandle = nextHandle void (async () => { if (isWebTerminalSurfaceTabId(tabId ?? '')) { - await attachHostSessionMirror(options, false, generation) + await attachHostSessionMirror(options, false, generation, attachLifecycleEpoch) return } if (!tabId || !leafId || !worktreeId) { @@ -1845,10 +1926,15 @@ export function createRemoteRuntimePtyTransport( } await adoptResolvedHostPane(resolved, options, false, generation) })().catch((error) => { - if (generation !== attachGeneration || destroyed) { + if ( + generation !== attachGeneration || + attachLifecycleEpoch !== lifecycleEpoch || + destroyed + ) { return } clearPendingViewportClaim() + recovery.cancel() handleRemoteTerminalError(error) }) }, @@ -1967,6 +2053,16 @@ export function createRemoteRuntimePtyTransport( return true }, + setOutputPaused(paused: boolean): boolean { + desiredOutputPaused = paused + if (!connected || !handle) { + return false + } + const supported = getCurrentMultiplexedStream(handle)?.setOutputPaused(paused) === true + storedCallbacks.onOutputPauseChanged?.(paused, supported) + return supported + }, + resize(cols: number, rows: number, meta): boolean { if (!connected || !handle) { return false @@ -1998,6 +2094,23 @@ export function createRemoteRuntimePtyTransport( getRecoveryState, retryRecovery() { + if ( + !destroyed && + !terminalEnded && + !connected && + isWebTerminalSurfaceTabId(tabId ?? '') && + recovery.currentPhase === 'disconnected' + ) { + recovery.cancel() + if (lastAttachOptions) { + transport.attach(lastAttachOptions) + return true + } + if (lastConnectOptions) { + void transport.connect(lastConnectOptions) + return true + } + } if ( !destroyed && !terminalEnded && diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts index ce3d14c9cf8..f194f71f171 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts @@ -6,6 +6,7 @@ import { TERMINAL_REMOTE_PASTE_OPERATION_TIMEOUT_MS } from './terminal-paste-limits' import { runTerminalPasteOperationWithTimeout } from './terminal-paste-operation-timeout' +import { runTerminalPtyInputTransaction } from './terminal-pty-input-transaction' import type { TerminalPasteExecutionReason, TerminalPasteExecutionResult, @@ -24,6 +25,15 @@ type ExecuteTerminalPastePlanArgs = { } export async function executeTerminalPastePlan( + plan: TerminalPastePlan, + args: ExecuteTerminalPastePlanArgs +): Promise { + return await runTerminalPtyInputTransaction(plan.target.ptyId, () => + executeTerminalPastePlanNow(plan, args) + ) +} + +async function executeTerminalPastePlanNow( plan: TerminalPastePlan, { pasteText, diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts new file mode 100644 index 00000000000..b36f0d06f65 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' + +import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' +import { + BRACKETED_PASTE_END, + BRACKETED_PASTE_START, + wrapTerminalBracketedPasteText +} from './terminal-bracketed-paste' +import { executeTerminalPastePlan, planTerminalPaste } from './terminal-paste-coordinator' +import { runTerminalPtyInputTransaction } from './terminal-pty-input-transaction' + +function chunkedWindowsPaste() { + return planTerminalPaste({ + text: 'user-line-1\nuser-line-2', + source: 'keyboard', + target: { + kind: 'terminal', + paneId: 1, + leafId: 'leaf-1', + ptyId: 'pty-1', + runtime: { + platform: 'win32', + runtimeKey: 'local:win32', + kind: 'local', + isWindowsConpty: true + } + }, + terminalBracketedPasteMode: true, + maxDirectBytes: 4, + maxChunkBytes: 12 + }) +} + +describe('terminal paste operation ordering', () => { + it('starts an uncontended paste operation synchronously', async () => { + let started = false + const operation = runTerminalPtyInputTransaction('pty-1', async () => { + started = true + }) + + expect(started).toBe(true) + await operation + }) + + it('keeps startup context outside an active chunked user paste frame', async () => { + const writes: string[] = [] + let startupDraft: Promise | null = null + const writePty = async (data: string): Promise => { + writes.push(data) + return true + } + + const userPasteResult = await executeTerminalPastePlan(chunkedWindowsPaste(), { + pasteText: () => {}, + writePty, + yieldToEventLoop: async () => { + startupDraft ??= sendAgentDraftPasteContent(null, 'pty-1', 'GENERATED_CONTEXT', writePty) + } + }) + await startupDraft + + expect(userPasteResult.status).toBe('pasted') + expect(writes).toEqual([ + BRACKETED_PASTE_START, + 'user-line-1\r', + 'user-line-2', + BRACKETED_PASTE_END, + wrapTerminalBracketedPasteText('GENERATED_CONTEXT') + ]) + }) + + it('waits for an active startup frame before opening a user paste frame', async () => { + const writes: { owner: 'startup' | 'user'; data: string }[] = [] + let releaseStartup!: () => void + let startupOpened!: () => void + const opened = new Promise((resolve) => { + startupOpened = resolve + }) + const startup = sendAgentDraftPasteContent( + null, + 'pty-1', + 'G'.repeat(64 * 1024 + 1), + async (data) => { + writes.push({ owner: 'startup', data }) + if (data === BRACKETED_PASTE_START) { + startupOpened() + await new Promise((resolve) => { + releaseStartup = resolve + }) + } + return true + } + ) + await opened + + const user = executeTerminalPastePlan(chunkedWindowsPaste(), { + pasteText: () => {}, + writePty: async (data) => { + writes.push({ owner: 'user', data }) + return true + }, + yieldToEventLoop: async () => {} + }) + await Promise.resolve() + expect(writes).toEqual([{ owner: 'startup', data: BRACKETED_PASTE_START }]) + + releaseStartup() + await expect(Promise.all([startup, user])).resolves.toEqual([ + true, + expect.objectContaining({ status: 'pasted' }) + ]) + const firstUserWrite = writes.findIndex(({ owner }) => owner === 'user') + expect(firstUserWrite).toBeGreaterThan(0) + expect(writes.slice(0, firstUserWrite).every(({ owner }) => owner === 'startup')).toBe(true) + expect(writes.slice(firstUserWrite).every(({ owner }) => owner === 'user')).toBe(true) + expect(writes[firstUserWrite]?.data).toBe(BRACKETED_PASTE_START) + expect(writes.at(-1)?.data).toBe(BRACKETED_PASTE_END) + }) + + it('does not block paste operations for another PTY', async () => { + let releaseFirst!: () => void + let firstStarted!: () => void + const started = new Promise((resolve) => { + firstStarted = resolve + }) + const first = runTerminalPtyInputTransaction('pty-1', async () => { + firstStarted() + await new Promise((resolve) => { + releaseFirst = resolve + }) + }) + await started + + await expect( + runTerminalPtyInputTransaction('pty-2', async () => 'second-complete') + ).resolves.toBe('second-complete') + releaseFirst() + await first + }) + + it('releases a PTY after a failed paste operation', async () => { + await expect( + runTerminalPtyInputTransaction('pty-1', async () => { + throw new Error('write failed') + }) + ).rejects.toThrow('write failed') + + await expect(runTerminalPtyInputTransaction('pty-1', async () => 'recovered')).resolves.toBe( + 'recovered' + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts b/src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts new file mode 100644 index 00000000000..e6c0239751b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts @@ -0,0 +1,29 @@ +const transactionTails = new Map>() + +export async function runTerminalPtyInputTransaction( + ptyId: string | null | undefined, + operation: () => Promise +): Promise { + if (!ptyId) { + return await operation() + } + + const previous = transactionTails.get(ptyId) + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + transactionTails.set(ptyId, current) + + if (previous) { + await previous + } + try { + return await operation() + } finally { + release() + if (transactionTails.get(ptyId) === current) { + transactionTails.delete(ptyId) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index b73f690e5f4..e05137ffb64 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -152,10 +152,20 @@ describe('registerTerminalSideEffectFactConsumer', () => { it('routes live facts to the registered consumer in batch order', () => { const { callbacks, events } = createCallbackRecorder() - registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + ...callbacks, + onAgentStatus: (payload) => events.push(['status', payload.state, payload.agentType]) + } + }) _dispatchTerminalSideEffectBatchForTest( batch([ + { + kind: 'agent-status', + payload: { state: 'working', prompt: 'fix it', agentType: 'codex' } + }, { kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' }, { kind: 'agent-working' }, { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, @@ -165,6 +175,7 @@ describe('registerTerminalSideEffectFactConsumer', () => { ) expect(events).toEqual([ + ['status', 'working', 'codex'], ['title', '⠋ Claude', '⠋ Claude'], ['working'], ['title', '✳ Claude', '✳ Claude'], diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index e7f4a54ecd3..546af83987b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -13,6 +13,7 @@ * is a PTY whose consumer just unregistered: see the handoff buffer below. */ import type { GlobalSettings } from '../../../../shared/types' +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { TerminalSideEffectBatch, @@ -63,6 +64,7 @@ export function isMainTerminalSideEffectAuthorityForPty(args: { } export type TerminalSideEffectFactConsumerCallbacks = { + onAgentStatus?: (payload: ParsedAgentStatusPayload) => void /** `meta.staleWorkingTitleClear` marks facts derived from main's 3s * stale-title timer — policy must clear title/cache state without * scheduling task-complete notifications or unread attention. */ @@ -104,6 +106,9 @@ let channelUnsubscribe: (() => void) | null = null function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void { switch (fact.kind) { + case 'agent-status': + entry.callbacks.onAgentStatus?.(fact.payload) + return case 'title': entry.lastLiveTitleSeq = seq entry.callbacks.onTitleChange?.( diff --git a/src/renderer/src/components/worktree-base-fallback-notice.ts b/src/renderer/src/components/worktree-base-fallback-notice.ts new file mode 100644 index 00000000000..fc314732b4a --- /dev/null +++ b/src/renderer/src/components/worktree-base-fallback-notice.ts @@ -0,0 +1,35 @@ +import type { WorktreeCreateBaseFallback } from '../../../shared/types' + +const notices: WorktreeCreateBaseFallback[] = [] +const listeners = new Set<() => void>() + +function notifyListeners(): void { + for (const listener of listeners) { + listener() + } +} + +export function requestWorktreeBaseFallbackNotice(notice: WorktreeCreateBaseFallback): void { + notices.push(notice) + notifyListeners() +} + +export function dismissWorktreeBaseFallbackNotice(): void { + if (notices.shift()) { + notifyListeners() + } +} + +export function getWorktreeBaseFallbackNotice(): WorktreeCreateBaseFallback | null { + return notices[0] ?? null +} + +export function subscribeWorktreeBaseFallbackNotice(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function resetWorktreeBaseFallbackNoticesForTests(): void { + notices.length = 0 + notifyListeners() +} diff --git a/src/renderer/src/hooks/installed-agent-skills-change-event.ts b/src/renderer/src/hooks/installed-agent-skills-change-event.ts index 2e11a17ff03..3cee8c70dc1 100644 --- a/src/renderer/src/hooks/installed-agent-skills-change-event.ts +++ b/src/renderer/src/hooks/installed-agent-skills-change-event.ts @@ -1 +1,2 @@ export const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' +export const INSTALLED_AGENT_SKILLS_REFRESHED_EVENT = 'orca:installed-agent-skills-refreshed' diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 5a304b41846..2bdd0e4b666 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -2,12 +2,16 @@ export const ORCA_TERMINAL_COMMAND_FINISHED_EVENT = 'orca:terminal-command-finis export type TerminalCommandFinishedEventDetail = { worktreeId: string + // OSC 133;D may omit the command's exit code. + exitCode: number | null } // Why: the OSC 133;D handler lives in a per-pane closure; a window event lets -// decoupled consumers (e.g. git status refresh) react to shell commands -// finishing without reaching into terminal internals. -export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { +// decoupled consumers react without reaching into terminal internals. +export function dispatchTerminalCommandFinishedEvent( + worktreeId: string, + exitCode: number | null +): void { // Why: unit tests and non-DOM renderer shims may expose only the preload API. if (typeof window.dispatchEvent !== 'function') { return @@ -15,7 +19,7 @@ export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { window.dispatchEvent( new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { - detail: { worktreeId } + detail: { worktreeId, exitCode } }) ) } diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts index 67b6d1ae8da..9c78fa81e0b 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import type { SkillDiscoveryTarget } from '../../../shared/skills' @@ -25,6 +25,16 @@ const EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME: ActiveProjectSkillRuntime = Object.fre installDisabledReason: null }) +// Why: on Windows the runtime resolution is rebuilt from scratch on every +// worktree-store change, so a same-runtime result still arrives with a fresh +// identity. Downstream skill discovery keys effects off `discoveryTarget`, so +// that churn would re-run a scan (and blink its loading state) per store update. +// Serializing the whole value (rather than picking fields) keeps the comparison +// honest if the resolution grows a field the runtime cache keys do not encode. +function activeProjectSkillRuntimeIdentity(runtime: ActiveProjectSkillRuntime): string { + return JSON.stringify(runtime) +} + export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { const runtimeState = useAppStore( useShallow((state) => ({ @@ -39,7 +49,7 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { const currentPlatform = getCurrentPlatform() const windowsCapabilities = useWindowsTerminalCapabilities(currentPlatform === 'win32') - return useMemo(() => { + const resolved = useMemo(() => { const projectRuntime = getLocalProjectExecutionRuntimeContext( runtimeState, undefined, @@ -75,6 +85,16 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { installDisabledReason: getProjectSkillInstallDisabledReason(projectRuntime) } }, [currentPlatform, runtimeState, windowsCapabilities]) + + // Content-equal runtimes keep one reference so effect keys do not thrash. + // Adjust during render (not a ref write) when serialized identity changes. + const [stable, setStable] = useState(resolved) + const stableIdentity = activeProjectSkillRuntimeIdentity(stable) + const resolvedIdentity = activeProjectSkillRuntimeIdentity(resolved) + if (stableIdentity !== resolvedIdentity) { + setStable(resolved) + } + return stableIdentity === resolvedIdentity ? stable : resolved } function getCurrentPlatform(): NodeJS.Platform { diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx index 008aef245d3..f6b446964ac 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx +++ b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx @@ -18,6 +18,7 @@ import { type InstalledAgentSkillState, _installedAgentSkillDiscoveryInternalsForTests, notifyInstalledAgentSkillsChanged, + notifyInstalledAgentSkillsRefreshed, useInstalledAgentSkillNames } from './useInstalledAgentSkills' @@ -288,6 +289,136 @@ describe('useInstalledAgentSkill', () => { }) }) + it('stays settled through a focus rescan so status surfaces do not flash', async () => { + const firstScan = deferred() + const focusScan = deferred() + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockReturnValueOnce(firstScan.promise) + .mockReturnValueOnce(focusScan.promise) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbe() + expect(latestState?.settled).toBe(false) + + firstScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await firstScan.promise + }) + expect(latestState?.settled).toBe(true) + expect(latestState?.installed).toBe(true) + + await act(async () => { + window.dispatchEvent(new Event('focus')) + }) + + // The forced rescan is in flight, but the previous result is still current. + expect(latestState?.loading).toBe(true) + expect(latestState?.settled).toBe(true) + expect(latestState?.installed).toBe(true) + + focusScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await focusScan.promise + }) + expect(latestState?.settled).toBe(true) + }) + + it('reuses cached discovery when another surface finishes re-checking', async () => { + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockResolvedValue(discoveryResult([skill({ name: 'orca-linear' })])) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbe() + await act(async () => { + await Promise.resolve() + }) + expect(discover).toHaveBeenCalledTimes(1) + + await act(async () => { + notifyInstalledAgentSkillsRefreshed() + await Promise.resolve() + }) + + expect(discover).toHaveBeenCalledTimes(1) + expect(latestState?.installed).toBe(true) + }) + + it('clears loading when a silent refresh supersedes an in-flight focus rescan', async () => { + const firstScan = deferred() + const focusScan = deferred() + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockReturnValueOnce(firstScan.promise) + .mockReturnValueOnce(focusScan.promise) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbe() + firstScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await firstScan.promise + }) + + await act(async () => { + window.dispatchEvent(new Event('focus')) + }) + expect(latestState?.loading).toBe(true) + + // The silent refresh takes over the generation, so it owns the spinner too. + await act(async () => { + notifyInstalledAgentSkillsRefreshed() + await Promise.resolve() + }) + await flushMicrotasks() + expect(latestState?.loading).toBe(false) + + focusScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await focusScan.promise + }) + expect(latestState?.loading).toBe(false) + }) + + it('reports unsettled again when the discovery target changes', async () => { + const hostScan = deferred() + const wslScan = deferred() + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockReturnValueOnce(hostScan.promise) + .mockReturnValueOnce(wslScan.promise) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbe() + hostScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await hostScan.promise + }) + expect(latestState?.settled).toBe(true) + + await renderProbe({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(latestState?.settled).toBe(false) + + wslScan.resolve(discoveryResult([])) + await act(async () => { + await wslScan.promise + }) + expect(latestState?.settled).toBe(true) + expect(latestState?.installed).toBe(false) + }) + it('scans the connected remote runtime and keeps that result out of the local cache', async () => { const discover = vi .fn<(target?: SkillDiscoveryTarget) => Promise>() diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts index 28453f00489..df6ec1a1f4b 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts @@ -5,7 +5,8 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, _installedAgentSkillDiscoveryInternalsForTests, hasInstalledAgentSkill, - hasInstalledAgentSkillNamed + hasInstalledAgentSkillNamed, + notifyInstalledAgentSkillsRefreshed } from './useInstalledAgentSkills' afterEach(() => { @@ -223,6 +224,31 @@ describe('discoverInstalledAgentSkills', () => { await expect(forcedRefresh).resolves.toBe(freshResult) }) + it('lets completed-scan broadcasts reuse the cached result', async () => { + const discover = vi + .fn<() => Promise>() + .mockResolvedValue(discoveryResult([skill({ name: 'orca-linear' })])) + vi.stubGlobal('window', { + api: { skills: { discover } }, + dispatchEvent: vi.fn(), + CustomEvent: class {} + }) + + await _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(true) + notifyInstalledAgentSkillsRefreshed() + const fromSubscribers = [1, 2, 3].map(() => + _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false) + ) + + expect(discover).toHaveBeenCalledTimes(1) + for (const pending of fromSubscribers) { + await expect(pending).resolves.toMatchObject({ + skills: [expect.objectContaining({ name: 'orca-linear' })] + }) + } + expect(discover).toHaveBeenCalledTimes(1) + }) + it('caches host and WSL discovery results separately', async () => { const hostResult = discoveryResult([skill({ name: 'host-skill' })]) const wslResult = discoveryResult([skill({ name: 'wsl-skill' })]) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index 57e4b0cc7fe..482ea67386d 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -15,7 +15,10 @@ import { getSkillDiscoveryTargetKey, resetSkillDiscoveryCacheForTests } from './installed-agent-skill-discovery' -import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' +import { + INSTALLED_AGENT_SKILLS_CHANGED_EVENT, + INSTALLED_AGENT_SKILLS_REFRESHED_EVENT +} from './installed-agent-skills-change-event' import { useActiveSkillDiscoveryRuntimeTarget } from './use-active-skill-discovery-runtime-target' import { useMountedRef } from './useMountedRef' @@ -41,6 +44,9 @@ type InstalledAgentSkillMatchOptions = { export type InstalledAgentSkillState = { installed: boolean loading: boolean + // Why: a forced rescan keeps the previous result, so only the first scan per + // runtime-scoped target is genuinely unknown. + settled: boolean error: string | null skills: readonly DiscoveredSkill[] sources: readonly SkillDiscoverySource[] @@ -87,6 +93,12 @@ export function hasInstalledAgentSkillNamed( }) } +export function notifyInstalledAgentSkillsRefreshed(): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_REFRESHED_EVENT)) + } +} + export const _installedAgentSkillDiscoveryInternalsForTests = { discoverInstalledAgentSkills, getSkillDiscoveryTargetKey, @@ -159,7 +171,7 @@ export function useInstalledAgentSkillNames( } const refresh = useCallback( - async (force = true): Promise => { + async (force = true, showLoading = true): Promise => { const requestDiscoveryTargetKey = discoveryTargetKey const requestGeneration = ++refreshGenerationRef.current const writeIfCurrent = (write: () => void): void => { @@ -178,9 +190,11 @@ export function useInstalledAgentSkillNames( }) return false } - writeIfCurrent(() => { - setLoading(true) - }) + if (showLoading) { + writeIfCurrent(() => { + setLoading(true) + }) + } if (!runtimeTarget) { // Why: stay in the loading state rather than scanning the wrong host and // reporting "not installed" before the owning runtime is known. @@ -205,6 +219,9 @@ export function useInstalledAgentSkillNames( ) }) } finally { + // Why: a silent refresh can supersede an in-flight loading one, whose own + // clear is then dropped by the generation guard. Only the winning + // generation clears, so it must clear regardless of its own showLoading. writeIfCurrent(() => { setLoading(false) }) @@ -233,13 +250,18 @@ export function useInstalledAgentSkillNames( const refreshFromExternalChange = (): void => { void refresh(true) } + const refreshFromCompletedScan = (): void => { + void refresh(false, false) + } // Why: skill install commands run outside React state, often in a terminal. // Refresh on focus and explicit install events so completion is detected. window.addEventListener('focus', refreshFromExternalChange) window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refreshFromExternalChange) + window.addEventListener(INSTALLED_AGENT_SKILLS_REFRESHED_EVENT, refreshFromCompletedScan) return () => { window.removeEventListener('focus', refreshFromExternalChange) window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refreshFromExternalChange) + window.removeEventListener(INSTALLED_AGENT_SKILLS_REFRESHED_EVENT, refreshFromCompletedScan) } }, [enabled, refresh]) @@ -271,6 +293,7 @@ export function useInstalledAgentSkillNames( return { installed, loading: loadingForRender, + settled: enabled && resultForRender !== null, error: errorForRender, skills, sources, diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index df84c7ef0c7..15fdd70342f 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -197,7 +197,7 @@ export function buildSettingsNavigationMetadata({ title: translate('auto.hooks.useSettingsNavigationMetadata.linearTitle', 'Linear'), description: translate( 'auto.hooks.useSettingsNavigationMetadata.linearDescription', - 'Give agents the skill to read and update your linked Linear tickets.' + 'How Linear works in Orca, setup checklist, agent skill, and example prompts.' ), icon: LinearIcon, searchEntries: getLinearAgentSkillPaneSearchEntries(), @@ -325,8 +325,8 @@ export function buildSettingsNavigationMetadata({ id: 'tasks', title: translate('auto.hooks.useSettingsNavigationMetadata.85f4fd7710', 'Task Sources'), description: translate( - 'auto.hooks.useSettingsNavigationMetadata.5235c215ca', - 'Choose which task providers appear in the Tasks page and sidebar.' + 'auto.hooks.useSettingsNavigationMetadata.tasksDescription', + 'Connect providers, install the Linear skill, and choose what appears in Tasks.' ), icon: ListChecks, searchEntries: getTasksPaneSearchEntries(), diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts index bb8755760ac..1c48d6456ff 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { runVirtualizedScrollAnchorRestore } from './virtualized-scroll-anchor-restore' function createReactHookHarness() { const refs: { current: unknown }[] = [] @@ -171,3 +172,256 @@ describe('useVirtualizedScrollAnchor listener effect dependencies', () => { expect(anchorRef.current).toBe(savedAnchor) }) }) + +// Sidebar geometry from the reported jump: 12 rows x 100px in a 300px viewport, +// scrolled to 520 so the parent worktree ("p") sits 20px above the top edge. +type FakeRow = { key: string; size: number } +const VIEWPORT_HEIGHT = 300 +const ITEM_SELECTOR = '[data-worktree-virtual-row]' +const PARENT_KEY = 'wt:sec:p' +const GROUP_KEY = 'lineage-group:sec:lineage:p' + +function row(key: string, size = 100): FakeRow { + return { key, size } +} + +const LEADING_ROWS = ['r0', 'r1', 'r2', 'r3', 'r4'].map((id) => row(`wt:sec:${id}`)) +const TRAILING_ROWS = ['s0', 's1', 's2', 'r10', 'r11'].map((id) => row(`wt:sec:${id}`)) +// Before the child exists: parent and its (future) child are separate rows. +const UNFOLDED_ROWS = [...LEADING_ROWS, row(PARENT_KEY), row('wt:sec:c'), ...TRAILING_ROWS] +// After the child is created: both fold into one 200px lineage-group row that +// still starts at 500px, so nothing on screen actually moved. +const FOLDED_ROWS = [...LEADING_ROWS, row(GROUP_KEY, 200), ...TRAILING_ROWS] +// Parent genuinely deleted: the rows below slide up by 200px. +const DELETED_ROWS = [...LEADING_ROWS, ...TRAILING_ROWS] +// Last child deleted: the 200px group collapses back to a 100px card. +const DISSOLVED_ROWS = [...LEADING_ROWS, row(PARENT_KEY), ...TRAILING_ROWS] + +function createFakeScroller(rows: readonly FakeRow[], scrollTop: number) { + const starts: number[] = [] + let total = 0 + for (const item of rows) { + starts.push(total) + total += item.size + } + + const el = { + clientHeight: VIEWPORT_HEIGHT, + scrollHeight: total, + scrollTop, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + getBoundingClientRect: () => ({ top: 0, bottom: VIEWPORT_HEIGHT, height: VIEWPORT_HEIGHT }), + querySelectorAll: () => elements + } + const elements = rows.map((item, index) => ({ + isConnected: true, + rowKey: item.key, + getBoundingClientRect: () => { + const top = (starts[index] as number) - el.scrollTop + return { top, bottom: top + item.size, height: item.size } + } + })) + + return { + el, + elements, + rowIndexByKey: new Map(rows.map((item, index) => [item.key, index])), + virtualizer: { + getVirtualItems: () => + rows.map((item, index) => ({ + index, + size: item.size, + start: starts[index] as number, + end: (starts[index] as number) + item.size + })), + isScrolling: false, + scrollToIndex: vi.fn() + } + } +} + +function restore(args: { + anchor: { key: string; offset: number; fallbackKeys?: string[]; scrollTop?: number } + rekeyedRowKeys?: ReadonlyMap + rows: readonly FakeRow[] + scrollTop: number + useDom: boolean +}): number { + const scroller = createFakeScroller(args.rows, args.scrollTop) + runVirtualizedScrollAnchorRestore({ + anchor: args.anchor, + el: scroller.el, + ...(args.useDom + ? { + getItemElementKey: (element: { rowKey: string }) => element.rowKey, + itemElementSelector: ITEM_SELECTOR + } + : {}), + pendingRestoreRef: { current: false }, + recordScrollAnchor: vi.fn(), + ...(args.rekeyedRowKeys ? { rekeyedRowKeys: args.rekeyedRowKeys } : {}), + rowIndexByKey: scroller.rowIndexByKey, + scrollOffsetRef: { current: args.scrollTop }, + virtualizer: scroller.virtualizer + } as never) + return scroller.el.scrollTop +} + +// What WorktreeList's buildLineageRowRekeyMap produces for FOLDED_ROWS. +const FOLD_REKEYS: ReadonlyMap = new Map([ + [PARENT_KEY, GROUP_KEY], + ['wt:sec:c', GROUP_KEY] +]) + +const ANCHORED_ON_PARENT = { + key: PARENT_KEY, + offset: 20, + fallbackKeys: ['wt:sec:s0', 'wt:sec:s1', 'wt:sec:s2'], + scrollTop: 520 +} + +describe('runVirtualizedScrollAnchorRestore lineage re-keying', () => { + it.each([ + ['dom', true], + ['measured', false] + ])( + 'keeps the sidebar still (%s path) when the anchored parent folds into a lineage group', + (_label, useDom) => { + // The anchored row did not move — only its key changed from `wt:` to + // `lineage-group:`. Resolving a fallback here jumped the list +180px. + expect( + restore({ + anchor: { ...ANCHORED_ON_PARENT }, + rekeyedRowKeys: FOLD_REKEYS, + rows: FOLDED_ROWS, + scrollTop: 520, + useDom + }) + ).toBe(520) + } + ) + + it.each([ + ['dom', true], + ['measured', false] + ])( + 'still pins a genuine fallback at offset 0 (%s path) when no rekey map is supplied', + (_label, useDom) => { + // CombinedDiffViewer never passes a map; its behaviour must not change. + expect( + restore({ + anchor: { ...ANCHORED_ON_PARENT }, + rows: FOLDED_ROWS, + scrollTop: 520, + useDom + }) + ).toBe(700) + } + ) + + it('pins a fallback at offset 0 when the anchored row was genuinely deleted', () => { + // A rekey map is supplied but has no entry for the removed row, so the + // fallback path (different row, offset dropped) must still win. + expect( + restore({ + anchor: { ...ANCHORED_ON_PARENT }, + rekeyedRowKeys: new Map(), + rows: DELETED_ROWS, + scrollTop: 520, + useDom: true + }) + ).toBe(500) + }) + + it.each([ + ['dom', true], + ['measured', false] + ])( + 'clamps the followed offset to the shorter row (%s path) when a group dissolves', + (_label, useDom) => { + // Anchor recorded 150px into the 200px group row; the dissolved card is + // only 100px tall, so the offset clamps the way recording does. + expect( + restore({ + anchor: { key: GROUP_KEY, offset: 150, fallbackKeys: ['wt:sec:s0'], scrollTop: 650 }, + rekeyedRowKeys: new Map([[GROUP_KEY, PARENT_KEY]]), + rows: DISSOLVED_ROWS, + scrollTop: 650, + useDom + }) + ).toBe(600) + } + ) + + it('ignores a rekey target that is not rendered this pass', () => { + // Stale map entry pointing at an absent row must degrade to the fallback, + // not leave the anchor unresolved. + expect( + restore({ + anchor: { ...ANCHORED_ON_PARENT }, + rekeyedRowKeys: new Map([[PARENT_KEY, 'lineage-group:sec:lineage:gone']]), + rows: DELETED_ROWS, + scrollTop: 520, + useDom: true + }) + ).toBe(500) + }) +}) + +describe('useVirtualizedScrollAnchor with sidebar row re-keying', () => { + afterEach(() => { + vi.doUnmock('react') + vi.resetModules() + }) + + it('does not scroll when a child worktree folds the anchored parent into a group', async () => { + const harness = createReactHookHarness() + vi.doMock('react', () => harness.react) + const { useVirtualizedScrollAnchor, VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } = + await import('./useVirtualizedScrollAnchor') + + const anchorRef: { current: unknown } = { current: null } + const scrollOffsetRef = { current: 520 } + let scroller = createFakeScroller(UNFOLDED_ROWS, 520) + const scrollElementRef: { current: unknown } = { current: scroller.el } + const listeners = new Map void>() + scroller.el.addEventListener.mockImplementation((name: string, handler: () => void) => { + listeners.set(name, handler) + }) + + // Sidebar's real config: DOM-confirmed rows, no restoreSignal, no marks. + const render = (rows: readonly FakeRow[], rekeyedRowKeys?: ReadonlyMap) => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getItemElementKey: (element: { rowKey: string }) => element.rowKey, + getRowKey: (item: FakeRow) => item.key, + itemElementSelector: ITEM_SELECTOR, + rekeyedRowKeys, + rows, + scrollElementRef, + scrollOffsetRef, + totalSize: rows.reduce((sum, item) => sum + item.size, 0), + virtualizer: scroller.virtualizer + } as never) + } + + render(UNFOLDED_ROWS) + harness.effects[0]?.effect() + listeners.get(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)?.() + expect(anchorRef.current).toMatchObject({ key: PARENT_KEY, offset: 20, scrollTop: 520 }) + + // Child created: same pixels, new key for the parent's row. + scroller = createFakeScroller(FOLDED_ROWS, 520) + scrollElementRef.current = scroller.el + render(FOLDED_ROWS, FOLD_REKEYS) + harness.effects[1]?.effect() + + expect(scroller.el.scrollTop).toBe(520) + // The anchor re-records against the row's new key so later restores hit the + // exact-key path. + expect(anchorRef.current).toMatchObject({ key: GROUP_KEY, offset: 20 }) + }) +}) diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts index 56353fbee43..6594aedd0e3 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts @@ -46,6 +46,10 @@ type UseVirtualizedScrollAnchorOptions< // Why: some callers record user scroll anchors outside this hook; passive // programmatic scroll events during remount must not teach them a transient row. recordAnchorOnScroll?: boolean + // Why: old key -> new key for rows this render re-keyed without moving, so an + // anchor recorded under the old key follows its own row instead of pinning a + // neighbour. Omitted by callers whose row keys are stable. + rekeyedRowKeys?: ReadonlyMap // Why: when provided, anchor restore runs only when this value changes // (structural row changes) or while a prior restore is still converging — // not on every totalSize/isScrolling tick. Measurement-driven shifts are the @@ -80,6 +84,7 @@ export function useVirtualizedScrollAnchor< programmaticScrollMarks, recordAnchorOnCleanup = true, recordAnchorOnScroll = true, + rekeyedRowKeys, restoreSignal, rows, scrollElementRef, @@ -291,6 +296,7 @@ export function useVirtualizedScrollAnchor< pendingRestoreRef, programmaticScrollMarks: programmaticScrollMarksRef.current, recordScrollAnchor, + rekeyedRowKeys, rowIndexByKey, scrollOffsetRef, virtualizer @@ -300,6 +306,7 @@ export function useVirtualizedScrollAnchor< getItemElementKey, itemElementSelector, recordScrollAnchor, + rekeyedRowKeys, restoreSignal, rowIndexByKey, scrollElementRef, diff --git a/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts b/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts index 083e95acb13..a3cba0c8694 100644 --- a/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts +++ b/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts @@ -17,6 +17,9 @@ type RunVirtualizedScrollAnchorRestoreArgs< pendingRestoreRef: MutableRefObject programmaticScrollMarks?: ProgrammaticScrollMarks recordScrollAnchor: (scrollTop: number) => void + // Why: old key -> new key for rows that were re-keyed without moving. Lets an + // anchor follow its own row instead of resolving a fallback row below it. + rekeyedRowKeys?: ReadonlyMap rowIndexByKey: ReadonlyMap scrollOffsetRef: MutableRefObject virtualizer: Virtualizer @@ -40,13 +43,18 @@ export function runVirtualizedScrollAnchorRestore< pendingRestoreRef, programmaticScrollMarks, recordScrollAnchor, + rekeyedRowKeys, rowIndexByKey, scrollOffsetRef, virtualizer }: RunVirtualizedScrollAnchorRestoreArgs): (() => void) | undefined { - const resolvedKey = rowIndexByKey.has(anchor.key) + const hasExactKey = rowIndexByKey.has(anchor.key) + const rekeyed = hasExactKey ? undefined : rekeyedRowKeys?.get(anchor.key) + // Why: only follow a rekey that names a row present in this render. + const followedKey = rekeyed !== undefined && rowIndexByKey.has(rekeyed) ? rekeyed : undefined + const resolvedKey = hasExactKey ? anchor.key - : anchor.fallbackKeys?.find((key) => rowIndexByKey.has(key)) + : (followedKey ?? anchor.fallbackKeys?.find((key) => rowIndexByKey.has(key))) if (!resolvedKey) { // Why: an unresolvable anchor has nothing to converge; rows that make it // resolvable again always arrive via a signal change, so leaving the arm @@ -59,7 +67,16 @@ export function runVirtualizedScrollAnchorRestore< pendingRestoreRef.current = false return } - const offset = resolvedKey === anchor.key ? anchor.offset : 0 + // Why: a fallback key means the anchored row was REMOVED and the row below + // slid up — a different row, so the within-row offset does not transfer. A + // followed key is the SAME row under a new key, so it does. + const followedRow = resolvedKey === followedKey + const offset = hasExactKey || followedRow ? anchor.offset : 0 + // Why: the followed row can be a different height than the one the offset was + // recorded against (a lineage group dissolving back to a single card), so + // clamp it the same way recording does. + const offsetWithinRow = (rowHeight: number): number => + followedRow ? Math.min(rowHeight, offset) : offset const canConfirmFromDom = Boolean(itemElementSelector && getItemElementKey) const restoreFromDomElement = (): boolean => { @@ -75,7 +92,7 @@ export function runVirtualizedScrollAnchorRestore< } const scrollRect = el.getBoundingClientRect() const rect = element.getBoundingClientRect() - const desiredTop = scrollRect.top - offset + const desiredTop = scrollRect.top - offsetWithinRow(rect.height) const delta = rect.top - desiredTop if (Math.abs(delta) > 1) { // Why: this scroll write is still part of restore; keep the target @@ -99,7 +116,10 @@ export function runVirtualizedScrollAnchorRestore< return false } const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight) - const nextScrollTop = Math.min(maxScrollTop, Math.max(0, item.start + offset)) + const nextScrollTop = Math.min( + maxScrollTop, + Math.max(0, item.start + offsetWithinRow(item.size)) + ) if (Math.abs(el.scrollTop - nextScrollTop) > 1) { programmaticScrollMarks?.mark(nextScrollTop) el.scrollTop = nextScrollTop diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b74621aad7..b38e6915083 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -640,7 +640,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "Attach the review link", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -648,8 +648,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "Link the pull or merge request to the Linear issue when you open it.", + "attachPrPrompt": "Use {{value0}} to attach this pull or merge request to the linked Linear issue.", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -814,9 +814,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "{{value0}} hosts", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "How Linear works in Orca, setup checklist, agent skill, and example prompts.", "pluginsTitle": "Plugins", - "pluginsDescription": "Install and manage experimental Orca plugins." + "pluginsDescription": "Install and manage experimental Orca plugins.", + "tasksDescription": "Connect providers, install the Linear skill, and choose what appears in Tasks." }, "useAppMenuPaste": { "pasteTooLarge": "Paste is too large." @@ -5316,7 +5317,12 @@ "copyCommandAria": "Copy command", "runCommandDescription": "Press Enter to run the command.", "5f818f12ab": "Preparing...", - "4c05b9d7cb": "Preparing setup terminal." + "4c05b9d7cb": "Preparing setup terminal.", + "installLabel": "Install", + "updateLabel": "Update", + "setupCommandFailed": "The setup command exited with code {{value0}}. This error will clear after a successful retry.", + "setupFailed": "Setup failed", + "retrySetup": "Retry" }, "AgentsPane": { "d83834f5e6": "Detecting installed agents…", @@ -6908,7 +6914,8 @@ "dev": "Dev Tools", "devDescription": "Dev-only tools for exercising UI states.", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "How Linear works in Orca, setup checklist, agent skill, and example prompts.", + "tasksDescription": "Connect providers, install the Linear skill, and choose what appears in Tasks." }, "SettingsFormControls": { "42a4d15a30": "No matching fonts.", @@ -7137,18 +7144,34 @@ "53e9aabfc0": "Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions." }, "TasksPane": { - "3a72b9745e": "Choose which task providers appear in the Tasks page and sidebar shortcuts.", "f71d8a9dd3": "Task Providers", - "71644aba56": "Choose which task providers appear in the Tasks page source picker and sidebar shortcuts. At least one provider must stay visible.", - "93e72ef659": "Task Sources", - "8e1305fcc6": "Show Jira in the Tasks source picker and sidebar shortcuts.", "6b23a34f6d": "Jira", - "e4170c9615": "Show Linear in the Tasks source picker and sidebar shortcuts.", "09ae2d7c51": "Linear", - "dd67a1b6e1": "Show GitLab in the Tasks source picker and sidebar shortcuts.", "7c5d7fdc20": "GitLab", - "1db47236cd": "Show GitHub in the Tasks source picker and sidebar shortcuts.", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "Connect {{provider}}", + "connectCodeHostDescription": "Install and authenticate the CLI under Integrations so Orca can load issues.", + "connectionCheckUnavailable": "Orca couldn't check this connection. Try again, or open Integrations for setup details.", + "retryConnection": "Try again", + "openIntegrations": "Integrations", + "connectInIntegrations": "Set up in Integrations", + "connectJiraTitle": "Connect Jira", + "connectJiraDescription": "Add a Jira Cloud site or self-hosted instance with an API token or PAT.", + "manageJira": "Manage keys", + "addJira": "Add Jira access", + "githubDescription": "Browse GitHub issues and start workspaces from them.", + "gitlabDescription": "Browse GitLab issues and start workspaces from them.", + "linearDescription": "Connect Linear, install the agent skill, and show it in Tasks.", + "jiraDescription": "Connect Jira Cloud or self-hosted Jira and show it in Tasks.", + "setupTitle": "Task management setup", + "setupDescription": "Finish connect + visibility for each provider in one place. Linear also needs the agent skill so coding agents can read and update tickets. At least one provider must stay visible.", + "incompleteBannerTitle": "Some visible providers still need setup", + "incompleteBannerBody": "Hide providers you do not use, or expand a card and finish its steps.", + "providersDescription": "Each card walks through connection (and skill, for Linear) plus whether it appears in Tasks.", + "integrationsHint": "Credentials for all providers also live under", + "integrationsLink": "Integrations", + "skillHint": ". After Linear is connected, usage examples stay under Settings → Linear.", + "incompleteBannerBodyWithLinear": "Hide providers you do not use, or expand a card and finish its steps. For Linear: API access, the agent skill, and Show in Tasks." }, "TerminalAppearanceSection": { "a14a427ae4": "Thickness of the pane divider line.", @@ -8794,8 +8817,12 @@ "3d81c26d78": "source", "cf0e3e0c2f": "provider", "2ec54bee51": "tasks", - "765f0c544d": "Choose which task providers appear in the Tasks page and sidebar shortcuts.", - "5b8e4aace5": "Task Providers" + "5b8e4aace5": "Task Providers", + "providersDescription": "Connect task providers, install the Linear agent skill, and choose what appears in Tasks.", + "setup": "setup", + "apiKey": "api key", + "skill": "skill", + "connect": "connect" } }, "terminal": { @@ -9520,13 +9547,13 @@ "notInstalled": "Not installed", "recheck": "Re-check", "installedDescription": "Agent skill installed. To update it, run:", - "description": "Let your agents read and edit Linear tasks.", + "description": "Let agents read and edit Linear tasks. Full guided setup (connect + skill + visibility) is under Settings → Task Sources.", "copyCommand": "Copy command" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Linear skill status, usage examples, and links to Task Sources setup." } } } @@ -9616,15 +9643,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", + "description": "How Linear works in Orca: browse issues, start linked workspaces, and let agents update tickets with /orca-linear.", "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", + "howToUse": "Example prompts", + "howToUseDescription": "Click a card to copy a prompt. Use these in a Linear-linked worktree after the skill is installed.", "terminalTitle": "Linear skill setup", "terminalAriaLabel": "Linear skill install terminal", "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "manageConnectionLink": "Integrations" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9941,6 +9967,69 @@ "development": "development plugins" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. Connect Linear", + "setupConnectBody": "Personal API key so Orca can list issues and open linked workspaces.", + "manageKeys": "Manage keys", + "addAccess": "Add access", + "setupSkillTitle": "2. Install the agent skill", + "setupSkillBody": "Gives coding agents /orca-linear for reading, updates, triage, and attaching pull or merge requests.", + "setupVisibleTitle": "3. Show Linear in Tasks", + "setupVisibleBody": "Keeps Linear in the Tasks source picker and sidebar shortcuts.", + "openTaskSources": "Task Sources", + "setupTitle": "Setup checklist", + "setupBody": "All three are required for the full Tasks + agent loop. First-time path is also under Task Sources.", + "setupReady": "All set", + "setupProgress": "{{done}} of {{total}} ready", + "notesTitle": "Good to know", + "notesIntro": "Quick reminders once Linear is connected and the skill is installed.", + "noteLinkedTitle": "Start from a Linear issue", + "noteLinkedBody": "Ticket actions work best in a worktree created from Tasks so the issue stays linked as context.", + "noteSlashTitle": "Mention /orca-linear", + "noteSlashBody": "In chat, use /orca-linear (or ask in plain language) so the agent loads the skill for that turn.", + "noteKeysTitle": "Keys follow the runtime", + "noteKeysBody": "API keys and workspaces are stored for the active runtime.", + "noteVisibilityTitle": "Hiding ≠ disconnect", + "noteVisibilityBody": "Hiding Linear in Task Sources only removes it from the picker. It does not remove your key or skill.", + "setupChecking": "Checking…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "Connect Linear", + "connectDescription": "Add a Personal API key so Orca can browse issues and open workspaces with ticket context.", + "manageAccess": "Manage keys", + "addAccess": "Add Linear access", + "connectedHint": "Workspaces and keys are stored for the active runtime. You can add more access any time.", + "recheck": "Re-check connection", + "skillTitle": "Install Linear agent skill", + "skillDescription": "Gives agents /orca-linear to read tickets, post updates, move states, and attach pull or merge requests.", + "skillBlocked": "Connect Linear first, then install the skill for agents.", + "skillPanelTitle": "Linear skill", + "terminalTitle": "Linear skill setup", + "terminalAriaLabel": "Linear skill install terminal", + "showDescription": "Include Linear in the Tasks page source picker and sidebar shortcuts." + }, + "TaskSourceProviderCard": { + "statusChecking": "Checking…", + "statusReady": "Ready", + "statusConnectRequired": "Connect required", + "statusSkillRequired": "Skill required", + "statusUnavailable": "Status unavailable", + "statusHidden": "Hidden from Tasks", + "statusIncomplete": "Needs setup", + "collapseSetup": "Collapse {{provider}} setup steps", + "expandSetup": "Show {{provider}} setup steps" + }, + "TaskSourceShowInTasksStep": { + "shown": "Shown", + "show": "Show", + "hide": "Hide", + "hideProviderAction": "Hide {{provider}} from Tasks", + "showProviderAction": "Show {{provider}} in Tasks", + "lastProviderAction": "{{provider}} is shown in Tasks. At least one provider must stay visible.", + "lastProviderHint": "At least one provider must stay visible in Tasks.", + "title": "Show in Tasks", + "description": "Include this provider in the Tasks source picker and sidebar shortcuts." + }, "RuntimeHostAccessForm": { "getLink": "Get an access link from the other host", "stepOpenShare": "Open Settings → Remote Orca Servers → Share this host.", @@ -9986,6 +10075,23 @@ "setupRecipe": "Set up an environment recipe for your cloud provider.", "createWorkspace": "Create a workspace and select that recipe under Run on.", "openSetup": "Set up environment recipes" + }, + "ReleaseChannelSection": { + "switchFailed": "Could not switch to that build.", + "title": "Release channel", + "description": "Switch update channels or jump to any published build, including older ones. Downgrades are allowed and unvetted builds can be broken.", + "devOnly": "Dev only", + "channelAriaLabel": "Update channel", + "hourlyWarning": "Hourly builds are macOS-only, ship straight from main with no test gate, and are signed but not notarized. Keep a stable build handy.", + "loadingBuilds": "Loading builds…", + "noBuilds": "No builds found", + "refresh": "Refresh build list", + "switchTo": "Switch to build", + "alreadyRunning": "This is the build you are running.", + "willSwitch": "{{value0}} → {{value1}}", + "webUnavailable": "Switching builds is only available in the desktop app.", + "hourlyMacOnlyAria": "Hourly (macOS only)", + "hourlyMacOnly": "Hourly builds are produced only for macOS. Linux and Windows stay on Stable or RC." } }, "right": { @@ -14241,6 +14347,12 @@ }, "undo": "Undo", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "This page isn't ready for element selection yet.", + "errorNotAuthorized": "Browser access was denied.", + "errorAlreadyActive": "Element selection is already active.", + "errorInjectionFailed": "Could not start element selection on this page." } }, "pluginCatalog": { @@ -14257,6 +14369,11 @@ "useManualTerminalWorktreeParking": { "cannotPark": "These terminals cannot be parked safely." } + }, + "WorktreeBaseFallbackDialog": { + "title": "Workspace created from a local base", + "description": "The remote-tracking ref \"{{value0}}\" was unavailable, so Orca used local \"{{value1}}\" instead. This workspace may not include the latest remote changes.", + "dismiss": "Got it" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e29d1ebcee4..ae9d9dcff4a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -616,7 +616,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "Adjuntar el enlace de revisión", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -624,8 +624,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "Vincula la pull request o merge request con la incidencia de Linear al abrirla.", + "attachPrPrompt": "Usa {{value0}} para adjuntar esta pull request o merge request a la incidencia de Linear vinculada.", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -790,9 +790,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "{{value0}} hosts", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "Cómo funciona Linear en Orca, lista de configuración, habilidad del agente y ejemplos de prompts.", "pluginsTitle": "Plugins", - "pluginsDescription": "Instala y administra plugins experimentales de Orca." + "pluginsDescription": "Instala y administra plugins experimentales de Orca.", + "tasksDescription": "Conecta proveedores, instala la habilidad de Linear y elige qué aparece en Tareas." }, "useAppMenuPaste": { "pasteTooLarge": "El contenido pegado es demasiado grande." @@ -5288,7 +5289,9 @@ "copyCommandAria": "Copiar comando", "runCommandDescription": "Presiona Enter para ejecutar el comando.", "5f818f12ab": "Preparando...", - "4c05b9d7cb": "Preparando el terminal de configuración." + "4c05b9d7cb": "Preparando el terminal de configuración.", + "installLabel": "Instalar", + "updateLabel": "Actualizar" }, "AgentsPane": { "d83834f5e6": "Detectando agents instalados…", @@ -6839,7 +6842,8 @@ "dev": "Herramientas de desarrollo", "devDescription": "Herramientas solo de desarrollo para probar estados de la UI.", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "Cómo funciona Linear en Orca, lista de configuración, habilidad del agente y ejemplos de prompts.", + "tasksDescription": "Conecta proveedores, instala la habilidad de Linear y elige qué aparece en Tareas." }, "SettingsFormControls": { "42a4d15a30": "No hay fuentes coincidentes.", @@ -7068,18 +7072,34 @@ "53e9aabfc0": "Usa multiplexación de OpenSSH cuando está disponible. Desactívalo para hosts con restricciones SSH personalizadas." }, "TasksPane": { - "3a72b9745e": "Elige qué proveedores de tareas aparecen en la página Tareas y en los accesos directos de la barra lateral.", "f71d8a9dd3": "Proveedores de tareas", - "71644aba56": "Elige qué proveedores de tareas aparecen en el selector de fuentes de la página Tareas y en los accesos directos de la barra lateral. Al menos un proveedor debe permanecer visible.", - "93e72ef659": "Fuentes de tareas", - "8e1305fcc6": "Muestra Jira en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "6b23a34f6d": "Jira", - "e4170c9615": "Muestra Linear en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "09ae2d7c51": "Linear", - "dd67a1b6e1": "Muestra GitLab en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "7c5d7fdc20": "GitLab", - "1db47236cd": "Muestra GitHub en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "Conectar {{provider}}", + "connectCodeHostDescription": "Instala y autentica la CLI en Integraciones para que Orca pueda cargar incidencias.", + "connectionCheckUnavailable": "Orca no pudo comprobar esta conexión. Inténtalo de nuevo o abre Integraciones para ver los detalles de configuración.", + "retryConnection": "Intentar de nuevo", + "openIntegrations": "Integraciones", + "connectInIntegrations": "Configurar en Integraciones", + "connectJiraTitle": "Conectar Jira", + "connectJiraDescription": "Añade un sitio de Jira Cloud o una instancia autohospedada con un token de API o PAT.", + "manageJira": "Administrar claves", + "addJira": "Añadir acceso a Jira", + "githubDescription": "Explora incidencias de GitHub e inicia espacios de trabajo desde ellas.", + "gitlabDescription": "Explora incidencias de GitLab e inicia espacios de trabajo desde ellas.", + "linearDescription": "Conecta Linear, instala la habilidad del agente y muéstralo en Tareas.", + "jiraDescription": "Conecta Jira Cloud o Jira autohospedado y muéstralo en Tareas.", + "setupTitle": "Configuración de gestión de tareas", + "setupDescription": "Completa la conexión y la visibilidad de cada proveedor en un solo lugar. Linear también necesita la habilidad del agente para que los agentes puedan leer y actualizar incidencias. Al menos un proveedor debe permanecer visible.", + "incompleteBannerTitle": "Algunos proveedores visibles aún necesitan configuración", + "incompleteBannerBody": "Oculta los proveedores que no uses o expande una tarjeta y completa sus pasos.", + "providersDescription": "Cada tarjeta guía la conexión (y la habilidad, en Linear) y si aparece en Tareas.", + "integrationsHint": "Las credenciales de todos los proveedores también están en", + "integrationsLink": "Integraciones", + "skillHint": ". Después de conectar Linear, los ejemplos de uso están en Ajustes → Linear.", + "incompleteBannerBodyWithLinear": "Oculta los proveedores que no uses o expande una tarjeta y completa sus pasos. Para Linear: acceso a la API, la habilidad del agente y Mostrar en Tareas." }, "TerminalAppearanceSection": { "a14a427ae4": "Grosor de la línea divisoria del panel.", @@ -8724,8 +8744,12 @@ "3d81c26d78": "fuente", "cf0e3e0c2f": "proveedor", "2ec54bee51": "tareas", - "765f0c544d": "Elige qué proveedores de tareas aparecen en la página Tareas y en los accesos directos de la barra lateral.", - "5b8e4aace5": "Proveedores de tareas" + "5b8e4aace5": "Proveedores de tareas", + "providersDescription": "Conecta proveedores de tareas, instala la habilidad de agente de Linear y elige qué aparece en Tareas.", + "setup": "configuración", + "apiKey": "clave de API", + "skill": "habilidad", + "connect": "conectar" } }, "terminal": { @@ -9487,13 +9511,13 @@ "notInstalled": "No instalado", "recheck": "Reverificar", "installedDescription": "Habilidad del agente instalada. Para actualizarla, ejecuta:", - "description": "Permite que tus agentes lean y editen tareas de Linear.", + "description": "Permite que los agentes lean y editen tareas de Linear. La configuración guiada completa (conexión + habilidad + visibilidad) está en Ajustes → Fuentes de tareas.", "copyCommand": "Copiar comando" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Estado de la habilidad de Linear, ejemplos de uso y enlaces a la configuración de Fuentes de tareas." } } } @@ -9582,15 +9606,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", - "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", - "terminalTitle": "Linear skill setup", - "terminalAriaLabel": "Linear skill install terminal", - "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "description": "Cómo funciona Linear en Orca: explora incidencias, inicia espacios de trabajo vinculados y permite que los agentes actualicen incidencias con /orca-linear.", + "skillTitle": "Habilidad de Linear", + "howToUse": "Prompts de ejemplo", + "howToUseDescription": "Haz clic en una tarjeta para copiar un prompt. Úsalos en un espacio de trabajo vinculado a Linear después de instalar la habilidad.", + "terminalTitle": "Configuración de la habilidad de Linear", + "terminalAriaLabel": "Terminal de instalación de la habilidad de Linear", + "manageConnectionHint": "Revisa los espacios de trabajo de Linear conectados y las claves de API en", + "manageConnectionLink": "Integraciones" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9907,6 +9930,69 @@ "development": "plugins de desarrollo" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. Conectar Linear", + "setupConnectBody": "Una clave de API personal para que Orca pueda listar incidencias y abrir espacios de trabajo vinculados.", + "manageKeys": "Administrar claves", + "addAccess": "Añadir acceso", + "setupSkillTitle": "2. Instalar la habilidad del agente", + "setupSkillBody": "Proporciona /orca-linear a los agentes para leer, actualizar, clasificar y adjuntar solicitudes de cambio.", + "setupVisibleTitle": "3. Mostrar Linear en Tareas", + "setupVisibleBody": "Mantiene Linear en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", + "openTaskSources": "Fuentes de tareas", + "setupTitle": "Lista de configuración", + "setupBody": "Los tres pasos son necesarios para el flujo completo entre Tareas y agentes. La configuración inicial también está en Fuentes de tareas.", + "setupReady": "Todo listo", + "setupProgress": "{{done}} de {{total}} listos", + "notesTitle": "Conviene saberlo", + "notesIntro": "Recordatorios rápidos después de conectar Linear e instalar la habilidad.", + "noteLinkedTitle": "Comienza desde una incidencia de Linear", + "noteLinkedBody": "Las acciones de incidencias funcionan mejor en un espacio de trabajo creado desde Tareas, para mantener la incidencia vinculada como contexto.", + "noteSlashTitle": "Menciona /orca-linear", + "noteSlashBody": "En el chat, usa /orca-linear (o pide la acción en lenguaje natural) para que el agente cargue la habilidad durante ese turno.", + "noteKeysTitle": "Las claves siguen al entorno de ejecución", + "noteKeysBody": "Las claves de API y los espacios de trabajo se guardan para el entorno de ejecución activo.", + "noteVisibilityTitle": "Ocultar no desconecta", + "noteVisibilityBody": "Ocultar Linear en Fuentes de tareas solo lo quita del selector. No elimina la clave ni la habilidad.", + "setupChecking": "Comprobando…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "Conectar Linear", + "connectDescription": "Añade una clave de API personal para que Orca pueda explorar incidencias y abrir espacios de trabajo con su contexto.", + "manageAccess": "Administrar claves", + "addAccess": "Añadir acceso a Linear", + "connectedHint": "Los espacios de trabajo y las claves se guardan para el entorno de ejecución activo. Puedes añadir más accesos en cualquier momento.", + "recheck": "Volver a comprobar la conexión", + "skillTitle": "Instalar la habilidad de agente de Linear", + "skillDescription": "Proporciona /orca-linear a los agentes para leer incidencias, publicar actualizaciones, cambiar estados y adjuntar solicitudes de cambio.", + "skillBlocked": "Conecta Linear primero y luego instala la habilidad para los agentes.", + "skillPanelTitle": "Habilidad de Linear", + "terminalTitle": "Configuración de la habilidad de Linear", + "terminalAriaLabel": "Terminal de instalación de la habilidad de Linear", + "showDescription": "Incluye Linear en el selector de fuentes de Tareas y en los accesos directos de la barra lateral." + }, + "TaskSourceProviderCard": { + "statusChecking": "Comprobando…", + "statusReady": "Listo", + "statusConnectRequired": "Requiere conexión", + "statusSkillRequired": "Requiere la habilidad", + "statusUnavailable": "Estado no disponible", + "statusHidden": "Oculto en Tareas", + "statusIncomplete": "Necesita configuración", + "collapseSetup": "Contraer los pasos de configuración de {{provider}}", + "expandSetup": "Mostrar los pasos de configuración de {{provider}}" + }, + "TaskSourceShowInTasksStep": { + "shown": "Visible", + "show": "Mostrar", + "hide": "Ocultar", + "hideProviderAction": "Ocultar {{provider}} de Tareas", + "showProviderAction": "Mostrar {{provider}} en Tareas", + "lastProviderAction": "{{provider}} se muestra en Tareas. Al menos un proveedor debe permanecer visible.", + "lastProviderHint": "Al menos un proveedor debe permanecer visible en Tareas.", + "title": "Mostrar en Tareas", + "description": "Incluye este proveedor en el selector de fuentes de Tareas y en los accesos directos de la barra lateral." + }, "RuntimeHostAccessForm": { "getLink": "Obtener un enlace de acceso del otro host", "stepOpenShare": "Abre Ajustes → Servidores remotos de Orca → Compartir este host.", @@ -14181,6 +14267,12 @@ }, "undo": "Deshacer", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "Esta página aún no está lista para seleccionar elementos.", + "errorNotAuthorized": "Se denegó el acceso al navegador.", + "errorAlreadyActive": "La selección de elementos ya está activa.", + "errorInjectionFailed": "No se pudo iniciar la selección de elementos en esta página." } }, "pluginCatalog": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8def5244244..ac17b89b683 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -616,7 +616,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "レビューリンクを添付", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -624,8 +624,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "プルリクエストまたはマージリクエストを作成したら Linear の課題にリンクします。", + "attachPrPrompt": "{{value0}} を使って、このプルリクエストまたはマージリクエストをリンク済みの Linear 課題に添付してください。", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -790,9 +790,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "{{value0}} 台のホスト", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "Orca での Linear の仕組み、セットアップチェックリスト、エージェントスキル、プロンプト例。", "pluginsTitle": "プラグイン", - "pluginsDescription": "試験的な Orca プラグインをインストールして管理します。" + "pluginsDescription": "試験的な Orca プラグインをインストールして管理します。", + "tasksDescription": "プロバイダーを接続し、Linear スキルをインストールして、タスクに表示する項目を選択します。" }, "useAppMenuPaste": { "pasteTooLarge": "貼り付け内容が大きすぎます。" @@ -5273,7 +5274,9 @@ "copyCommandAria": "コマンドをコピー", "runCommandDescription": "Enter を押してコマンドを実行します。", "5f818f12ab": "準備中...", - "4c05b9d7cb": "セットアップ用ターミナルを準備しています。" + "4c05b9d7cb": "セットアップ用ターミナルを準備しています。", + "installLabel": "インストール", + "updateLabel": "更新" }, "AgentsPane": { "d83834f5e6": "インストールされている agents を検出しています…", @@ -6861,7 +6864,8 @@ "dev": "Dev Tools", "devDescription": "Dev-only tools for exercising UI states.", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "Orca での Linear の仕組み、セットアップチェックリスト、エージェントスキル、プロンプト例。", + "tasksDescription": "プロバイダーを接続し、Linear スキルをインストールして、タスクに表示する項目を選択します。" }, "SettingsFormControls": { "42a4d15a30": "一致するフォントがありません。", @@ -7090,18 +7094,34 @@ "53e9aabfc0": "利用可能な場合は OpenSSH の多重化を使用します。独自の SSH 制限があるホストではオフにしてください。" }, "TasksPane": { - "3a72b9745e": "[タスク] ページとサイドバーのショートカットに表示するタスク プロバイダーを選択します。", "f71d8a9dd3": "タスクプロバイダー", - "71644aba56": "[タスク] ページのソース ピッカーとサイドバーのショートカットに表示するタスク プロバイダーを選択します。少なくとも 1 つのプロバイダーが表示されたままでなければなりません。", - "93e72ef659": "タスクソース", - "8e1305fcc6": "タスクのソース ピッカーとサイドバーのショートカットに Jira を表示します。", "6b23a34f6d": "Jira", - "e4170c9615": "タスクのソース ピッカーとサイドバーのショートカットに Linear を表示します。", "09ae2d7c51": "Linear", - "dd67a1b6e1": "タスクのソース ピッカーとサイドバーのショートカットに GitLab を表示します。", "7c5d7fdc20": "GitLab", - "1db47236cd": "タスクのソース ピッカーとサイドバーのショートカットに GitHub を表示します。", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "{{provider}} に接続", + "connectCodeHostDescription": "Orca が課題を読み込めるように、連携で CLI をインストールして認証します。", + "connectionCheckUnavailable": "Orca はこの接続を確認できませんでした。もう一度試すか、連携を開いてセットアップの詳細を確認してください。", + "retryConnection": "もう一度試す", + "openIntegrations": "連携", + "connectInIntegrations": "連携で設定", + "connectJiraTitle": "Jira に接続", + "connectJiraDescription": "API トークンまたは PAT を使って Jira Cloud サイトまたはセルフホスト環境を追加します。", + "manageJira": "キーを管理", + "addJira": "Jira アクセスを追加", + "githubDescription": "GitHub の Issue を参照し、そこからワークスペースを開始します。", + "gitlabDescription": "GitLab の Issue を参照し、そこからワークスペースを開始します。", + "linearDescription": "Linear に接続し、エージェントスキルをインストールして、タスクに表示します。", + "jiraDescription": "Jira Cloud またはセルフホスト版 Jira に接続し、タスクに表示します。", + "setupTitle": "タスク管理のセットアップ", + "setupDescription": "各プロバイダーの接続と表示設定を 1 か所で完了できます。Linear では、エージェントが課題を読み書きするためのスキルも必要です。少なくとも 1 つのプロバイダーを表示する必要があります。", + "incompleteBannerTitle": "表示中の一部のプロバイダーは設定が必要です", + "incompleteBannerBody": "使用しないプロバイダーを非表示にするか、カードを展開して手順を完了してください。", + "providersDescription": "各カードで接続、タスクへの表示、Linear の場合はスキル設定を進められます。", + "integrationsHint": "すべてのプロバイダーの認証情報は次の場所でも管理できます:", + "integrationsLink": "連携", + "skillHint": "。Linear の接続後、使用例は設定 → Linear で確認できます。", + "incompleteBannerBodyWithLinear": "使用しないプロバイダーを非表示にするか、カードを展開して手順を完了してください。Linear では API アクセス、エージェントスキル、タスクへの表示が必要です。" }, "TerminalAppearanceSection": { "a14a427ae4": "ペイン分割線の太さ。", @@ -8746,8 +8766,12 @@ "3d81c26d78": "ソース", "cf0e3e0c2f": "プロバイダー", "2ec54bee51": "タスク", - "765f0c544d": "[タスク] ページとサイドバーのショートカットに表示するタスク プロバイダーを選択します。", - "5b8e4aace5": "タスクプロバイダー" + "5b8e4aace5": "タスクプロバイダー", + "providersDescription": "タスクプロバイダーを接続し、Linear エージェントスキルをインストールして、タスクに表示する項目を選択します。", + "setup": "セットアップ", + "apiKey": "APIキー", + "skill": "スキル", + "connect": "接続" } }, "terminal": { @@ -9487,13 +9511,13 @@ "notInstalled": "未インストール", "recheck": "再確認", "installedDescription": "エージェントスキルがインストールされました。更新するには以下を実行してください:", - "description": "エージェントにLinearタスクの読み取りと編集を許可します。", + "description": "エージェントが Linear の課題を読み書きできるようにします。接続、スキル、表示設定を含むガイドは、設定 → タスクソースにあります。", "copyCommand": "コマンドをコピー" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Linear スキルの状態、使用例、タスクソース設定へのリンク。" } } } @@ -9582,15 +9606,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", - "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", - "terminalTitle": "Linear skill setup", - "terminalAriaLabel": "Linear skill install terminal", - "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "description": "Orca で Linear の課題を参照し、リンク済みワークスペースを開始して、/orca-linear でエージェントから更新できます。", + "skillTitle": "Linear スキル", + "howToUse": "プロンプト例", + "howToUseDescription": "カードをクリックするとプロンプトをコピーできます。スキルのインストール後、Linear にリンクしたワークスペースで使用してください。", + "terminalTitle": "Linear スキルのセットアップ", + "terminalAriaLabel": "Linear スキルのインストールターミナル", + "manageConnectionHint": "接続済みの Linear ワークスペースと API キーは次の場所で確認できます:", + "manageConnectionLink": "連携" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9907,6 +9930,69 @@ "development": "開発プラグイン" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. Linear に接続", + "setupConnectBody": "Orca が課題を一覧表示し、リンク済みワークスペースを開くための個人 API キー。", + "manageKeys": "キーを管理", + "addAccess": "アクセスを追加", + "setupSkillTitle": "2. エージェントスキルをインストール", + "setupSkillBody": "エージェントが /orca-linear で課題の参照、更新、トリアージ、プル/マージリクエストの添付を行えるようにします。", + "setupVisibleTitle": "3. タスクに Linear を表示", + "setupVisibleBody": "Linear をタスクのソース選択とサイドバーのショートカットに表示します。", + "openTaskSources": "タスクソース", + "setupTitle": "セットアップチェックリスト", + "setupBody": "タスクとエージェントの一連の流れには 3 つの手順が必要です。初回設定はタスクソースでも行えます。", + "setupReady": "設定完了", + "setupProgress": "{{total}} 件中 {{done}} 件が完了", + "notesTitle": "知っておくと便利", + "notesIntro": "Linear の接続とスキルのインストール後に役立つポイントです。", + "noteLinkedTitle": "Linear の課題から開始", + "noteLinkedBody": "タスクから作成したワークスペースでは課題がコンテキストとしてリンクされるため、課題操作が最もスムーズです。", + "noteSlashTitle": "/orca-linear を指定", + "noteSlashBody": "チャットで /orca-linear を使うか自然言語で依頼すると、そのターンでエージェントがスキルを読み込みます。", + "noteKeysTitle": "キーは実行環境ごとに保存", + "noteKeysBody": "API キーとワークスペースはアクティブな実行環境に保存されます。", + "noteVisibilityTitle": "非表示にしても接続は維持", + "noteVisibilityBody": "タスクソースで Linear を非表示にしても、選択肢から外れるだけで、キーやスキルは削除されません。", + "setupChecking": "確認中…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "Linear に接続", + "connectDescription": "Orca が課題を参照し、そのコンテキストでワークスペースを開けるように個人 API キーを追加します。", + "manageAccess": "キーを管理", + "addAccess": "Linear アクセスを追加", + "connectedHint": "ワークスペースとキーはアクティブな実行環境に保存されます。アクセスはいつでも追加できます。", + "recheck": "接続を再確認", + "skillTitle": "Linear エージェントスキルをインストール", + "skillDescription": "エージェントが /orca-linear で課題の参照、更新の投稿、状態変更、プル/マージリクエストの添付を行えるようにします。", + "skillBlocked": "先に Linear に接続してから、エージェント用スキルをインストールしてください。", + "skillPanelTitle": "Linear スキル", + "terminalTitle": "Linear スキルのセットアップ", + "terminalAriaLabel": "Linear スキルのインストールターミナル", + "showDescription": "Linear をタスクのソース選択とサイドバーのショートカットに表示します。" + }, + "TaskSourceProviderCard": { + "statusChecking": "確認中…", + "statusReady": "準備完了", + "statusConnectRequired": "接続が必要", + "statusSkillRequired": "スキルが必要", + "statusUnavailable": "ステータスを確認できません", + "statusHidden": "タスクで非表示", + "statusIncomplete": "設定が必要", + "collapseSetup": "{{provider}} のセットアップ手順を折りたたむ", + "expandSetup": "{{provider}} のセットアップ手順を表示" + }, + "TaskSourceShowInTasksStep": { + "shown": "表示中", + "show": "表示", + "hide": "非表示", + "hideProviderAction": "{{provider}} をタスクから非表示", + "showProviderAction": "{{provider}} をタスクに表示", + "lastProviderAction": "{{provider}} はタスクに表示されています。少なくとも 1 つのプロバイダーを表示する必要があります。", + "lastProviderHint": "タスクには少なくとも 1 つのプロバイダーを表示する必要があります。", + "title": "タスクに表示", + "description": "このプロバイダーをタスクのソース選択とサイドバーのショートカットに表示します。" + }, "RuntimeHostAccessForm": { "getLink": "もう一方のホストからアクセスリンクを取得", "stepOpenShare": "設定 → リモート Orca サーバー → このホストを共有 を開きます。", @@ -14181,6 +14267,12 @@ }, "undo": "元に戻す", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "このページではまだ要素を選択できません。", + "errorNotAuthorized": "ブラウザーへのアクセスが拒否されました。", + "errorAlreadyActive": "要素の選択はすでに有効です。", + "errorInjectionFailed": "このページで要素の選択を開始できませんでした。" } }, "pluginCatalog": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e1cc97e3a0d..b483967528b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -616,7 +616,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "리뷰 링크 첨부", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -624,8 +624,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "풀 또는 머지 리퀘스트를 만들 때 Linear 이슈에 연결하세요.", + "attachPrPrompt": "{{value0}}을 사용해 이 풀 또는 머지 리퀘스트를 연결된 Linear 이슈에 첨부하세요.", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -790,9 +790,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "호스트 {{value0}}개", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "Orca에서 Linear가 작동하는 방식, 설정 체크리스트, 에이전트 스킬, 프롬프트 예시입니다.", "pluginsTitle": "플러그인", - "pluginsDescription": "실험적인 Orca 플러그인을 설치하고 관리합니다." + "pluginsDescription": "실험적인 Orca 플러그인을 설치하고 관리합니다.", + "tasksDescription": "제공업체를 연결하고 Linear 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요." }, "useAppMenuPaste": { "pasteTooLarge": "붙여넣기 내용이 너무 큽니다." @@ -5273,7 +5274,9 @@ "copyCommandAria": "명령 복사", "runCommandDescription": "Enter를 눌러 명령을 실행하세요.", "5f818f12ab": "준비 중...", - "4c05b9d7cb": "설정 터미널을 준비하고 있습니다." + "4c05b9d7cb": "설정 터미널을 준비하고 있습니다.", + "installLabel": "설치", + "updateLabel": "업데이트" }, "AgentsPane": { "d83834f5e6": "설치된 agents 감지 중…", @@ -6824,7 +6827,8 @@ "dev": "Dev Tools", "devDescription": "UI 상태를 시험해 보기 위한 개발 전용 도구입니다.", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "Orca에서 Linear가 작동하는 방식, 설정 체크리스트, 에이전트 스킬, 프롬프트 예시입니다.", + "tasksDescription": "제공업체를 연결하고 Linear 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요." }, "SettingsFormControls": { "42a4d15a30": "일치하는 글꼴이 없습니다.", @@ -7053,18 +7057,34 @@ "53e9aabfc0": "사용 가능한 경우 OpenSSH 멀티플렉싱을 사용합니다. 사용자 지정 SSH 제한이 있는 호스트에서는 끄세요." }, "TasksPane": { - "3a72b9745e": "작업 페이지와 사이드바 바로가기에 표시할 작업 제공자를 선택합니다.", "f71d8a9dd3": "작업 제공자", - "71644aba56": "작업 페이지 소스 선택기 및 사이드바 바로가기에 표시할 작업 제공자를 선택합니다. 하나 이상의 제공업체가 계속 표시되어야 합니다.", - "93e72ef659": "작업 소스", - "8e1305fcc6": "작업 소스 선택기 및 사이드바 바로가기에 Jira를 표시합니다.", "6b23a34f6d": "Jira", - "e4170c9615": "작업 소스 선택기 및 사이드바 바로가기에 Linear를 표시합니다.", "09ae2d7c51": "Linear", - "dd67a1b6e1": "작업 소스 선택기 및 사이드바 바로가기에 GitLab을 표시합니다.", "7c5d7fdc20": "GitLab", - "1db47236cd": "작업 소스 선택기 및 사이드바 바로가기에 GitHub를 표시합니다.", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "{{provider}} 연결", + "connectCodeHostDescription": "Orca가 이슈를 불러올 수 있도록 연동에서 CLI를 설치하고 인증하세요.", + "connectionCheckUnavailable": "Orca가 이 연결을 확인하지 못했습니다. 다시 시도하거나 연동을 열어 설정 세부 정보를 확인하세요.", + "retryConnection": "다시 시도", + "openIntegrations": "연동", + "connectInIntegrations": "연동에서 설정", + "connectJiraTitle": "Jira 연결", + "connectJiraDescription": "API 토큰 또는 PAT로 Jira Cloud 사이트나 자체 호스팅 인스턴스를 추가하세요.", + "manageJira": "키 관리", + "addJira": "Jira 액세스 추가", + "githubDescription": "GitHub 이슈를 탐색하고 해당 이슈에서 워크스페이스를 시작하세요.", + "gitlabDescription": "GitLab 이슈를 탐색하고 해당 이슈에서 워크스페이스를 시작하세요.", + "linearDescription": "Linear를 연결하고 에이전트 스킬을 설치한 다음 작업에 표시하세요.", + "jiraDescription": "Jira Cloud 또는 자체 호스팅 Jira를 연결하고 작업에 표시하세요.", + "setupTitle": "작업 관리 설정", + "setupDescription": "각 제공업체의 연결과 표시 설정을 한곳에서 완료하세요. Linear에서는 에이전트가 이슈를 읽고 업데이트할 수 있도록 에이전트 스킬도 필요합니다. 제공업체는 하나 이상 표시되어야 합니다.", + "incompleteBannerTitle": "표시 중인 일부 제공업체에 설정이 필요합니다", + "incompleteBannerBody": "사용하지 않는 제공업체를 숨기거나 카드를 펼쳐 단계를 완료하세요.", + "providersDescription": "각 카드에서 연결, 작업 표시 여부, Linear의 경우 스킬 설정까지 진행할 수 있습니다.", + "integrationsHint": "모든 제공업체의 자격 증명은 다음에서도 관리할 수 있습니다:", + "integrationsLink": "연동", + "skillHint": ". Linear를 연결한 후 사용 예시는 설정 → Linear에서 확인할 수 있습니다.", + "incompleteBannerBodyWithLinear": "사용하지 않는 제공업체를 숨기거나 카드를 펼쳐 단계를 완료하세요. Linear에는 API 액세스, 에이전트 스킬, 작업에 표시가 필요합니다." }, "TerminalAppearanceSection": { "a14a427ae4": "창 구분선의 두께입니다.", @@ -8709,8 +8729,12 @@ "3d81c26d78": "소스", "cf0e3e0c2f": "공급자", "2ec54bee51": "작업", - "765f0c544d": "작업 페이지와 사이드바 바로가기에 표시할 작업 제공자를 선택합니다.", - "5b8e4aace5": "작업 제공자" + "5b8e4aace5": "작업 제공자", + "providersDescription": "작업 제공업체를 연결하고 Linear 에이전트 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요.", + "setup": "설정", + "apiKey": "API 키", + "skill": "스킬", + "connect": "연결" } }, "terminal": { @@ -9487,13 +9511,13 @@ "notInstalled": "설치되지 않음", "recheck": "다시 확인", "installedDescription": "에이전트 스킬이 설치되었습니다. 업데이트하려면 다음을 실행하세요:", - "description": "에이전트가 Linear 작업을 읽고 편집할 수 있도록 합니다.", + "description": "에이전트가 Linear 작업을 읽고 편집할 수 있도록 합니다. 전체 가이드 설정(연결 + 스킬 + 표시)은 설정 → 작업 소스에 있습니다.", "copyCommand": "명령 복사" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Linear 스킬 상태, 사용 예시, 작업 소스 설정 링크입니다." } } } @@ -9582,15 +9606,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", - "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", - "terminalTitle": "Linear skill setup", - "terminalAriaLabel": "Linear skill install terminal", - "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "description": "Orca에서 Linear 이슈를 탐색하고 연결된 워크스페이스를 시작하며 /orca-linear로 에이전트가 이슈를 업데이트할 수 있습니다.", + "skillTitle": "Linear 스킬", + "howToUse": "프롬프트 예시", + "howToUseDescription": "카드를 클릭해 프롬프트를 복사하세요. 스킬을 설치한 후 Linear에 연결된 워크스페이스에서 사용하세요.", + "terminalTitle": "Linear 스킬 설정", + "terminalAriaLabel": "Linear 스킬 설치 터미널", + "manageConnectionHint": "연결된 Linear 워크스페이스와 API 키는 다음에서 확인할 수 있습니다.", + "manageConnectionLink": "연동" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9907,6 +9930,69 @@ "development": "개발 플러그인" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. Linear 연결", + "setupConnectBody": "Orca가 이슈를 나열하고 연결된 워크스페이스를 열 수 있도록 하는 개인 API 키입니다.", + "manageKeys": "키 관리", + "addAccess": "액세스 추가", + "setupSkillTitle": "2. 에이전트 스킬 설치", + "setupSkillBody": "에이전트가 /orca-linear로 이슈 읽기, 업데이트, 분류, 풀 또는 머지 리퀘스트 첨부를 할 수 있게 합니다.", + "setupVisibleTitle": "3. 작업에 Linear 표시", + "setupVisibleBody": "작업 소스 선택기와 사이드바 바로 가기에 Linear를 표시합니다.", + "openTaskSources": "작업 소스", + "setupTitle": "설정 체크리스트", + "setupBody": "작업과 에이전트의 전체 흐름에는 세 단계가 모두 필요합니다. 최초 설정은 작업 소스에서도 진행할 수 있습니다.", + "setupReady": "설정 완료", + "setupProgress": "{{total}}개 중 {{done}}개 완료", + "notesTitle": "알아두면 좋은 점", + "notesIntro": "Linear를 연결하고 스킬을 설치한 후 참고할 내용입니다.", + "noteLinkedTitle": "Linear 이슈에서 시작", + "noteLinkedBody": "작업에서 만든 워크스페이스는 이슈가 컨텍스트로 연결되어 있어 이슈 작업이 가장 원활합니다.", + "noteSlashTitle": "/orca-linear 언급", + "noteSlashBody": "채팅에서 /orca-linear를 사용하거나 자연어로 요청하면 해당 턴에 에이전트가 스킬을 불러옵니다.", + "noteKeysTitle": "키는 런타임별로 저장", + "noteKeysBody": "API 키와 워크스페이스는 활성 런타임에 저장됩니다.", + "noteVisibilityTitle": "숨겨도 연결은 유지", + "noteVisibilityBody": "작업 소스에서 Linear를 숨기면 선택기에서만 제거됩니다. 키나 스킬은 삭제되지 않습니다.", + "setupChecking": "확인 중…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "Linear 연결", + "connectDescription": "Orca가 이슈를 탐색하고 이슈 컨텍스트로 워크스페이스를 열 수 있도록 개인 API 키를 추가하세요.", + "manageAccess": "키 관리", + "addAccess": "Linear 액세스 추가", + "connectedHint": "워크스페이스와 키는 활성 런타임에 저장됩니다. 언제든 액세스를 더 추가할 수 있습니다.", + "recheck": "연결 다시 확인", + "skillTitle": "Linear 에이전트 스킬 설치", + "skillDescription": "에이전트가 /orca-linear로 이슈 읽기, 업데이트 게시, 상태 변경, 풀 또는 머지 리퀘스트 첨부를 할 수 있게 합니다.", + "skillBlocked": "먼저 Linear를 연결한 다음 에이전트용 스킬을 설치하세요.", + "skillPanelTitle": "Linear 스킬", + "terminalTitle": "Linear 스킬 설정", + "terminalAriaLabel": "Linear 스킬 설치 터미널", + "showDescription": "작업 소스 선택기와 사이드바 바로 가기에 Linear를 표시합니다." + }, + "TaskSourceProviderCard": { + "statusChecking": "확인 중…", + "statusReady": "준비됨", + "statusConnectRequired": "연결 필요", + "statusSkillRequired": "스킬 필요", + "statusUnavailable": "상태를 확인할 수 없음", + "statusHidden": "작업에서 숨김", + "statusIncomplete": "설정 필요", + "collapseSetup": "{{provider}} 설정 단계 접기", + "expandSetup": "{{provider}} 설정 단계 표시" + }, + "TaskSourceShowInTasksStep": { + "shown": "표시 중", + "show": "표시", + "hide": "숨기기", + "hideProviderAction": "작업에서 {{provider}} 숨기기", + "showProviderAction": "작업에 {{provider}} 표시", + "lastProviderAction": "작업에 표시된 제공업체는 {{provider}}입니다. 제공업체는 하나 이상 표시되어야 합니다.", + "lastProviderHint": "작업에는 제공업체가 하나 이상 표시되어야 합니다.", + "title": "작업에 표시", + "description": "이 제공업체를 작업 소스 선택기와 사이드바 바로 가기에 표시합니다." + }, "RuntimeHostAccessForm": { "getLink": "다른 호스트에서 액세스 링크 가져오기", "stepOpenShare": "설정 → 원격 Orca 서버 → 이 호스트 공유를 엽니다.", @@ -14181,6 +14267,12 @@ }, "undo": "실행 취소", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "이 페이지에서는 아직 요소를 선택할 수 없습니다.", + "errorNotAuthorized": "브라우저 접근이 거부되었습니다.", + "errorAlreadyActive": "요소 선택이 이미 활성화되어 있습니다.", + "errorInjectionFailed": "이 페이지에서 요소 선택을 시작할 수 없습니다." } }, "pluginCatalog": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 40b1c6b3cc4..75e5cea0482 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -616,7 +616,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "附加评审链接", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -624,8 +624,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "创建拉取或合并请求时,将其关联到 Linear 议题。", + "attachPrPrompt": "使用 {{value0}} 将此拉取或合并请求附加到关联的 Linear 议题。", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -790,9 +790,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "{{value0}} 个主机", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "Linear 在 Orca 中的工作方式、设置清单、智能体技能和提示词示例。", "pluginsTitle": "插件", - "pluginsDescription": "安装和管理实验性 Orca 插件。" + "pluginsDescription": "安装和管理实验性 Orca 插件。", + "tasksDescription": "连接提供商、安装 Linear 技能并选择要在任务中显示的内容。" }, "useAppMenuPaste": { "pasteTooLarge": "粘贴内容过大。" @@ -5273,7 +5274,9 @@ "copyCommandAria": "复制命令", "runCommandDescription": "按 Enter 运行命令。", "5f818f12ab": "正在准备...", - "4c05b9d7cb": "正在准备设置终端。" + "4c05b9d7cb": "正在准备设置终端。", + "installLabel": "安装", + "updateLabel": "更新" }, "AgentsPane": { "d83834f5e6": "检测已安装的智能体...", @@ -6824,7 +6827,8 @@ "dev": "开发工具", "devDescription": "仅用于调试 UI 状态的开发工具。", "linearTitle": "Linear", - "linearDescription": "赋予智能体读取和更新您关联的 Linear 工单的能力。" + "linearDescription": "Linear 在 Orca 中的工作方式、设置清单、智能体技能和提示词示例。", + "tasksDescription": "连接提供商、安装 Linear 技能并选择要在任务中显示的内容。" }, "SettingsFormControls": { "42a4d15a30": "没有匹配的字体。", @@ -7053,18 +7057,34 @@ "53e9aabfc0": "可用时使用 OpenSSH 多路复用。对于有自定义 SSH 限制的主机,请关闭此项。" }, "TasksPane": { - "3a72b9745e": "选择要在“任务”页面和侧边栏快捷键中显示的任务提供商。", "f71d8a9dd3": "任务提供者", - "71644aba56": "选择要在“任务”页面源选择器和侧边栏快捷键中显示的任务提供商。至少需保留一个可见提供商。", - "93e72ef659": "任务来源", - "8e1305fcc6": "在任务源选择器和侧边栏快捷方式中显示 Jira。", "6b23a34f6d": "Jira", - "e4170c9615": "在任务源选择器和侧边栏快捷方式中显示 Linear。", "09ae2d7c51": "Linear", - "dd67a1b6e1": "在任务源选择器和侧边栏快捷方式中显示 GitLab。", "7c5d7fdc20": "GitLab", - "1db47236cd": "在任务源选择器和侧边栏快捷方式中显示 GitHub。", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "连接 {{provider}}", + "connectCodeHostDescription": "在集成中安装并验证 CLI,以便 Orca 加载议题。", + "connectionCheckUnavailable": "Orca 无法检查此连接。请重试,或打开“集成”查看设置详情。", + "retryConnection": "重试", + "openIntegrations": "集成", + "connectInIntegrations": "在集成中设置", + "connectJiraTitle": "连接 Jira", + "connectJiraDescription": "使用 API Token 或 PAT 添加 Jira Cloud 站点或自托管实例。", + "manageJira": "管理密钥", + "addJira": "添加 Jira 访问权限", + "githubDescription": "浏览 GitHub 议题并从中启动工作区。", + "gitlabDescription": "浏览 GitLab 议题并从中启动工作区。", + "linearDescription": "连接 Linear、安装智能体技能并将其显示在任务中。", + "jiraDescription": "连接 Jira Cloud 或自托管 Jira 并将其显示在任务中。", + "setupTitle": "任务管理设置", + "setupDescription": "在一处完成每个提供商的连接和显示设置。Linear 还需要智能体技能,以便智能体读取和更新议题。必须至少保留一个可见提供商。", + "incompleteBannerTitle": "部分可见提供商仍需设置", + "incompleteBannerBody": "隐藏不使用的提供商,或展开卡片并完成其中的步骤。", + "providersDescription": "每张卡片都会引导你完成连接、任务显示设置;Linear 还包括技能设置。", + "integrationsHint": "所有提供商的凭据也可在此管理:", + "integrationsLink": "集成", + "skillHint": "。连接 Linear 后,可在设置 → Linear 中查看使用示例。", + "incompleteBannerBodyWithLinear": "隐藏不使用的提供商,或展开卡片并完成其中的步骤。Linear 需要 API 访问权限、智能体技能以及在任务中显示。" }, "TerminalAppearanceSection": { "a14a427ae4": "窗格分隔线的厚度。", @@ -8709,8 +8729,12 @@ "3d81c26d78": "来源", "cf0e3e0c2f": "提供者", "2ec54bee51": "任务", - "765f0c544d": "选择要在“任务”页面和侧边栏快捷键中显示的任务提供商。", - "5b8e4aace5": "任务提供者" + "5b8e4aace5": "任务提供者", + "providersDescription": "连接任务提供商、安装 Linear 智能体技能并选择要在任务中显示的内容。", + "setup": "设置", + "apiKey": "API 密钥", + "skill": "技能", + "connect": "连接" } }, "terminal": { @@ -9487,13 +9511,13 @@ "notInstalled": "未安装", "recheck": "重新检查", "installedDescription": "智能体技能已安装。要更新,请运行:", - "description": "让您的智能体读取和编辑 Linear 任务。", + "description": "让智能体读取和编辑 Linear 任务。完整引导设置(连接 + 技能 + 可见性)位于设置 → 任务来源。", "copyCommand": "复制命令" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Linear 技能状态、使用示例和任务来源设置链接。" } } } @@ -9582,15 +9606,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", - "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", - "terminalTitle": "Linear skill setup", - "terminalAriaLabel": "Linear skill install terminal", - "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "description": "在 Orca 中浏览 Linear 议题、启动关联工作区,并让智能体通过 /orca-linear 更新议题。", + "skillTitle": "Linear 技能", + "howToUse": "提示词示例", + "howToUseDescription": "点击卡片即可复制提示词。安装技能后,请在关联 Linear 的工作区中使用。", + "terminalTitle": "Linear 技能设置", + "terminalAriaLabel": "Linear 技能安装终端", + "manageConnectionHint": "可在此查看已连接的 Linear 工作区和 API 密钥:", + "manageConnectionLink": "集成" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9907,6 +9930,69 @@ "development": "开发插件" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. 连接 Linear", + "setupConnectBody": "用于让 Orca 列出议题并打开关联工作区的个人 API 密钥。", + "manageKeys": "管理密钥", + "addAccess": "添加访问权限", + "setupSkillTitle": "2. 安装智能体技能", + "setupSkillBody": "让智能体使用 /orca-linear 读取、更新、分类议题并附加拉取或合并请求。", + "setupVisibleTitle": "3. 在任务中显示 Linear", + "setupVisibleBody": "在任务来源选择器和侧边栏快捷方式中保留 Linear。", + "openTaskSources": "任务来源", + "setupTitle": "设置清单", + "setupBody": "完整的任务与智能体流程需要完成全部三个步骤。首次设置也可在任务来源中进行。", + "setupReady": "全部就绪", + "setupProgress": "已完成 {{done}}/{{total}}", + "notesTitle": "使用须知", + "notesIntro": "连接 Linear 并安装技能后的一些快速提示。", + "noteLinkedTitle": "从 Linear 议题开始", + "noteLinkedBody": "从任务创建的工作区会将议题保留为关联上下文,因此议题操作最顺畅。", + "noteSlashTitle": "提及 /orca-linear", + "noteSlashBody": "在聊天中使用 /orca-linear(或用自然语言提出请求),智能体会在该轮加载此技能。", + "noteKeysTitle": "密钥按运行环境保存", + "noteKeysBody": "API 密钥和工作区存储在当前运行环境中。", + "noteVisibilityTitle": "隐藏不等于断开连接", + "noteVisibilityBody": "在任务来源中隐藏 Linear 只会将其从选择器中移除,不会删除密钥或技能。", + "setupChecking": "检查中…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "连接 Linear", + "connectDescription": "添加个人 API 密钥,让 Orca 能够浏览议题并使用议题上下文打开工作区。", + "manageAccess": "管理密钥", + "addAccess": "添加 Linear 访问权限", + "connectedHint": "工作区和密钥存储在当前运行环境中。你可以随时添加更多访问权限。", + "recheck": "重新检查连接", + "skillTitle": "安装 Linear 智能体技能", + "skillDescription": "让智能体使用 /orca-linear 读取议题、发布更新、更改状态并附加拉取或合并请求。", + "skillBlocked": "请先连接 Linear,然后为智能体安装技能。", + "skillPanelTitle": "Linear 技能", + "terminalTitle": "Linear 技能设置", + "terminalAriaLabel": "Linear 技能安装终端", + "showDescription": "在任务来源选择器和侧边栏快捷方式中显示 Linear。" + }, + "TaskSourceProviderCard": { + "statusChecking": "检查中…", + "statusReady": "已就绪", + "statusConnectRequired": "需要连接", + "statusSkillRequired": "需要技能", + "statusUnavailable": "状态不可用", + "statusHidden": "已从任务中隐藏", + "statusIncomplete": "需要设置", + "collapseSetup": "收起 {{provider}} 设置步骤", + "expandSetup": "显示 {{provider}} 设置步骤" + }, + "TaskSourceShowInTasksStep": { + "shown": "已显示", + "show": "显示", + "hide": "隐藏", + "hideProviderAction": "从任务中隐藏 {{provider}}", + "showProviderAction": "在任务中显示 {{provider}}", + "lastProviderAction": "{{provider}} 已显示在任务中。必须至少保留一个可见提供商。", + "lastProviderHint": "任务中必须至少保留一个可见提供商。", + "title": "在任务中显示", + "description": "在任务来源选择器和侧边栏快捷方式中显示此提供商。" + }, "RuntimeHostAccessForm": { "getLink": "从另一台主机获取访问链接", "stepOpenShare": "打开设置 → 远程 Orca 服务器 → 共享此主机。", @@ -14181,6 +14267,12 @@ }, "undo": "撤销", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "此页面尚未准备好进行元素选择。", + "errorNotAuthorized": "浏览器访问被拒绝。", + "errorAlreadyActive": "元素选择已处于活动状态。", + "errorInjectionFailed": "无法在此页面上启动元素选择。" } }, "pluginCatalog": { diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index 23109db3ba6..3b1865eae6d 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -6,6 +6,7 @@ import { normalizeTerminalPasteLineEndings, wrapTerminalBracketedPasteText } from '@/components/terminal-pane/terminal-bracketed-paste' +import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction' import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' // Why: bracketed paste markers let supported TUIs treat generated prompt text @@ -25,6 +26,17 @@ export async function sendAgentDraftPasteContent( ptyId: string, content: string, writePty?: AgentDraftPtyInputWriter +): Promise { + return await runTerminalPtyInputTransaction(ptyId, () => + sendAgentDraftPasteContentNow(settings, ptyId, content, writePty) + ) +} + +async function sendAgentDraftPasteContentNow( + settings: Pick | null | undefined, + ptyId: string, + content: string, + writePty?: AgentDraftPtyInputWriter ): Promise { if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES) { return false diff --git a/src/renderer/src/lib/language-detect.test.ts b/src/renderer/src/lib/language-detect.test.ts index 0bb81a5c7f8..77f5331e233 100644 --- a/src/renderer/src/lib/language-detect.test.ts +++ b/src/renderer/src/lib/language-detect.test.ts @@ -47,6 +47,23 @@ describe('detectLanguage', () => { expect(detectLanguage('C:\\Users\\alice\\.codex\\LOG.JSONL')).toBe('jsonl') }) + it('maps .cts/.mts files to the Monaco built-in typescript language id (case-insensitive)', () => { + expect(detectLanguage('config/vitest.config.mts')).toBe('typescript') + expect(detectLanguage('scripts/postinstall.cts')).toBe('typescript') + expect(detectLanguage('types/global.d.mts')).toBe('typescript') + expect(detectLanguage('C:\\repo\\config\\BUILD.MTS')).toBe('typescript') + }) + + it('keeps .mjs/.cjs on the Monaco built-in javascript language id', () => { + expect(detectLanguage('scripts/build.mjs')).toBe('javascript') + expect(detectLanguage('scripts/legacy.cjs')).toBe('javascript') + }) + + it('maps .r files to the r language id regardless of case', () => { + expect(detectLanguage('analysis/model.r')).toBe('r') + expect(detectLanguage('analysis/MODEL.R')).toBe('r') + }) + it('keeps .json/.jsonc on the built-in json language and unknown on plaintext', () => { expect(detectLanguage('config/settings.json')).toBe('json') expect(detectLanguage('config/tsconfig.jsonc')).toBe('json') diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts index fabf6c31460..9be15124ada 100644 --- a/src/renderer/src/lib/language-detect.ts +++ b/src/renderer/src/lib/language-detect.ts @@ -14,6 +14,8 @@ const EXT_TO_LANGUAGE: Record = { // is what gives .tsx/.jsx files syntax highlighting in the editor. '.ts': 'typescript', '.tsx': 'typescript', + '.cts': 'typescript', + '.mts': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', @@ -72,7 +74,6 @@ const EXT_TO_LANGUAGE: Record = { '.proto': 'proto', '.lua': 'lua', '.r': 'r', - '.R': 'r', '.scala': 'scala', '.dart': 'dart', '.ex': 'elixir', diff --git a/src/renderer/src/lib/linear-usage-examples.ts b/src/renderer/src/lib/linear-usage-examples.ts index af6b0a5f1e8..38c60bfc8c4 100644 --- a/src/renderer/src/lib/linear-usage-examples.ts +++ b/src/renderer/src/lib/linear-usage-examples.ts @@ -47,14 +47,14 @@ export const getLinearUsageExamples = createLocalizedCatalog((): SkillUsageExamp }, { id: 'attach-pr', - title: translate('auto.lib.linear.usage.examples.attachPr', 'Attach the PR link'), + title: translate('auto.lib.linear.usage.examples.attachPr', 'Attach the review link'), summary: translate( 'auto.lib.linear.usage.examples.attachPrSummary', - 'Link the pull request to the Linear issue when you open it.' + 'Link the pull or merge request to the Linear issue when you open it.' ), prompt: translate( 'auto.lib.linear.usage.examples.attachPrPrompt', - 'Use {{value0}} to attach this PR to the linked Linear issue with a PR link.', + 'Use {{value0}} to attach this pull or merge request to the linked Linear issue.', { value0: LINEAR_SLASH_COMMAND } ) }, diff --git a/src/renderer/src/lib/react-renderer-root.test.ts b/src/renderer/src/lib/react-renderer-root.test.ts new file mode 100644 index 00000000000..3592196f969 --- /dev/null +++ b/src/renderer/src/lib/react-renderer-root.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import { getOrCreateRendererRoot } from './react-renderer-root' + +vi.mock('react-dom/client', () => ({ + createRoot: vi.fn() +})) + +beforeEach(() => { + vi.mocked(createRoot).mockReset() +}) + +describe('getOrCreateRendererRoot', () => { + it('reuses the root retained by an HMR entry module', () => { + const hotData: { orcaRendererRoot?: Root } = {} + const root = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + vi.mocked(createRoot).mockReturnValue(root) + + expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root) + expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root) + expect(createRoot).toHaveBeenCalledTimes(1) + }) + + it('creates an independent root without HMR state', () => { + const first = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + const second = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + vi.mocked(createRoot).mockReturnValueOnce(first).mockReturnValueOnce(second) + + expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(first) + expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(second) + }) +}) diff --git a/src/renderer/src/lib/react-renderer-root.ts b/src/renderer/src/lib/react-renderer-root.ts new file mode 100644 index 00000000000..0a73210ecfa --- /dev/null +++ b/src/renderer/src/lib/react-renderer-root.ts @@ -0,0 +1,20 @@ +import { createRoot, type Root } from 'react-dom/client' + +type RendererRootHotData = { + orcaRendererRoot?: Root +} + +export function getOrCreateRendererRoot( + container: HTMLElement, + hotData?: RendererRootHotData +): Root { + const existingRoot = hotData?.orcaRendererRoot + if (existingRoot) { + return existingRoot + } + const root = createRoot(container) + if (hotData) { + hotData.orcaRendererRoot = root + } + return root +} diff --git a/src/renderer/src/lib/skill-freshness-display-status.test.ts b/src/renderer/src/lib/skill-freshness-display-status.test.ts index 55a68e774e6..66c616a702f 100644 --- a/src/renderer/src/lib/skill-freshness-display-status.test.ts +++ b/src/renderer/src/lib/skill-freshness-display-status.test.ts @@ -57,6 +57,18 @@ function pluginCachePlacement( } } +function repoPlacement(status: SkillFreshnessStatus = 'unrecognized'): SkillFreshnessInstallation { + return { + ...placement(status, 7), + rootId: 'repo-work', + sourceKind: 'repo', + sourceLabel: 'work', + topology: 'repo-scope', + unresolvedPath: `/home/projects/work/.agents/skills/${SKILL_NAME}`, + resolvedPath: `/home/projects/work/.agents/skills/${SKILL_NAME}` + } +} + function inventory( installations: SkillFreshnessInstallation[], eligibleUpdateNames: string[] = [], @@ -209,6 +221,46 @@ describe('hasSkillCopyNeedingAttention', () => { ).toBe(true) }) + // The exact shape of the rc.4 report: a pristine global install alongside a drifted + // copy inside a work directory. Orca only updates global skills, so the project copy + // must not turn the badge amber over drift it has no way to fix. + it('reports up to date for a current global copy beside a drifted project copy', () => { + const value = inventory([placement('current'), repoPlacement('unrecognized')]) + + expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('up-to-date') + expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false) + }) + + // Why by scope and not by status: an outdated or unreadable project copy is just as + // far outside the global updater's reach as an unrecognized one, so pinning only the + // reported status would leave the same bug reachable through a different one. + it.each<[SkillFreshnessStatus]>([['unrecognized'], ['outdated'], ['inaccessible']])( + 'ignores a %s project copy when judging global freshness', + (status) => { + const value = inventory([placement('current'), repoPlacement(status)]) + + expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('up-to-date') + } + ) + + // A project copy is not evidence the skill is installed globally, so it reports + // presence rather than a freshness claim about a copy Orca does not manage. + it('reports presence only when every copy is project-owned', () => { + expect(getSkillFreshnessDisplayStatus(inventory([repoPlacement()]), SKILL_NAME)).toBe( + 'installed' + ) + }) + + // Regression guard for the centralization: the badge predicate deliberately omits the + // shared helper's outdated carve-out, so a non-eligible outdated copy stays amber. + // Collapsing the two predicates would flip this to green while the dialog still shows + // a reinstall row — the badge/dialog contradiction this change exists to avoid. + it('keeps a non-eligible outdated global copy amber', () => { + expect(getSkillFreshnessDisplayStatus(inventory([placement('outdated')]), SKILL_NAME)).toBe( + 'needs-attention' + ) + }) + // Why: an unreadable plugin path could hide a copy of anything, but a skill Orca // never found anywhere is not the one to blame for it — that reads as a problem // with a skill the user has not installed. @@ -218,4 +270,14 @@ describe('hasSkillCopyNeedingAttention', () => { expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('installed') expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false) }) + + // Why: a project copy is not the presence that makes the cache fault this skill's + // problem either. The two helpers have to read the same placements, or the badge calls + // a repo-only skill merely installed while the review affordance flags it amber. + it('does not treat a project copy as the presence that owns a cache fault', () => { + const value = inventory([repoPlacement()], [], [scanIssue('io-error')]) + + expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('installed') + expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false) + }) }) diff --git a/src/renderer/src/lib/skill-freshness-display-status.ts b/src/renderer/src/lib/skill-freshness-display-status.ts index ed983e88c2a..3d6ad527a97 100644 --- a/src/renderer/src/lib/skill-freshness-display-status.ts +++ b/src/renderer/src/lib/skill-freshness-display-status.ts @@ -1,6 +1,7 @@ import { isSkillCopyNeedingAttention, isSkillScanIssueNeedingAttention, + skillPlacementParticipatesInGlobalFreshness, type SkillFreshnessInventory } from '../../../shared/skill-freshness' @@ -23,6 +24,13 @@ export function getSkillFreshnessDisplayStatus( if (installation.name !== skillName) { continue } + // Why: a project-owned copy is outside the global updater's reach, so it can neither + // stand in as evidence this skill is installed globally nor make the badge amber over + // drift Orca has no way to fix. Skipped before `hasPlacement` so a repo-only skill + // reports presence, not a freshness claim about a copy Orca does not manage. + if (!skillPlacementParticipatesInGlobalFreshness(installation)) { + continue + } hasPlacement = true // Why: 'newer-known' is recognized official content ahead of this build — the // updater's own install or a newer release's bytes. There is nothing to fix and @@ -61,8 +69,11 @@ export function hasSkillCopyNeedingAttention( inventory: SkillFreshnessInventory | null, skillName: string ): boolean { + // Why: the same participation filter the status above applies, or an unreadable plugin + // folder flags a repo-only skill the status calls merely installed. const placements = (inventory?.installations ?? []).filter( - (installation) => installation.name === skillName + (installation) => + installation.name === skillName && skillPlacementParticipatesInGlobalFreshness(installation) ) return ( (placements.length > 0 && diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 713ddfb7902..7cbd47bfdcb 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -198,6 +198,24 @@ describe('windows terminal capabilities', () => { expect(wslIsAvailable).toHaveBeenCalledTimes(2) }) + it('rechecks availability when distro discovery invalidates a stale failure', async () => { + const wslIsAvailable = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + vi.stubGlobal('window', { + api: { + wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue(['Ubuntu']) }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } + } + }) + + await expect(loadWindowsTerminalCapabilities()).resolves.toMatchObject({ + wslAvailable: true, + wslDistros: ['Ubuntu'] + }) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + }) + it('re-probes when the capability cache expires', async () => { const wslIsAvailable = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false) const pwshIsAvailable = vi.fn().mockResolvedValue(false) @@ -558,6 +576,49 @@ describe('windows terminal capabilities', () => { expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1) }) + it('refreshes local capabilities while a long-lived consumer remains mounted', async () => { + vi.useFakeTimers() + const { wslIsAvailable, wslListDistros } = stubTerminalCapabilityApi({ + wslAvailable: false, + pwshAvailable: true, + wslDistros: [] + }) + wslIsAvailable.mockResolvedValueOnce(false).mockResolvedValue(true) + wslListDistros.mockResolvedValueOnce([]).mockResolvedValue(['Ubuntu']) + let latest: ReturnType | null = null + + function HookProbe(): null { + latest = useWindowsTerminalCapabilities(true) + return null + } + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + hookRoots.push(root) + + try { + await act(async () => { + root.render(createElement(HookProbe)) + await Promise.resolve() + await Promise.resolve() + }) + expect(latest).toMatchObject({ wslAvailable: false, wslDistros: [] }) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await Promise.resolve() + await Promise.resolve() + }) + expect(latest).toMatchObject({ wslAvailable: true, wslDistros: ['Ubuntu'] }) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(30_000) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + it.each([ { name: 'Windows to Linux', diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 37c888f41dc..5d085cc8136 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -174,7 +174,7 @@ export function loadWindowsTerminalCapabilities( } export function refreshWindowsTerminalCapabilities( - ownerKey: string | undefined = undefined, + ownerKey?: string, target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }, sshConnectionId?: string | null ): Promise { @@ -197,7 +197,7 @@ export function selectWindowsTerminalCapabilitiesForOwner( export function useWindowsTerminalCapabilities( enabled: boolean, forceRefreshOnMount = false, - ownerKey: string | undefined = undefined, + ownerKey?: string, target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }, sshConnectionId?: string | null ): WindowsTerminalCapabilities { @@ -249,9 +249,24 @@ export function useWindowsTerminalCapabilities( setState({ ownerKey: resolvedOwnerKey, capabilities: nextCapabilities }) } }) + const refreshInterval = globalThis.setInterval(() => { + if (resolvedTarget.kind !== 'local' || sshConnectionIdKey) { + return + } + const cachedCapabilities = getCachedWindowsTerminalCapabilities(resolvedOwnerKey) + if (cachedCapabilities.wslAvailable && cachedCapabilities.wslDistros.length > 0) { + return + } + void loadWindowsTerminalCapabilities({ + ownerKey: resolvedOwnerKey, + target: resolvedTarget, + sshConnectionId: sshConnectionIdKey + }) + }, CAPABILITY_CACHE_TTL_MS) return () => { cancelled = true + globalThis.clearInterval(refreshInterval) const currentSubscribers = subscribersByOwnerKey.get(resolvedOwnerKey) currentSubscribers?.delete(setCapabilities) if (currentSubscribers?.size === 0) { diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts index e73336b424f..3c9a7edc6bc 100644 --- a/src/renderer/src/lib/windows-terminal-capability-read.ts +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -4,6 +4,18 @@ import type { WindowsTerminalCapabilities } from './windows-terminal-capabilitie export type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget +async function reconcileWslAvailability( + available: boolean, + distros: string[], + probe: () => Promise +): Promise { + // A non-empty list can invalidate the negative cache after the parallel probe read it. + if (available || distros.length === 0) { + return available + } + return probe().catch(() => false) +} + export async function readWindowsTerminalCapabilities( target: WindowsTerminalCapabilityLoadTarget, sshConnectionId?: string | null @@ -48,8 +60,11 @@ export async function readWindowsTerminalCapabilities( .then((status) => status.hostPlatform ?? null) .catch(() => null) ]) + const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () => + window.api.wsl.isAvailable() + ) return { - wslAvailable, + wslAvailable: reconciledWslAvailable, wslDistros, pwshAvailable, gitBashAvailable, @@ -76,8 +91,13 @@ export async function readWindowsTerminalCapabilities( .then((status) => status.hostPlatform ?? null) .catch(() => null) ]) + const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () => + callRuntimeRpc(target, 'host.wsl.isAvailable', undefined, { + timeoutMs: 15_000 + }) + ) return { - wslAvailable, + wslAvailable: reconciledWslAvailable, wslDistros, pwshAvailable, gitBashAvailable, diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a980c8ed730..7acc230bb5a 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,7 +1,6 @@ import './assets/main.css' import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' import { useTranslation } from 'react-i18next' import App from './App' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' @@ -14,6 +13,7 @@ import { installTypingLatencyDiagnostic } from './lib/typing-latency-diagnostic' import { shouldEnableReactGrab } from './lib/react-grab-dev-gate' import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' +import { getOrCreateRendererRoot } from './lib/react-renderer-root' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() @@ -55,7 +55,7 @@ function RendererRoot(): React.JSX.Element { ) } -createRoot(rootElement).render( +getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render( diff --git a/src/renderer/src/popout.tsx b/src/renderer/src/popout.tsx index e68565f509c..15f1be88fb6 100644 --- a/src/renderer/src/popout.tsx +++ b/src/renderer/src/popout.tsx @@ -1,7 +1,6 @@ import './assets/main.css' import { StrictMode, useEffect } from 'react' -import { createRoot } from 'react-dom/client' import { useTranslation } from 'react-i18next' import { DashboardPopoutRoot } from './components/dashboard-popout/DashboardPopoutRoot' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' @@ -15,6 +14,7 @@ import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' import { useAppStore } from './store' import type { GlobalSettings } from '../../shared/types' +import { getOrCreateRendererRoot } from './lib/react-renderer-root' // Why: the pop-out window is a separate BrowserWindow with its own React root, // so it must run the same renderer bootstrap as main.tsx (crash diagnostics, @@ -114,7 +114,7 @@ function PopoutRoot(): React.JSX.Element { ) } -createRoot(rootElement).render( +getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render( diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 5d828b36c89..5ea3510c2e0 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -36,7 +36,7 @@ type TerminalMultiplexEvent = type: 'subscribed' streamId: number streamGeneration?: string - capabilities?: { ackOutputSourceRanges?: 1 } + capabilities?: { ackOutputSourceRanges?: 1; outputPause?: 1 } } | { type: 'end'; streamId: number } | { type: 'error'; streamId: number; message?: string } @@ -58,6 +58,7 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { onData: (data: string, meta?: { seq?: number; rawLength?: number; transformed?: boolean }) => void onSnapshot: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void onSubscribed?: () => void + onOutputPauseCapability?: () => void onEnd?: () => void onError?: (message: string) => void onFitOverrideChanged?: (event: { @@ -76,6 +77,7 @@ export type RemoteRuntimeMultiplexedTerminal = { sendInput: (text: string) => boolean resize: (cols: number, rows: number) => boolean claimViewport: (cols: number, rows: number) => boolean + setOutputPaused: (paused: boolean) => boolean serializeBuffer: (opts?: { scrollbackRows?: number }) => Promise<{ data: string cols: number @@ -93,6 +95,8 @@ type RemoteRuntimeMultiplexedTerminalState = { subscriptionRequested: boolean acknowledgeOutput: boolean acknowledgeOutputSourceRanges: boolean + supportsOutputPause: boolean + outputPaused: boolean streamGeneration: string | null sourceAckedEndByte: number heldAckBytes: number @@ -110,6 +114,8 @@ type RemoteRuntimeMultiplexedTerminalState = { // Track it so a gap triggers a self-healing snapshot resync instead of // silently rendering corrupt/missing output (frame-drop resync). expectedSeq: number | undefined + // Why: compare command probes when initial/live frames supplied no high-water. + commandProbeBaselineSeq: number | undefined recoverySnapshotSeq: number | undefined resyncInFlight: boolean resyncPendingSend: boolean @@ -162,6 +168,8 @@ export const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' type E2eRemoteTerminalMultiplexAckGateSnapshot = { + droppedOutputBytes: number + droppedOutputFrames: number heldTerminalCount: number heldStreamCount: number heldAckChars: number @@ -169,6 +177,7 @@ type E2eRemoteTerminalMultiplexAckGateSnapshot = { } type E2eRemoteTerminalMultiplexAckGateApi = { + dropOutputUntilResubscribe: (terminals: string[]) => number hold: (terminals: string[]) => void release: () => void sendInput: (terminal: string, text: string) => number @@ -180,6 +189,9 @@ type E2eRemoteTerminalMultiplexAckGateWindow = Window & { } const e2eHeldRemoteAckTerminals = new Set() +const e2eDroppedOutputStreams = new Set() +let e2eDroppedOutputBytes = 0 +let e2eDroppedOutputFrames = 0 let e2eReleasedRemoteAckChars = 0 function shouldHoldE2eRemoteTerminalAck(terminal: string): boolean { @@ -198,6 +210,8 @@ function getE2eRemoteAckSnapshot(): E2eRemoteTerminalMultiplexAckGateSnapshot { } } return { + droppedOutputBytes: e2eDroppedOutputBytes, + droppedOutputFrames: e2eDroppedOutputFrames, heldTerminalCount: e2eHeldRemoteAckTerminals.size, heldStreamCount, heldAckChars, @@ -212,19 +226,52 @@ function releaseE2eRemoteTerminalAcks(): void { e2eHeldRemoteAckTerminals.clear() } +function resetE2eDroppedRemoteOutput(): void { + e2eDroppedOutputStreams.clear() + e2eDroppedOutputBytes = 0 + e2eDroppedOutputFrames = 0 +} + +function shouldDropE2eRemoteTerminalOutput( + stream: RemoteRuntimeMultiplexedTerminalState, + bytes: number +): boolean { + if (!e2eConfig.exposeStore || !e2eDroppedOutputStreams.has(stream)) { + return false + } + e2eDroppedOutputBytes += bytes + e2eDroppedOutputFrames += 1 + return true +} + function exposeE2eRemoteTerminalMultiplexAckGate(): void { if (!e2eConfig.exposeStore || typeof window === 'undefined') { return } const target = window as E2eRemoteTerminalMultiplexAckGateWindow target.__remoteTerminalMultiplexAckGate ??= { + dropOutputUntilResubscribe: (terminals) => { + resetE2eDroppedRemoteOutput() + const targets = new Set(terminals) + for (const multiplexer of multiplexers.values()) { + for (const stream of multiplexer.getStreamsForE2e()) { + if (targets.has(stream.terminal)) { + e2eDroppedOutputStreams.add(stream) + } + } + } + return e2eDroppedOutputStreams.size + }, hold: (terminals) => { releaseE2eRemoteTerminalAcks() for (const terminal of terminals) { e2eHeldRemoteAckTerminals.add(terminal) } }, - release: releaseE2eRemoteTerminalAcks, + release: () => { + releaseE2eRemoteTerminalAcks() + resetE2eDroppedRemoteOutput() + }, sendInput: (terminal, value) => { let sent = 0 for (const multiplexer of multiplexers.values()) { @@ -277,6 +324,8 @@ class RemoteRuntimeTerminalMultiplexer { subscriptionRequested: false, acknowledgeOutput: true, acknowledgeOutputSourceRanges: false, + supportsOutputPause: false, + outputPaused: false, streamGeneration: null, sourceAckedEndByte: 0, heldAckBytes: 0, @@ -290,6 +339,7 @@ class RemoteRuntimeTerminalMultiplexer { initialSnapshotReceived: false, pendingSnapshotRequest: null, expectedSeq: undefined, + commandProbeBaselineSeq: undefined, recoverySnapshotSeq: undefined, resyncInFlight: false, resyncPendingSend: false, @@ -347,6 +397,7 @@ class RemoteRuntimeTerminalMultiplexer { ) return claimed && resized }, + setOutputPaused: (paused) => this.setOutputPaused(state, paused), serializeBuffer: (opts) => this.requestSnapshot(state, opts), close: () => { if (this.streams.get(streamId) === state) { @@ -377,6 +428,7 @@ class RemoteRuntimeTerminalMultiplexer { capabilities: { ackOutput: 1, ackOutputSourceRanges: 1, + outputPause: 1, ...(args.client.type === 'desktop' ? { desktopViewportClaims: 1 } : {}) } }) @@ -495,7 +547,7 @@ class RemoteRuntimeTerminalMultiplexer { if (event.type === 'subscribed') { const capabilities = typeof event.capabilities === 'object' && event.capabilities !== null - ? (event.capabilities as { ackOutputSourceRanges?: unknown }) + ? (event.capabilities as { ackOutputSourceRanges?: unknown; outputPause?: unknown }) : null if ( capabilities?.ackOutputSourceRanges === 1 && @@ -505,6 +557,10 @@ class RemoteRuntimeTerminalMultiplexer { stream.acknowledgeOutputSourceRanges = true stream.streamGeneration = event.streamGeneration } + stream.supportsOutputPause = capabilities?.outputPause === 1 + if (stream.supportsOutputPause) { + stream.callbacks.onOutputPauseCapability?.() + } } else if (event.type === 'end') { discardOutputAcknowledgements(stream) stream.watchdog.dispose() @@ -586,6 +642,14 @@ class RemoteRuntimeTerminalMultiplexer { } return } + if ( + (frame.opcode === TerminalStreamOpcode.Output || + frame.opcode === TerminalStreamOpcode.OutputSpan) && + shouldDropE2eRemoteTerminalOutput(stream, frame.payload.byteLength) + ) { + this.queueOutputAcknowledgement(stream, frame.payload.byteLength) + return + } stream.watchdog.recordInbound() if ( frame.opcode === TerminalStreamOpcode.Output || @@ -644,6 +708,7 @@ class RemoteRuntimeTerminalMultiplexer { } if (typeof seq === 'number') { stream.expectedSeq = seq + stream.commandProbeBaselineSeq = undefined } stream.callbacks.onData(data, { seq, @@ -746,6 +811,7 @@ class RemoteRuntimeTerminalMultiplexer { if (target === 'initial') { clearResyncTimer(stream) stream.expectedSeq = typeof info?.seq === 'number' ? info.seq : undefined + stream.commandProbeBaselineSeq = undefined stream.resyncInFlight = false stream.resyncPendingSend = false stream.initialSnapshotReceived = true @@ -756,6 +822,7 @@ class RemoteRuntimeTerminalMultiplexer { if (snapshotApplied) { clearResyncTimer(stream) stream.expectedSeq = typeof info?.seq === 'number' ? info.seq : undefined + stream.commandProbeBaselineSeq = undefined stream.recoverySnapshotSeq = typeof info?.seq === 'number' ? info.seq : undefined stream.resyncAttempts = 0 stream.resyncInFlight = false @@ -988,21 +1055,73 @@ class RemoteRuntimeTerminalMultiplexer { TerminalStreamOpcode.Input, encodeTerminalStreamText(text) ) - if (sent) { + if (sent && !stream.outputPaused) { stream.watchdog.recordCommandInput(text) } return sent } + private setOutputPaused(stream: RemoteRuntimeMultiplexedTerminalState, paused: boolean): boolean { + if (!stream.supportsOutputPause || this.streams.get(stream.streamId) !== stream) { + return false + } + if (stream.outputPaused === paused) { + return true + } + const sent = this.sendFrame( + stream.streamId, + TerminalStreamOpcode.SetOutputPaused, + encodeTerminalStreamJson({ paused }) + ) + if (sent) { + stream.outputPaused = paused + } + return sent + } + private probeCommandResponse(stream: RemoteRuntimeMultiplexedTerminalState): void { void this.requestSnapshot(stream).then( - () => { + (snapshot) => { if (this.streams.get(stream.streamId) !== stream) { return } + if (typeof snapshot?.seq === 'number' && typeof stream.expectedSeq !== 'number') { + if ( + typeof stream.commandProbeBaselineSeq === 'number' && + snapshot.seq > stream.commandProbeBaselineSeq + ) { + recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_baseline_advanced', { + baselineSeq: stream.commandProbeBaselineSeq, + environmentId: this.environmentId, + snapshotSeq: snapshot.seq, + streamId: stream.streamId, + terminal: stream.terminal + }) + this.recoverStalledStream(stream) + return + } + stream.commandProbeBaselineSeq ??= snapshot.seq + } else if ( + typeof snapshot?.seq === 'number' && + typeof stream.expectedSeq === 'number' && + snapshot.seq > stream.expectedSeq + ) { + recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_detected_gap', { + deliveredSeq: stream.expectedSeq, + environmentId: this.environmentId, + snapshotSeq: snapshot.seq, + streamId: stream.streamId, + terminal: stream.terminal + }) + this.recoverStalledStream(stream) + return + } stream.watchdog.completeCommandResponseProbe() recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_succeeded', { + deliveredSeq: stream.expectedSeq ?? null, environmentId: this.environmentId, + probeBaselineSeq: stream.commandProbeBaselineSeq ?? null, + snapshotSeq: snapshot?.seq ?? null, streamId: stream.streamId, terminal: stream.terminal }) @@ -1209,6 +1328,7 @@ export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number { export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { multiplexers.clear() e2eHeldRemoteAckTerminals.clear() + resetE2eDroppedRemoteOutput() e2eReleasedRemoteAckChars = 0 } diff --git a/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts index 9d4c13bcfad..ba49c7cfa26 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts @@ -157,6 +157,71 @@ describe('remote terminal renderer backpressure', () => { stream.close() }) + it('pauses output only after the host confirms the stream capability', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-hidden', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn() } + }) + sendBinary.mockClear() + + expect(stream.setOutputPaused(true)).toBe(false) + expect(sentOpcodes()).not.toContain(TerminalStreamOpcode.SetOutputPaused) + + callbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId: stream.streamId, + capabilities: { outputPause: 1 } + } + }) + + expect(stream.setOutputPaused(true)).toBe(true) + expect(sentOpcodes()).toEqual([TerminalStreamOpcode.SetOutputPaused]) + expect( + decodeTerminalStreamJson<{ paused?: boolean }>( + decodeTerminalStreamFrame(sendBinary.mock.calls[0]![0])!.payload + ) + ).toEqual({ paused: true }) + expect(stream.setOutputPaused(true)).toBe(true) + expect(sentOpcodes()).toEqual([TerminalStreamOpcode.SetOutputPaused]) + stream.close() + }) + + it('keeps paused input immediate without arming a hidden-output probe', async () => { + vi.useFakeTimers() + try { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-hidden-input', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn() } + }) + callbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId: stream.streamId, + capabilities: { outputPause: 1 } + } + }) + expect(stream.setOutputPaused(true)).toBe(true) + sendBinary.mockClear() + + expect(stream.sendInput('\r')).toBe(true) + await vi.advanceTimersByTimeAsync(10_000) + + expect(sentOpcodes()).toEqual([TerminalStreamOpcode.Input]) + stream.close() + } finally { + vi.useRealTimers() + } + }) + it('releases unknown streams and closes malformed connections instead of leaking credit', async () => { const { getRemoteRuntimeTerminalMultiplexer } = await import('./remote-runtime-terminal-multiplexer') @@ -372,4 +437,11 @@ describe('remote terminal renderer backpressure', () => { return typeof payload?.bytes === 'number' ? [payload.bytes] : [] }) } + + function sentOpcodes(): TerminalStreamOpcode[] { + return sendBinary.mock.calls.flatMap(([bytes]) => { + const opcode = decodeTerminalStreamFrame(bytes)?.opcode + return opcode === undefined ? [] : [opcode] + }) + } }) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts index 921eb0d3a4f..1e8b239d1ce 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts @@ -142,6 +142,7 @@ describe('remote terminal stalled stream recovery', () => { client: { id: 'mac-viewer', type: 'desktop' }, callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose } }) + emitSnapshot(stream.streamId, undefined, 'baseline', 8) sendBinary.mockClear() expect(stream.sendInput('secret\r')).toBe(true) @@ -152,7 +153,7 @@ describe('remote terminal stalled stream recovery', () => { : null expect(firstRequestPayload?.requestId).toBeTypeOf('number') - emitRequestedSnapshot(stream.streamId, firstRequestPayload?.requestId ?? 0) + emitSnapshot(stream.streamId, firstRequestPayload?.requestId ?? 0, 'baseline', 8) await vi.advanceTimersByTimeAsync(0) expect(onTransportClose).not.toHaveBeenCalled() @@ -163,6 +164,138 @@ describe('remote terminal stalled stream recovery', () => { stream.close() }) + it('restarts a stream when the authoritative snapshot advanced without live output', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const onTransportClose = vi.fn() + const onData = vi.fn() + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-stale-live-tail', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData, onSnapshot: vi.fn(), onTransportClose } + }) + emitOutput(stream.streamId, 'baseline') + sendBinary.mockClear() + + expect(stream.sendInput('echo missing\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0] + const payload = request + ? decodeTerminalStreamJson<{ requestId: number }>(request.payload) + : null + emitSnapshot(stream.streamId, payload?.requestId ?? 0, 'baselinemissing', 15) + await vi.advanceTimersByTimeAsync(0) + + expect(onData).toHaveBeenCalledOnce() + expect(onTransportClose).toHaveBeenCalledWith({ recoverable: true }) + expect(sentUnsubscribeStreamIds()).toEqual([stream.streamId]) + }) + + it('establishes a probe baseline before recovering an unsequenced stream', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const onTransportClose = vi.fn() + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-first-output-lost', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose } + }) + sendBinary.mockClear() + + expect(stream.sendInput('echo first-output\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0] + const payload = request + ? decodeTerminalStreamJson<{ requestId: number }>(request.payload) + : null + emitSnapshot(stream.streamId, payload?.requestId ?? 0, 'first-output', 12) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('silent-command\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const sameRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[1] + const samePayload = sameRequest + ? decodeTerminalStreamJson<{ requestId: number }>(sameRequest.payload) + : null + emitSnapshot(stream.streamId, samePayload?.requestId ?? 0, 'first-output', 12) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('regressed-snapshot\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const regressedRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[2] + expect(regressedRequest).toBeDefined() + if (!regressedRequest) { + throw new Error('Missing regressed snapshot request') + } + const regressedPayload = decodeTerminalStreamJson<{ requestId: number }>( + regressedRequest.payload + ) + expect(regressedPayload?.requestId).toBeTypeOf('number') + if (!regressedPayload || typeof regressedPayload.requestId !== 'number') { + throw new Error('Malformed regressed snapshot request') + } + emitSnapshot(stream.streamId, regressedPayload.requestId, 'partial-output', 8) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('still-below-baseline\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const belowBaselineRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[3] + expect(belowBaselineRequest).toBeDefined() + if (!belowBaselineRequest) { + throw new Error('Missing below-baseline snapshot request') + } + const belowBaselinePayload = decodeTerminalStreamJson<{ requestId: number }>( + belowBaselineRequest.payload + ) + expect(belowBaselinePayload?.requestId).toBeTypeOf('number') + if (!belowBaselinePayload || typeof belowBaselinePayload.requestId !== 'number') { + throw new Error('Malformed below-baseline snapshot request') + } + emitSnapshot(stream.streamId, belowBaselinePayload.requestId, 'partial-output', 10) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('echo second-output\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const advancedRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[4] + const advancedPayload = advancedRequest + ? decodeTerminalStreamJson<{ requestId: number }>(advancedRequest.payload) + : null + emitSnapshot(stream.streamId, advancedPayload?.requestId ?? 0, 'second-output', 25) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).toHaveBeenCalledWith({ recoverable: true }) + expect(sentUnsubscribeStreamIds()).toEqual([stream.streamId]) + }) + + it('keeps a responsive stream when its probe confirms zero output high-water', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const onTransportClose = vi.fn() + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-unsequenced-silent', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose } + }) + sendBinary.mockClear() + + expect(stream.sendInput('silent-command\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0] + const payload = request + ? decodeTerminalStreamJson<{ requestId: number }>(request.payload) + : null + emitSnapshot(stream.streamId, payload?.requestId ?? 0, '', 0) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(sentUnsubscribeStreamIds()).toEqual([]) + stream.close() + }) + it('keeps a command stream when any host frame proves it is responsive', async () => { const { getRemoteRuntimeTerminalMultiplexer } = await import('./remote-runtime-terminal-multiplexer') @@ -240,7 +373,12 @@ describe('remote terminal stalled stream recovery', () => { ) } - function emitRequestedSnapshot(streamId: number, requestId: number): void { + function emitSnapshot( + streamId: number, + requestId: number | undefined, + data = '', + seq?: number + ): void { callbacks?.onBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.SnapshotStart, @@ -250,15 +388,26 @@ describe('remote terminal stalled stream recovery', () => { kind: 'scrollback', requestId, cols: 80, - rows: 24 + rows: 24, + seq }) }) ) + if (data) { + callbacks?.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(data) + }) + ) + } callbacks?.onBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.SnapshotEnd, streamId, - seq: 2, + seq: 3, payload: new Uint8Array() }) ) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index be485d0ef2f..75c6a5c03db 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -112,7 +112,8 @@ describe('remote runtime terminal data subscriptions', () => { expect(subscribePayload?.capabilities).toEqual({ ackOutput: 1, ackOutputSourceRanges: 1, - desktopViewportClaims: 1 + desktopViewportClaims: 1, + outputPause: 1 }) callbacks?.onBinary?.( @@ -462,6 +463,63 @@ describe('remote runtime terminal multiplex ACK gate', () => { liveTerminal.close() }) + it('drops output only from the armed stream when its replacement reuses the stream ID', async () => { + const { getRemoteRuntimeTerminalMultiplexer, resetRemoteRuntimeTerminalMultiplexersForTests } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const oldData = vi.fn() + const oldStream = await getRemoteRuntimeTerminalMultiplexer( + 'env-output-drop-gate' + ).subscribeTerminal({ + terminal: 'terminal-reused-stream-id', + client: { id: 'desktop-old', type: 'desktop' }, + callbacks: { onData: oldData, onSnapshot: vi.fn() } + }) + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + dropOutputUntilResubscribe: (terminals: string[]) => number + release: () => void + } + } + ).__remoteTerminalMultiplexAckGate + expect(gate?.dropOutputUntilResubscribe(['terminal-reused-stream-id'])).toBe(1) + + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: oldStream.streamId, + seq: 7, + payload: encodeTerminalStreamText('dropped') + }) + ) + expect(oldData).not.toHaveBeenCalled() + oldStream.close() + + const replacementData = vi.fn() + const replacement = await getRemoteRuntimeTerminalMultiplexer( + 'env-output-drop-gate' + ).subscribeTerminal({ + terminal: 'terminal-reused-stream-id', + client: { id: 'desktop-new', type: 'desktop' }, + callbacks: { onData: replacementData, onSnapshot: vi.fn() } + }) + expect(replacement.streamId).toBe(oldStream.streamId) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: replacement.streamId, + seq: 9, + payload: encodeTerminalStreamText('delivered') + }) + ) + + expect(replacementData).toHaveBeenCalledWith('delivered', { seq: 9, rawLength: 9 }) + gate?.release() + replacement.close() + }) + it('applies mid-session recovery snapshots without re-subscribing', async () => { const { getRemoteRuntimeTerminalMultiplexer } = await import('./remote-runtime-terminal-multiplexer') diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index b2049c38936..fcdfbddecb3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -1866,7 +1866,10 @@ describe('applyWebSessionTabsSnapshot', () => { stateStartedAt: NOW - 1_000, agentType: 'codex', paneKey: hostPaneKey, + tabId: 'host-tab-1', + worktreeId: WT, terminalTitle: 'codex [working]', + providerSession: { key: 'session_id', id: 'session-1' }, stateHistory: [] } } @@ -1882,6 +1885,9 @@ describe('applyWebSessionTabsSnapshot', () => { prompt: 'fix web parity', agentType: 'codex', paneKey: mirroredPaneKey, + tabId: mirroredId, + worktreeId: WT, + providerSession: { key: 'session_id', id: 'session-1' }, terminalTitle: 'codex [working]' }) expect(patch.agentStatusByPaneKey?.[hostPaneKey]).toBeUndefined() @@ -1889,7 +1895,7 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.sortEpoch).toBe(1) }) - it('bumps aggregate epochs when a mirrored same-state entry gains attribution', () => { + it('repairs mirrored same-state attribution and retains identity from an older snapshot', () => { const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) const snapshot = makeSnapshot([ { @@ -1910,6 +1916,7 @@ describe('applyWebSessionTabsSnapshot', () => { paneKey: hostPaneKey, worktreeId: WT, tabId: 'host-tab-1', + providerSession: { key: 'session_id', id: 'session-1' }, stateHistory: [] } } @@ -1922,11 +1929,15 @@ describe('applyWebSessionTabsSnapshot', () => { ) as Partial const mirroredPaneKey = Object.keys(initial.agentStatusByPaneKey ?? {})[0]! const existing = initial.agentStatusByPaneKey![mirroredPaneKey]! - const patch = applyWebSessionTabsSnapshot( + const attributionPatch = applyWebSessionTabsSnapshot( makeState({ ...initial, agentStatusByPaneKey: { - [mirroredPaneKey]: { ...existing, worktreeId: 'stale-worktree', tabId: 'stale-tab' } + [mirroredPaneKey]: { + ...existing, + worktreeId: 'stale-worktree', + tabId: 'stale-tab' + } }, agentStatusEpoch: 7, sortEpoch: 11 @@ -1936,8 +1947,109 @@ describe('applyWebSessionTabsSnapshot', () => { NOW ) as Partial - expect(patch.agentStatusEpoch).toBe(8) - expect(patch.sortEpoch).toBe(12) + expect(attributionPatch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({ + worktreeId: existing.worktreeId, + tabId: existing.tabId + }) + expect(attributionPatch.agentStatusEpoch).toBe(8) + expect(attributionPatch.sortEpoch).toBe(12) + + const fresherAttributionPatch = applyWebSessionTabsSnapshot( + makeState({ + ...initial, + agentStatusByPaneKey: { + [mirroredPaneKey]: { + ...existing, + updatedAt: NOW, + worktreeId: 'stale-worktree', + tabId: 'stale-tab', + providerSession: undefined + } + }, + agentStatusEpoch: 7, + sortEpoch: 11 + }), + { ...snapshot, snapshotVersion: 3 }, + ENV, + NOW + ) as Partial + + expect(fresherAttributionPatch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({ + worktreeId: existing.worktreeId, + tabId: existing.tabId, + updatedAt: NOW, + providerSession: { key: 'session_id', id: 'session-1' } + }) + expect(fresherAttributionPatch.agentStatusEpoch).toBe(8) + expect(fresherAttributionPatch.sortEpoch).toBe(12) + + const identityPatch = applyWebSessionTabsSnapshot( + makeState({ + ...initial, + ...attributionPatch, + agentStatusByPaneKey: { + [mirroredPaneKey]: { + ...attributionPatch.agentStatusByPaneKey![mirroredPaneKey]!, + updatedAt: NOW, + providerSession: undefined + } + } + }), + { ...snapshot, snapshotVersion: 4 }, + ENV, + NOW + ) as Partial + + expect(identityPatch.agentStatusByPaneKey?.[mirroredPaneKey]?.providerSession).toEqual({ + key: 'session_id', + id: 'session-1' + }) + expect(identityPatch.agentStatusByPaneKey?.[mirroredPaneKey]?.updatedAt).toBe(NOW) + expect(identityPatch.agentStatusEpoch).toBe(8) + expect(identityPatch.sortEpoch).toBe(12) + + const nextTurnPatch = applyWebSessionTabsSnapshot( + makeState({ + ...initial, + agentStatusByPaneKey: { + [mirroredPaneKey]: { + ...existing, + state: 'working', + updatedAt: NOW, + stateStartedAt: NOW, + worktreeId: 'stale-worktree', + tabId: 'stale-tab', + providerSession: undefined + } + } + }), + { + ...snapshot, + snapshotVersion: 5, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' && tab.agentStatus + ? { + ...tab, + agentStatus: { + ...tab.agentStatus, + state: 'done', + providerSession: { key: 'session_id', id: 'previous-session' } + } + } + : tab + ) + }, + ENV, + NOW + ) as Partial + + expect(nextTurnPatch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({ + state: 'working', + stateStartedAt: NOW, + worktreeId: WT, + tabId: existing.tabId + }) + expect(nextTurnPatch.agentStatusByPaneKey?.[mirroredPaneKey]?.providerSession).toBeUndefined() }) it('keeps mirrored OMP tabs from repainting to Pi-compatible titles', () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 022ba9503b5..ca5f56a2ae0 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -8,6 +8,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../shared/agent-status-types' +import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionBrowserTab, @@ -674,7 +675,8 @@ function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null }) return { ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent), - paneKey + paneKey, + tabId: toWebTerminalSurfaceTabId(surface.parentTabId) } } @@ -711,10 +713,23 @@ function buildMirroredAgentStatusPatch( continue } const existing = state.agentStatusByPaneKey[entry.paneKey] - // Why: an active web stream can report a fresher OSC 9999 status before the next host snapshot, so don't rewind it with an older host publication. + // Why: keep fresher OSC state while taking remapped ownership metadata from the authoritative host snapshot. + const hostIdentityPredatesCurrentTurn = + existing !== undefined && + entry.state === 'done' && + existing.state !== 'done' && + existing.stateStartedAt > entry.stateStartedAt const nextEntry = existing && existing.updatedAt > entry.updatedAt - ? normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType) + ? { + ...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType), + paneKey: entry.paneKey, + worktreeId: entry.worktreeId ?? existing.worktreeId, + tabId: entry.tabId, + providerSession: + existing.providerSession ?? + (hostIdentityPredatesCurrentTurn ? undefined : entry.providerSession) + } : entry nextByPaneKey.set(entry.paneKey, nextEntry) } @@ -1346,6 +1361,7 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn a.lastAssistantMessage === b.lastAssistantMessage && a.interrupted === b.interrupted && a.promptInteractionKey === b.promptInteractionKey && + agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && sameAgentStateHistory(a.stateHistory, b.stateHistory) ) } diff --git a/src/renderer/src/store/slices/github-checks.test.ts b/src/renderer/src/store/slices/github-checks.test.ts index 2f295ab9328..b5c46ce6882 100644 --- a/src/renderer/src/store/slices/github-checks.test.ts +++ b/src/renderer/src/store/slices/github-checks.test.ts @@ -15,6 +15,13 @@ describe('deriveCheckStatusFromChecks', () => { ] expect(deriveCheckStatusFromChecks(checks)).toBe('failure') }) + + it('keeps an unknown terminal conclusion neutral rather than pending', () => { + const checks = [ + { name: 'future-check', status: 'completed', conclusion: 'future_state', url: null } + ] as unknown as PRCheckDetail[] + expect(deriveCheckStatusFromChecks(checks)).toBe('neutral') + }) }) describe('normalizeBranchName', () => { diff --git a/src/renderer/src/store/slices/github-checks.ts b/src/renderer/src/store/slices/github-checks.ts index 93a0e28d392..46bf368d757 100644 --- a/src/renderer/src/store/slices/github-checks.ts +++ b/src/renderer/src/store/slices/github-checks.ts @@ -1,42 +1,14 @@ import type { AppState } from '../types' -import type { PRCheckDetail, CheckStatus, GitHubOwnerRepo } from '../../../../shared/types' +import type { PRCheckDetail, GitHubOwnerRepo } from '../../../../shared/types' import { getGitHubPRCacheKey } from './github-cache-key' import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' +import { derivePRCheckStatus } from '../../../../shared/pr-check-status' export function normalizeBranchName(branch: string): string { return branch.replace(/^refs\/heads\//, '') } -export function deriveCheckStatusFromChecks(checks: PRCheckDetail[]): CheckStatus { - if (checks.length === 0) { - return 'neutral' - } - - let hasPending = false - - for (const check of checks) { - if ( - check.conclusion === 'failure' || - check.conclusion === 'timed_out' || - check.conclusion === 'cancelled' || - // Why: action_required (e.g. an unapproved workflow run) blocks merge until - // someone acts; treat it as needs-attention rather than a silent pass. - check.conclusion === 'action_required' - ) { - return 'failure' - } - - if ( - check.status === 'queued' || - check.status === 'in_progress' || - check.conclusion === 'pending' - ) { - hasPending = true - } - } - - return hasPending ? 'pending' : 'success' -} +export const deriveCheckStatusFromChecks = derivePRCheckStatus export function syncPRChecksStatus( state: AppState, diff --git a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts index a6859b21bb3..24e2c770dce 100644 --- a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts @@ -23,14 +23,29 @@ const remoteRepo: Repo = { addedAt: 1 } +const staleRemoteRepo: Repo = { + ...remoteRepo, + path: '/remote-stale', + displayName: 'Remote stale' +} + +const freshRemoteRepo: Repo = { + ...remoteRepo, + path: '/remote-fresh', + displayName: 'Remote fresh' +} + const runtimeEnvironmentCall = vi.fn() +const reposList = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() runtimeEnvironmentCall.mockReset() + reposList.mockReset() + reposList.mockResolvedValue([localRepo]) vi.stubGlobal('window', { api: { - repos: { list: vi.fn().mockResolvedValue([localRepo]) }, + repos: { list: reposList }, projects: { list: vi.fn().mockResolvedValue([]), listHostSetups: vi.fn().mockResolvedValue([]) @@ -46,6 +61,185 @@ beforeEach(() => { }) describe('fetchReposForAllHosts generation', () => { + it('settles startup hydration after the local catalog without waiting for remotes', async () => { + let resolveRemote!: (value: unknown) => void + let markRemoteStarted!: () => void + const remote = new Promise((resolve) => { + resolveRemote = resolve + }) + const remoteStarted = new Promise((resolve) => { + markRemoteStarted = resolve + }) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + markRemoteStarted() + return remote + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + const load = store.getState().fetchReposForAllHosts() + await remoteStarted + await store.getState().awaitLocalRepoCatalogSettlement() + + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + + resolveRemote({ + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await load + }) + + it('does not let a remote refresh supersede or delay local startup settlement', async () => { + let resolveLocal!: (repos: Repo[]) => void + let resolveRemote!: (value: unknown) => void + const local = new Promise((resolve) => { + resolveLocal = resolve + }) + const remote = new Promise((resolve) => { + resolveRemote = resolve + }) + reposList.mockReturnValueOnce(local) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return remote + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + const remoteRefresh = store.getState().fetchRepos() + let remoteSettled = false + void remoteRefresh.then(() => { + remoteSettled = true + }) + + resolveLocal([localRepo]) + await startup + await store.getState().awaitLocalRepoCatalogSettlement() + + expect(remoteSettled).toBe(false) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + + resolveRemote({ + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await remoteRefresh + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo']) + }) + + it('keeps a newer Connect-flow catalog when an older all-host response resolves last', async () => { + let resolveOlder!: (value: unknown) => void + let markOlderStarted!: () => void + const older = new Promise((resolve) => { + resolveOlder = resolve + }) + const olderStarted = new Promise((resolve) => { + markOlderStarted = resolve + }) + let repoListCalls = 0 + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method !== 'repo.list') { + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + repoListCalls++ + if (repoListCalls === 1) { + markOlderStarted() + return older + } + return { + id: 'rpc-fresh', + ok: true, + result: { repos: [freshRemoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + const allHosts = store.getState().fetchReposForAllHosts() + await olderStarted + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + resolveOlder({ + id: 'rpc-stale', + ok: true, + result: { repos: [staleRemoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await allHosts + + expect(store.getState().repos.find((repo) => repo.id === remoteRepo.id)?.path).toBe( + freshRemoteRepo.path + ) + }) + + it('keeps a newer all-host catalog when an older Connect-flow response resolves last', async () => { + let resolveOlder!: (value: unknown) => void + const older = new Promise((resolve) => { + resolveOlder = resolve + }) + let repoListCalls = 0 + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method !== 'repo.list') { + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + repoListCalls++ + return repoListCalls === 1 + ? older + : { + id: 'rpc-fresh', + ok: true, + result: { repos: [freshRemoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + const connect = store.getState().fetchRuntimeEnvironmentRepos('env-1') + await store.getState().fetchReposForAllHosts() + + resolveOlder({ + id: 'rpc-stale', + ok: true, + result: { repos: [staleRemoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await connect + + expect(store.getState().repos.find((repo) => repo.id === remoteRepo.id)?.path).toBe( + freshRemoteRepo.path + ) + }) + it('does not validate repo UI from a superseded refresh', async () => { let resolveOlderRemote!: (value: unknown) => void let resolveNewerRemote!: (value: unknown) => void diff --git a/src/renderer/src/store/slices/repos-stale-fetch.test.ts b/src/renderer/src/store/slices/repos-stale-fetch.test.ts index a536224b860..c3c73d204ce 100644 --- a/src/renderer/src/store/slices/repos-stale-fetch.test.ts +++ b/src/renderer/src/store/slices/repos-stale-fetch.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { createTestStore } from './store-test-helpers' -import type { Repo } from '../../../../shared/types' +import { createTestStore, makeLayout, makeTab } from './store-test-helpers' +import type { Repo, WorkspaceSessionState } from '../../../../shared/types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' const localRepo: Repo = { @@ -20,6 +21,34 @@ const remoteRepo: Repo = { } const reposList = vi.fn() +const promotionWorktreeId = 'local-repo::/local' +const promotionTabId = 'restored-tab' +const promotionPtyId = 'original-daemon-pty' + +function makePromotionSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeRepoId: localRepo.id, + activeWorktreeId: promotionWorktreeId, + activeTabId: promotionTabId, + activeWorktreeIdsOnShutdown: [promotionWorktreeId], + tabsByWorktree: { + [promotionWorktreeId]: [ + makeTab({ + id: promotionTabId, + worktreeId: promotionWorktreeId, + ptyId: promotionPtyId + }) + ] + }, + terminalLayoutsByTabId: { + [promotionTabId]: { + ...makeLayout(), + ptyIdsByLeafId: { 'pane:1': promotionPtyId } + } + } + } +} beforeEach(() => { clearRuntimeCompatibilityCacheForTests() @@ -74,4 +103,96 @@ describe('repos slice stale-fetch race (#7020)', () => { await stale expect(store.getState().repos).toEqual([]) }) + + it('waits for the superseding catalog fetch before startup hydration', async () => { + let resolveStartup!: (repos: Repo[]) => void + let resolveRefresh!: (repos: Repo[]) => void + const startupRepos = new Promise((resolve) => { + resolveStartup = resolve + }) + const refreshedRepos = new Promise((resolve) => { + resolveRefresh = resolve + }) + reposList.mockReturnValueOnce(startupRepos).mockReturnValueOnce(refreshedRepos) + const store = createTestStore() + + const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + const refresh = store.getState().fetchRepos() + resolveStartup([localRepo]) + await startup + + let settled = false + const settlement = store + .getState() + .awaitLocalRepoCatalogSettlement() + .then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + resolveRefresh([localRepo]) + await Promise.all([refresh, settlement]) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + + store.getState().hydrateWorkspaceSession(makePromotionSession()) + + expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ + [promotionTabId]: promotionPtyId + }) + expect(store.getState().terminalLayoutsByTabId[promotionTabId]?.ptyIdsByLeafId).toEqual({ + 'pane:1': promotionPtyId + }) + }) + + it('waits for a refresh started after initial settlement before startup hydration', async () => { + let resolveRefresh!: (repos: Repo[]) => void + const refreshedRepos = new Promise((resolve) => { + resolveRefresh = resolve + }) + reposList.mockResolvedValueOnce([localRepo]).mockReturnValueOnce(refreshedRepos) + const store = createTestStore() + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + await store.getState().awaitLocalRepoCatalogSettlement() + const refresh = store.getState().fetchRepos() + let hydrated = false + const hydration = (async () => { + await store.getState().awaitLocalRepoCatalogSettlement() + store.getState().hydrateWorkspaceSession(makePromotionSession()) + hydrated = true + })() + + await Promise.resolve() + expect(hydrated).toBe(false) + + resolveRefresh([localRepo]) + await Promise.all([refresh, hydration]) + expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId) + expect(store.getState().pendingReconnectPtyIdByTabId[promotionTabId]).toBe(promotionPtyId) + }) + + it('rejects startup settlement when the superseding local catalog fails', async () => { + let resolveStartup!: (repos: Repo[]) => void + const startupRepos = new Promise((resolve) => { + resolveStartup = resolve + }) + reposList.mockReturnValueOnce(startupRepos).mockRejectedValueOnce(new Error('catalog failed')) + const store = createTestStore() + + const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + const refresh = store.getState().fetchRepos() + resolveStartup([localRepo]) + await Promise.all([startup, refresh]) + + const hydration = async (): Promise => { + await store.getState().awaitLocalRepoCatalogSettlement() + store.getState().hydrateWorkspaceSession(makePromotionSession()) + } + await expect(hydration()).rejects.toThrow('catalog failed') + expect(store.getState().tabsByWorktree).toEqual({}) + expect(store.getState().terminalLayoutsByTabId).toEqual({}) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({}) + }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index fc55fa9148d..dac56c84102 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1530,12 +1530,13 @@ export type RepoSlice = { folderWorkspaces: FolderWorkspace[] folderWorkspacePathStatuses: Record activeRepoId: string | null - // Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020). + // Monotonic sequence so overlapping catalog fetches can drop stale same-host results (#7020). reposFetchGeneration: number pendingSshRepoReadoptions: SshRepoReadoption[] recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void fetchRepos: () => Promise fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise + awaitLocalRepoCatalogSettlement: () => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise fetchProjectGroups: () => Promise fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise @@ -1633,6 +1634,63 @@ export type RepoSlice = { reorderRepos: (orderedIds: string[]) => Promise } +type LocalRepoCatalogFetchOutcome = + | { status: 'fulfilled' } + | { status: 'rejected'; reason: unknown } + +const latestLocalRepoCatalogFetchByStore = new WeakMap< + () => AppState, + Promise +>() +const latestRepoCatalogGenerationByHostByStore = new WeakMap<() => AppState, Map>() +const latestAllHostRepoCatalogGenerationByStore = new WeakMap<() => AppState, number>() + +function startLocalRepoCatalogFetch( + get: () => AppState +): (outcome: LocalRepoCatalogFetchOutcome) => void { + let settle: (outcome: LocalRepoCatalogFetchOutcome) => void = () => undefined + const settlement = new Promise((resolve) => { + settle = resolve + }) + latestLocalRepoCatalogFetchByStore.set(get, settlement) + return settle +} + +async function awaitLatestLocalRepoCatalogFetch(get: () => AppState): Promise { + while (true) { + const pending = latestLocalRepoCatalogFetchByStore.get(get) + if (!pending) { + return + } + const outcome = await pending + if (latestLocalRepoCatalogFetchByStore.get(get) === pending) { + if (outcome.status === 'rejected') { + throw outcome.reason + } + return + } + } +} + +function claimRepoCatalogGeneration(get: () => AppState, hostId: string, generation: number): void { + let generations = latestRepoCatalogGenerationByHostByStore.get(get) + if (!generations) { + generations = new Map() + latestRepoCatalogGenerationByHostByStore.set(get, generations) + } + if ((generations.get(hostId) ?? 0) < generation) { + generations.set(hostId, generation) + } +} + +function isLatestRepoCatalogGeneration( + get: () => AppState, + hostId: string, + generation: number +): boolean { + return latestRepoCatalogGenerationByHostByStore.get(get)?.get(hostId) === generation +} + export const createRepoSlice: StateCreator = (set, get) => ({ repos: [], projects: [], @@ -1670,17 +1728,22 @@ export const createRepoSlice: StateCreator = (set, }), fetchRepos: async () => { + const target = getActiveRuntimeTarget(get().settings) + const settleLocalCatalog: (outcome: LocalRepoCatalogFetchOutcome) => void = + target.kind === 'local' ? startLocalRepoCatalogFetch(get) : () => undefined + let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } // Why: overlapping repos:changed fetches can resolve out of order; a stale one must not overwrite a newer result and resurrect deleted projects (#7020). let generation = 0 set((s) => { generation = s.reposFetchGeneration + 1 return { reposFetchGeneration: generation } }) + const targetHostId = getRuntimeTargetHostId(target) + claimRepoCatalogGeneration(get, targetHostId, generation) try { - const target = getActiveRuntimeTarget(get().settings) const catalog = await fetchRepoCatalogForTarget(target) - // A newer fetchRepos superseded us while we awaited — drop this stale result. - if (get().reposFetchGeneration !== generation) { + // A newer same-host fetch superseded us while we awaited — drop this stale result. + if (!isLatestRepoCatalogGeneration(get, targetHostId, generation)) { return } let finalizedHostRepos: Repo[] = [] @@ -1731,7 +1794,10 @@ export const createRepoSlice: StateCreator = (set, }) scheduleSafeAutoForkSync(get, finalizedHostRepos) } catch (err) { + localCatalogOutcome = { status: 'rejected', reason: err } console.error('Failed to fetch repos:', err) + } finally { + settleLocalCatalog(localCatalogOutcome) } }, @@ -1740,11 +1806,19 @@ export const createRepoSlice: StateCreator = (set, runtimeRepoFetchGenerationByEnvironment.set(environmentId, requestGeneration) const connectionGeneration = getEnvironmentSshStateGeneration(environmentId) const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) + let catalogGeneration = 0 + set((s) => { + catalogGeneration = s.reposFetchGeneration + 1 + return { reposFetchGeneration: catalogGeneration } + }) + const target = { kind: 'environment' as const, environmentId } + const targetHostId = getRuntimeTargetHostId(target) + claimRepoCatalogGeneration(get, targetHostId, catalogGeneration) try { - const target = { kind: 'environment' as const, environmentId } const catalog = await fetchRepoCatalogForTarget(target) if ( runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || + !isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) || getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration ) { @@ -1754,6 +1828,7 @@ export const createRepoSlice: StateCreator = (set, set((s) => { if ( runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || + !isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) || getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration ) { @@ -1810,15 +1885,21 @@ export const createRepoSlice: StateCreator = (set, }, fetchReposForAllHosts: async (options) => { + const settleLocalCatalog = startLocalRepoCatalogFetch(get) let generation = 0 set((s) => { generation = s.reposFetchGeneration + 1 return { reposFetchGeneration: generation } }) + latestAllHostRepoCatalogGenerationByStore.set(get, generation) + claimRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation) // Why: fetching only the active host hides every other host's repos ("my projects vanished"); load local + all runtime envs, each failing soft. const applyCatalog = (catalog: FetchedRepoCatalog): void => { // Why: a concurrent all-host refresh must not let the older catalog resurrect a migrated SSH owner. - if (get().reposFetchGeneration !== generation) { + if ( + latestAllHostRepoCatalogGenerationByStore.get(get) !== generation || + !isLatestRepoCatalogGeneration(get, catalog.hostId, generation) + ) { return } let hostRepos: Repo[] = [] @@ -1880,13 +1961,20 @@ export const createRepoSlice: StateCreator = (set, // Local first so local repos are present even if a remote fetch stalls. let failed = false + let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } try { applyCatalog(await fetchRepoCatalogForTarget({ kind: 'local' })) } catch (err) { failed = true + localCatalogOutcome = { status: 'rejected', reason: err } console.error('Failed to fetch local repos for all-host load:', err) } - if (get().reposFetchGeneration !== generation) { + // Why: startup hydration needs the newest local catalog, not unreachable remote hosts. + settleLocalCatalog(localCatalogOutcome) + if ( + get().reposFetchGeneration !== generation && + !isLatestRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation) + ) { return } if (options?.remoteHosts === 'skip') { @@ -1897,13 +1985,13 @@ export const createRepoSlice: StateCreator = (set, // Why: unreachable remotes can spend the full connect timeout; merge each resolved host via the state updater so parallel loads don't clobber. await Promise.all( environments.map(async (environment) => { + const target = { + kind: 'environment' as const, + environmentId: environment.id + } + claimRepoCatalogGeneration(get, getRuntimeTargetHostId(target), generation) try { - applyCatalog( - await fetchRepoCatalogForTarget({ - kind: 'environment', - environmentId: environment.id - }) - ) + applyCatalog(await fetchRepoCatalogForTarget(target)) } catch (err) { failed = true console.warn(`Skipped repos for runtime environment ${environment.id}:`, err) @@ -1916,6 +2004,8 @@ export const createRepoSlice: StateCreator = (set, } }, + awaitLocalRepoCatalogSettlement: () => awaitLatestLocalRepoCatalogFetch(get), + fetchProjectGroups: async () => { try { const target = getActiveRuntimeTarget(get().settings) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 02402740316..625a8e9aba0 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -36,6 +36,7 @@ import { normalizeManualRepoOrder } from '../../../../shared/manual-repo-order' import { isTopLevelView } from '../../../../shared/top-level-view' +import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel' import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY, @@ -969,6 +970,9 @@ export type UISlice = { dismissedUpdateVersion: string | null dismissUpdate: (versionOverride?: string) => void clearDismissedUpdateVersion: () => void + /** Dev-only channel override; null follows the running build's own channel. */ + releaseChannelOverride: ReleaseChannel | null + setReleaseChannelOverride: (channel: ReleaseChannel | null) => void // Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus). updateCardCollapsed: boolean setUpdateCardCollapsed: (collapsed: boolean) => void @@ -2494,6 +2498,12 @@ export const createUISlice: StateCreator = (set, get) return DEFAULT_PET_ID })(), dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, + // Why: a persisted value from a build that knew a different channel set + // would otherwise survive as-is; activeChannel only falls back on null, + // so an unknown string reaches listBuilds and the segmented control. + releaseChannelOverride: isReleaseChannel(ui.releaseChannelOverride) + ? ui.releaseChannelOverride + : null, updateReassuranceSeen: ui.updateReassuranceSeen ?? false, osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true, browserDefaultUrl: ui.browserDefaultUrl ?? null, @@ -2588,6 +2598,11 @@ export const createUISlice: StateCreator = (set, get) clearDismissedUpdateVersion: () => { set({ dismissedUpdateVersion: null }) }, + releaseChannelOverride: null, + setReleaseChannelOverride: (channel) => { + void window.api.ui.set({ releaseChannelOverride: channel }).catch(console.error) + set({ releaseChannelOverride: channel }) + }, dismissUpdate: (versionOverride?: string) => set((s) => { // Why: the 'error' variant has no version field, so the card passes it via versionOverride. diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index dbcbff9f74e..44f45376fcf 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -31,6 +31,8 @@ import { markHugeRepoWarningDismissed } from '@/lib/source-control-huge-repo-warning-dismissals' +const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) + vi.mock('sonner', () => ({ toast: { warning: vi.fn(), @@ -41,6 +43,10 @@ vi.mock('sonner', () => ({ } })) +vi.mock('@/components/worktree-base-fallback-notice', () => ({ + requestWorktreeBaseFallbackNotice +})) + const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() const worktreeListMock = vi.fn().mockResolvedValue([]) @@ -3969,6 +3975,24 @@ describe('createWorktree base status merge', () => { }) }) + it('requests a warning dialog when creation falls back to a local base', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1' + }) + const baseFallback = { + requestedRef: 'origin/main', + localRef: 'main' + } + mockApi.worktrees.create.mockResolvedValue({ worktree: wt, baseFallback }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + expect(requestWorktreeBaseFallbackNotice).toHaveBeenCalledWith(baseFallback) + }) + it.each([ { status: 'skipped_dirty_worktree', diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 27f04a3c8eb..f51e76fef6e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,6 +71,7 @@ import { } from '@/lib/source-control-huge-repo-warning-dismissals' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' +import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, @@ -3785,6 +3786,9 @@ export const createWorktreeSlice: StateCreator } }) showLocalBaseRefRefreshToast(result.localBaseRefRefresh) + if (result.baseFallback) { + requestWorktreeBaseFallbackNotice(result.baseFallback) + } showLocalBaseRefUpdateSuggestionToast(result.localBaseRefUpdateSuggestion, { updateSettings: get().updateSettings, getSettings: () => get().settings, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2ae7a4d50c3..6e6367a5afa 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3070,6 +3070,17 @@ function createUpdaterApi(): NonNullable['updater']> { quitAndInstall: () => Promise.resolve(), dismissNudge: () => Promise.resolve(), dismissAvailableUpdate: () => Promise.resolve(), + // Why: the web client cannot install a desktop build, so channel switching + // reports unavailable rather than an empty list that looks like a fetch miss. + listBuilds: (channel) => + Promise.resolve({ + ok: false, + channel, + message: translate( + 'auto.components.settings.ReleaseChannelSection.webUnavailable', + 'Switching builds is only available in the desktop app.' + ) + }), onStatus: () => noopUnsubscribe, onClearDismissal: () => noopUnsubscribe } diff --git a/src/shared/browser-find-source.ts b/src/shared/browser-find-source.ts new file mode 100644 index 00000000000..8b3dbe5c7b1 --- /dev/null +++ b/src/shared/browser-find-source.ts @@ -0,0 +1,17 @@ +export type BrowserFindSource = { + browserPageId: string + browserWorkspaceId: string +} + +export function isBrowserFindSource(value: unknown): value is BrowserFindSource { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const source = value as Partial + return ( + typeof source.browserPageId === 'string' && + source.browserPageId.length > 0 && + typeof source.browserWorkspaceId === 'string' && + source.browserWorkspaceId.length > 0 + ) +} diff --git a/src/shared/browser-grab-types.ts b/src/shared/browser-grab-types.ts index f1dd59656a8..09a0ae07bc8 100644 --- a/src/shared/browser-grab-types.ts +++ b/src/shared/browser-grab-types.ts @@ -121,7 +121,11 @@ export type BrowserSetGrabModeArgs = { } /** Why a grab IPC call was rejected before the operation could start. */ -export type BrowserGrabRejectReason = 'not-ready' | 'not-authorized' | 'already-active' +export type BrowserGrabRejectReason = + | 'not-ready' + | 'not-authorized' + | 'already-active' + | 'injection-failed' export type BrowserSetGrabModeResult = { ok: true } | { ok: false; reason: BrowserGrabRejectReason } diff --git a/src/shared/daemon-audit-eligibility.ts b/src/shared/daemon-audit-eligibility.ts new file mode 100644 index 00000000000..5e3bde6fcd0 --- /dev/null +++ b/src/shared/daemon-audit-eligibility.ts @@ -0,0 +1,79 @@ +export const DAEMON_AUDIT_STATE_VALUES = ['present', 'gone', 'unknown'] as const + +export const DAEMON_AUDIT_TRIGGER_VALUES = [ + 'inventory_answered', + 'inventory_failed', + 'token_missing_after_authenticated_disconnect', + 'transport_closed' +] as const + +export const DAEMON_EVIDENCE_SOURCE_VALUES = [ + 'authenticated_inventory', + 'boot_identity', + 'endpoint_identity', + 'endpoint_stat', + 'linux_proc_stat', + 'pid_record', + 'process_command_line', + 'process_signal', + 'process_start_time', + 'token_file', + 'windows_cim', + 'windows_named_pipe' +] as const + +export const DAEMON_PROCESS_PRESENT_REASON_VALUES = [ + 'linux_identity_match', + 'macos_identity_match', + 'windows_identity_match' +] as const + +export const DAEMON_PROCESS_GONE_REASON_VALUES = [ + 'linux_boot_changed', + 'linux_start_ticks_mismatch', + 'linux_zombie', + 'pid_missing', + 'windows_creation_time_mismatch', + 'windows_process_missing' +] as const + +export const DAEMON_PROCESS_UNKNOWN_REASON_VALUES = [ + 'command_line_mismatch', + 'command_line_unavailable', + 'exact_identity_unavailable', + 'inspection_failed', + 'linux_identity_incomplete', + 'macos_start_time_mismatch', + 'permission_denied', + 'process_start_time_unavailable', + 'windows_process_start_time_unavailable' +] as const + +export const DAEMON_AUDIT_GONE_REASON_VALUES = [ + ...DAEMON_PROCESS_GONE_REASON_VALUES, + 'windows_named_pipe_missing' +] as const + +export const DAEMON_AUDIT_REASON_VALUES = [ + 'authenticated_inventory', + 'inventory_failed', + 'token_missing_after_authenticated_disconnect', + 'transport_closed', + ...DAEMON_AUDIT_GONE_REASON_VALUES +] as const + +export const DAEMON_AUDIT_PROCESS_REASON_VALUES = [ + ...DAEMON_PROCESS_PRESENT_REASON_VALUES, + ...DAEMON_PROCESS_GONE_REASON_VALUES, + ...DAEMON_PROCESS_UNKNOWN_REASON_VALUES, + 'windows_named_pipe_missing' +] as const + +export type DaemonAuditState = (typeof DAEMON_AUDIT_STATE_VALUES)[number] +export type DaemonAuditTrigger = (typeof DAEMON_AUDIT_TRIGGER_VALUES)[number] +export type DaemonAuditFailureTrigger = Exclude +export type DaemonEvidenceSource = (typeof DAEMON_EVIDENCE_SOURCE_VALUES)[number] +export type DaemonProcessPresentReason = (typeof DAEMON_PROCESS_PRESENT_REASON_VALUES)[number] +export type DaemonProcessGoneReason = (typeof DAEMON_PROCESS_GONE_REASON_VALUES)[number] +export type DaemonProcessUnknownReason = (typeof DAEMON_PROCESS_UNKNOWN_REASON_VALUES)[number] +export type DaemonAuditGoneReason = (typeof DAEMON_AUDIT_GONE_REASON_VALUES)[number] diff --git a/src/shared/gitlab-pipeline-checks.test.ts b/src/shared/gitlab-pipeline-checks.test.ts index 673fcf3cd16..48faafc38e0 100644 --- a/src/shared/gitlab-pipeline-checks.test.ts +++ b/src/shared/gitlab-pipeline-checks.test.ts @@ -49,7 +49,7 @@ describe('gitLabPipelineJobsToPRChecks', () => { { name: 'deploy: deploy', status: 'completed', - conclusion: 'neutral', + conclusion: 'action_required', url: null }, { diff --git a/src/shared/gitlab-pipeline-checks.ts b/src/shared/gitlab-pipeline-checks.ts index efcea5ee4bf..a988967fa32 100644 --- a/src/shared/gitlab-pipeline-checks.ts +++ b/src/shared/gitlab-pipeline-checks.ts @@ -35,10 +35,8 @@ export function mapGitLabPipelineJobStatusToConclusion( if (s === 'skipped') { return 'skipped' } - // Why: manual GitLab jobs are intentionally waiting for a human trigger; - // treating them as pending would make the Checks tab look stuck forever. - if (s === 'manual') { - return 'neutral' + if (s === 'manual' || s === 'action_required') { + return 'action_required' } if ( s === 'created' || diff --git a/src/shared/gitlab-types.ts b/src/shared/gitlab-types.ts index 5d007d7f923..503f2e36055 100644 --- a/src/shared/gitlab-types.ts +++ b/src/shared/gitlab-types.ts @@ -1,5 +1,6 @@ /* GitLab-specific shared types, split from `./types` to avoid merge conflicts on upstream syncs; re-exported from `./types` for import stability. */ -import type { CheckStatus, ClassifiedError, PRConflictSummary } from './types' +import type { CheckStatus, ClassifiedError, PRConflictSummary, ProviderCheckSummary } from './types' +import type { HostedReviewDecision } from './hosted-review' // Why: flat owner/repo is inadequate — projects nest (`group/subgroup/project`) and self-hosted hosts must travel with the path for URL/glab targeting. export type GitLabProjectRef = { host: string; path: string } @@ -172,6 +173,10 @@ export type GitLabWorkItem = { repoId: string /** Exact GitLab project that produced this row — mutations/details use it instead of re-resolving the repo preference. */ projectRef?: GitLabProjectRef + checksSummary?: ProviderCheckSummary + mergeable?: MRMergeableState + reviewDecision?: HostedReviewDecision + reviewerCount?: number } export type GitLabMRFile = { diff --git a/src/shared/hosted-review-github.ts b/src/shared/hosted-review-github.ts index e7db9111f03..6fc90a39aaf 100644 --- a/src/shared/hosted-review-github.ts +++ b/src/shared/hosted-review-github.ts @@ -1,5 +1,6 @@ import type { PRCheckDetail, PRComment, PRInfo } from './types' import type { HostedReviewInfo, HostedReviewQueueSummary } from './hosted-review' +import { derivePRCheckStatus } from './pr-check-status' export type HostedReviewFromGitHubPRInfoArgs = { pr: PRInfo @@ -32,33 +33,10 @@ function deriveChecksStatus( prChecksStatus: PRInfo['checksStatus'], checks?: PRCheckDetail[] ): PRInfo['checksStatus'] { - if (!checks || checks.length === 0) { + if (!checks) { return prChecksStatus } - const hasFailure = checks.some( - (check) => - check.conclusion === 'failure' || - check.conclusion === 'timed_out' || - check.conclusion === 'cancelled' || - // Why: action_required (e.g. a workflow awaiting approval) blocks merge; - // treat it as failure so the review queue doesn't report a clean PR. - check.conclusion === 'action_required' - ) - if (hasFailure) { - return 'failure' - } - const hasPending = checks.some( - (check) => - check.status !== 'completed' || check.conclusion === null || check.conclusion === 'pending' - ) - if (hasPending) { - return 'pending' - } - const hasSuccess = checks.some((check) => check.conclusion === 'success') - if (hasSuccess) { - return 'success' - } - return 'neutral' + return derivePRCheckStatus(checks) } export function hostedReviewSummaryFromGitHubPRInfo( diff --git a/src/shared/hosted-review-gitlab.ts b/src/shared/hosted-review-gitlab.ts index 401c6191714..3253ee9ff16 100644 --- a/src/shared/hosted-review-gitlab.ts +++ b/src/shared/hosted-review-gitlab.ts @@ -1,5 +1,6 @@ import type { HostedReviewInfo, HostedReviewQueueSummary } from './hosted-review' import type { PRCheckDetail, PRComment } from './types' +import { derivePRCheckStatus } from './pr-check-status' export type HostedReviewFromGitLabInfoArgs = { review: HostedReviewInfo & { provider: 'gitlab' } @@ -58,27 +59,10 @@ function deriveChecksStatus( reviewStatus: HostedReviewInfo['status'], checks?: PRCheckDetail[] ): HostedReviewInfo['status'] { - if (!checks || checks.length === 0) { + if (!checks) { return reviewStatus } - const hasFailure = checks.some( - (check) => check.conclusion === 'failure' || check.conclusion === 'timed_out' - ) - if (hasFailure) { - return 'failure' - } - const hasPending = checks.some( - (check) => - check.status !== 'completed' || check.conclusion === null || check.conclusion === 'pending' - ) - if (hasPending) { - return 'pending' - } - const hasSuccess = checks.some((check) => check.conclusion === 'success') - if (hasSuccess) { - return 'success' - } - return 'neutral' + return derivePRCheckStatus(checks) } export function hostedReviewSummaryFromGitLabInfo( diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 20f3938da81..edf2bfa22c6 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -286,6 +286,25 @@ describe('keybindings', () => { expect(keybindingMatchesAction('editor.addReviewNote', oldCtrlAltChord, 'win32')).toBe(false) }) + it('maps browser Find to Command on macOS and Control elsewhere', () => { + const commandF = { + key: 'f', + code: 'KeyF', + meta: true, + control: false, + alt: false, + shift: false + } + const controlF = { ...commandF, meta: false, control: true } + + expect(keybindingMatchesAction('browser.find', commandF, 'darwin')).toBe(true) + expect(keybindingMatchesAction('browser.find', controlF, 'darwin')).toBe(false) + expect(keybindingMatchesAction('browser.find', controlF, 'linux')).toBe(true) + expect(keybindingMatchesAction('browser.find', controlF, 'win32')).toBe(true) + expect(keybindingMatchesAction('browser.find', commandF, 'linux')).toBe(false) + expect(keybindingMatchesAction('browser.find', commandF, 'win32')).toBe(false) + }) + it('defines platform-native replace-in-editor shortcuts', () => { expect(getEffectiveKeybindingsForAction('editor.replace', 'darwin')).toEqual(['Mod+Alt+F']) expect(getEffectiveKeybindingsForAction('editor.replace', 'linux')).toEqual(['Mod+H']) diff --git a/src/shared/local-agent-install-dir-detection.ts b/src/shared/local-agent-install-dir-detection.ts new file mode 100644 index 00000000000..9b29a142f9e --- /dev/null +++ b/src/shared/local-agent-install-dir-detection.ts @@ -0,0 +1,17 @@ +import path from 'node:path' +import { resolveCliCommands } from './node-cli-command-resolution' + +// Why: detection may precede shell-PATH hydration, but the fallback stays bounded. +export function detectCommandsInInstallDirs(commands: readonly string[]): Set { + if (commands.length === 0) { + return new Set() + } + try { + const resolvedCommands = resolveCliCommands(commands) + return new Set( + commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command)) + ) + } catch { + return new Set() + } +} diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts new file mode 100644 index 00000000000..44e85c4dd08 --- /dev/null +++ b/src/shared/node-cli-command-resolution.ts @@ -0,0 +1,218 @@ +import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { delimiter, dirname, join } from 'node:path' + +type ResolveCommandOptions = { + pathEnv?: string | null + platform?: NodeJS.Platform + homePath?: string +} + +function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { + if (platform === 'win32') { + return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName] + } + + return [commandName] +} + +function splitPath(pathEnv: string | null | undefined): string[] { + if (!pathEnv) { + return [] + } + + return pathEnv + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function parseVersionSegment(raw: string): number[] { + return raw + .replace(/^v/i, '') + .split('.') + .map((segment) => Number.parseInt(segment, 10)) + .map((segment) => (Number.isFinite(segment) ? segment : 0)) +} + +function compareVersionDesc(left: string, right: string): number { + const leftParts = parseVersionSegment(left) + const rightParts = parseVersionSegment(right) + const length = Math.max(leftParts.length, rightParts.length) + + for (let index = 0; index < length; index += 1) { + const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) + if (delta !== 0) { + return delta + } + } + + return right.localeCompare(left) +} + +function findFirstExecutable( + platform: NodeJS.Platform, + directories: string[], + executableNames: string[] +): string | null { + for (const directory of directories) { + for (const executableName of executableNames) { + const candidate = join(directory, executableName) + if (isRunnableCommand(platform, candidate)) { + return candidate + } + } + } + + return null +} + +function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { + try { + const stats = statSync(candidate) + if (!stats.isFile()) { + return false + } + if (platform === 'win32') { + return true + } + // Why: GUI fallback probing should skip placeholders/directories so spawn + // can continue to a runnable CLI instead of failing later with EACCES/EISDIR. + accessSync(candidate, constants.X_OK) + return true + } catch { + return false + } +} + +function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] { + const directories = [ + join(homePath, '.volta', 'bin'), + join(homePath, '.asdf', 'shims'), + join(homePath, '.fnm', 'aliases', 'default', 'bin'), + // Why: mise (formerly rtx) exposes managed tool binaries via a shims + // directory, similar to asdf. + join(homePath, '.local', 'share', 'mise', 'shims') + ] + + if (platform === 'win32') { + // Why: Anthropic's native Windows installer places claude.exe here, and + // GUI-launched Orca may not inherit the user's PATH entry for it. + directories.push(join(homePath, '.local', 'bin')) + directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) + directories.push(join(homePath, 'AppData', 'Local', 'pnpm')) + directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin')) + } else { + directories.push(join(homePath, '.local', 'bin')) + // Why: pnpm uses platform-specific global bin directories that differ from + // npm's ~/.local/bin. + if (platform === 'darwin') { + directories.push(join(homePath, 'Library', 'pnpm')) + } else { + directories.push(join(homePath, '.local', 'share', 'pnpm')) + } + directories.push(join(homePath, '.yarn', 'bin')) + } + + directories.push(join(homePath, '.bun', 'bin')) + return directories +} + +function getNvmVersionDirectories(homePath: string): string[] { + const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node') + if (!existsSync(nvmVersionsDir)) { + return [] + } + + return readdirSync(nvmVersionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareVersionDesc) + .map((entry) => join(nvmVersionsDir, entry, 'bin')) +} + +function getVersionManagerDirectories( + platform: NodeJS.Platform, + homePath: string, + executableNames: string[] +): string[] { + const directories = getBaseVersionManagerDirectories(platform, homePath) + const firstNvmMatch = findFirstExecutable( + platform, + getNvmVersionDirectories(homePath), + executableNames + ) + if (firstNvmMatch) { + directories.unshift(dirname(firstNvmMatch)) + } + return directories +} + +export function resolveCliCommand( + commandName: string, + options: ResolveCommandOptions = {} +): string { + const platform = options.platform ?? process.platform + const executableNames = getExecutableNames(platform, commandName) + const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null + const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames) + if (pathCandidate) { + return pathCandidate + } + + const homePath = options.homePath ?? homedir() + const nvmCandidate = findFirstExecutable( + platform, + getNvmVersionDirectories(homePath), + executableNames + ) + const versionManagerCandidate = + nvmCandidate ?? + findFirstExecutable( + platform, + getBaseVersionManagerDirectories(platform, homePath), + executableNames + ) + return versionManagerCandidate ?? commandName +} + +export function resolveCliCommands( + commandNames: readonly string[], + options: ResolveCommandOptions = {} +): Map { + const platform = options.platform ?? process.platform + const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null + const pathDirectories = splitPath(pathEnv) + const homePath = options.homePath ?? homedir() + const installDirectories = [ + ...getNvmVersionDirectories(homePath), + ...getBaseVersionManagerDirectories(platform, homePath) + ] + const resolved = new Map() + + for (const commandName of new Set(commandNames)) { + const executableNames = getExecutableNames(platform, commandName) + const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames) + const installCandidate = + pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames) + resolved.set(commandName, installCandidate ?? commandName) + } + + return resolved +} + +export function resolveCodexCommand(options: ResolveCommandOptions = {}): string { + return resolveCliCommand('codex', options) +} + +export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string { + return resolveCliCommand('claude', options) +} + +// Why: Node-script CLIs need their version-manager sibling `node` on PATH. +export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] { + const platform = options.platform ?? process.platform + const homePath = options.homePath ?? homedir() + const nodeNames = getExecutableNames(platform, 'node') + return getVersionManagerDirectories(platform, homePath, nodeNames) +} diff --git a/src/shared/pi-agent-kind.test.ts b/src/shared/pi-agent-kind.test.ts index a05aef186dc..afef68c7bba 100644 --- a/src/shared/pi-agent-kind.test.ts +++ b/src/shared/pi-agent-kind.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { detectPiAgentKindFromCommand } from './pi-agent-kind' +import { detectExplicitPiAgentKindFromCommand, detectPiAgentKindFromCommand } from './pi-agent-kind' describe('detectPiAgentKindFromCommand', () => { it('returns "pi" for undefined or empty commands', () => { @@ -49,3 +49,25 @@ describe('detectPiAgentKindFromCommand', () => { expect(detectPiAgentKindFromCommand('PI.CMD')).toBe('pi') }) }) + +describe('detectExplicitPiAgentKindFromCommand', () => { + it('identifies explicit Pi and OMP launches', () => { + expect(detectExplicitPiAgentKindFromCommand('pi --resume')).toBe('pi') + expect(detectExplicitPiAgentKindFromCommand('/usr/local/bin/omp.sh')).toBe('omp') + expect(detectExplicitPiAgentKindFromCommand('PI.CMD')).toBe('pi') + }) + + it('does not classify bare shells or other agents as Pi launches', () => { + expect(detectExplicitPiAgentKindFromCommand(undefined)).toBeNull() + expect(detectExplicitPiAgentKindFromCommand('')).toBeNull() + expect(detectExplicitPiAgentKindFromCommand('codex --resume session-a')).toBeNull() + expect(detectExplicitPiAgentKindFromCommand('codex "ask about pi"')).toBeNull() + expect(detectExplicitPiAgentKindFromCommand('claude')).toBeNull() + expect(detectExplicitPiAgentKindFromCommand('python3 script.py')).toBeNull() + }) + + it('classifies the launched agent instead of mentions in its arguments', () => { + expect(detectExplicitPiAgentKindFromCommand('pi "compare omp"')).toBe('pi') + expect(detectExplicitPiAgentKindFromCommand('omp "compare pi"')).toBe('omp') + }) +}) diff --git a/src/shared/pi-agent-kind.ts b/src/shared/pi-agent-kind.ts index 109f0caae0a..82bd60aff99 100644 --- a/src/shared/pi-agent-kind.ts +++ b/src/shared/pi-agent-kind.ts @@ -24,30 +24,24 @@ export function isPiCompatibleAgentType( return agentType === 'pi' || agentType === 'omp' } -const OMP_LAUNCH_CMD = TUI_AGENT_CONFIG.omp.launchCmd - -// Why: regex carved to avoid matching `pi` inside `pip`, `mpi`, `api`, -// `python`, or `omp` inside `comp`, `omp.sh` (acceptable - that's literally -// the binary), `omp-foo`, etc. The leading boundary excludes alnum/underscore -// AND `-`/`.`/`/`/`\\` so that `~/bin/pi` or `./omp` still match but -// `mpi`/`pomp` do not. Trailing boundary allows whitespace, end-of-string, -// shell separators, or argv-style flags (`pi -v`, `omp --help`). -const BOUNDARY_BEFORE = `(?:^|[\\s;&|('"\`])` -const BOUNDARY_AFTER = `(?:$|[\\s;&|)'"\`])` -const PATH_PREFIX = `(?:[^\\s;&|('"\`]*[\\\\/])?` - -function makeLaunchCmdRegex(launchCmd: string): RegExp { - // Why: launchCmd may be a multi-token string ("hermes --tui"); only the - // first token is the binary name. Use that for matching. - const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCmd)) - const escaped = binary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return new RegExp( - `${BOUNDARY_BEFORE}${PATH_PREFIX}${escaped}(?:\\.cmd|\\.exe|\\.sh)?${BOUNDARY_AFTER}`, - 'i' - ) +function getLaunchBinary(command: string): string { + return getCommandTokenPathBasename(getFirstCommandToken(command)) + .toLowerCase() + .replace(/\.(?:cmd|exe|sh)$/, '') } -const OMP_REGEX = makeLaunchCmdRegex(OMP_LAUNCH_CMD) +const PI_LAUNCH_BINARY = getLaunchBinary(TUI_AGENT_CONFIG.pi.launchCmd) +const OMP_LAUNCH_BINARY = getLaunchBinary(TUI_AGENT_CONFIG.omp.launchCmd) + +export function detectExplicitPiAgentKindFromCommand( + command: string | undefined +): PiAgentKind | null { + const binary = getLaunchBinary(command ?? '') + if (binary === OMP_LAUNCH_BINARY) { + return 'omp' + } + return binary === PI_LAUNCH_BINARY ? 'pi' : null +} /** * Identify the Pi-compatible agent kind a launch command targets. @@ -62,9 +56,11 @@ const OMP_REGEX = makeLaunchCmdRegex(OMP_LAUNCH_CMD) * be substituted. */ export function detectPiAgentKindFromCommand(command: string | undefined): PiAgentKind { - if (typeof command === 'string' && OMP_REGEX.test(command)) { - return 'omp' + const explicitKind = detectExplicitPiAgentKindFromCommand(command) + if (explicitKind) { + return explicitKind } + // Why: PI launches and the no-command (bare-shell) fallback both resolve to // 'pi'. A bare shell that later invokes `pi` keeps the historical default; // if it later invokes `omp`, the status extension re-routes at runtime based diff --git a/src/shared/pr-check-severity-order.test.ts b/src/shared/pr-check-severity-order.test.ts new file mode 100644 index 00000000000..126ab012723 --- /dev/null +++ b/src/shared/pr-check-severity-order.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import type { PRCheckDetail } from './types' +import { getCheckSeverityRank, sortChecksBySeverity } from './pr-check-severity-order' +import { mapGitLabPipelineJobStatusToConclusion } from './gitlab-pipeline-checks' + +const check = (name: string, conclusion: string | null) => + ({ name, conclusion }) as Pick + +describe('PR check severity order', () => { + it('keeps passing checks above the skipped and neutral noise', () => { + const sorted = sortChecksBySeverity([ + check('daily-cleanup', 'skipped'), + check('Test Results', 'success'), + check('build-admin', 'skipped'), + check('advisory', 'neutral'), + check('Validate Configs', 'success') + ]) + + expect(sorted.map((c) => c.name)).toEqual([ + 'Test Results', + 'Validate Configs', + 'advisory', + 'daily-cleanup', + 'build-admin' + ]) + }) + + it('orders failures, then in-flight work, then successes', () => { + const sorted = sortChecksBySeverity([ + check('success', 'success'), + check('skipped', 'skipped'), + check('pending', null), + check('cancelled', 'cancelled'), + check('failure', 'failure') + ]) + + expect(sorted.map((c) => c.name)).toEqual([ + 'failure', + 'cancelled', + 'pending', + 'success', + 'skipped' + ]) + }) + + it('covers every known conclusion and preserves input order within equal ranks', () => { + const sorted = sortChecksBySeverity([ + check('success-a', 'success'), + check('unknown', 'future_state'), + check('skipped', 'skipped'), + check('action-required', 'action_required'), + check('pending', 'pending'), + check('timed-out', 'timed_out'), + check('neutral', 'neutral'), + check('cancelled', 'cancelled'), + check('failure', 'failure'), + check('success-b', 'success'), + check('failure-b', 'failure-b') + ]) + + expect(sorted.map((c) => c.name)).toEqual([ + 'action-required', + 'timed-out', + 'failure', + 'cancelled', + 'pending', + 'success-a', + 'success-b', + 'neutral', + 'skipped', + 'unknown', + 'failure-b' + ]) + }) + + it('sinks unknown conclusions below every known state', () => { + expect(getCheckSeverityRank('stale_from_a_future_github')).toBeGreaterThan( + getCheckSeverityRank('skipped') + ) + }) + + // Why: an object-literal rank table would resolve these off Object.prototype and + // return a function, turning the comparator into NaN and scrambling the list. + it('treats prototype property names as unknown conclusions', () => { + for (const inherited of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) { + expect(getCheckSeverityRank(inherited)).toBe( + getCheckSeverityRank('stale_from_a_future_github') + ) + } + + const sorted = sortChecksBySeverity([ + check('inherited', 'constructor'), + check('failure', 'failure'), + check('success', 'success') + ]) + expect(sorted.map((c) => c.name)).toEqual(['failure', 'success', 'inherited']) + }) + + it('treats a missing conclusion as pending', () => { + expect(getCheckSeverityRank(null)).toBe(getCheckSeverityRank('pending')) + expect(getCheckSeverityRank(undefined)).toBe(getCheckSeverityRank('pending')) + }) + + it('leaves the input array untouched', () => { + const checks = [check('success', 'success'), check('failure', 'failure')] + sortChecksBySeverity(checks) + expect(checks.map((c) => c.name)).toEqual(['success', 'failure']) + }) + + it('orders normalized provider states with stable ties', () => { + const checks = [ + // Preserve an unrecognized provider value so it remains visibly unknown. + check('future', 'future_state'), + check('manual', mapGitLabPipelineJobStatusToConclusion('manual')), + check('pass-a', 'success'), + check('pass-b', 'success') + ] + + expect(sortChecksBySeverity(checks).map((item) => item.name)).toEqual([ + 'manual', + 'pass-a', + 'pass-b', + 'future' + ]) + }) +}) diff --git a/src/shared/pr-check-severity-order.ts b/src/shared/pr-check-severity-order.ts new file mode 100644 index 00000000000..0bf9effba63 --- /dev/null +++ b/src/shared/pr-check-severity-order.ts @@ -0,0 +1,36 @@ +import type { PRCheckDetail } from './types' + +// Why: `neutral`/`skipped` carry no signal, so they sink below `success` — otherwise a +// wall of skipped jobs buries the passing checks a reviewer actually reads. +// A Map, not an object: conclusions arrive from provider payloads, and an object +// would resolve `constructor`/`toString` off the prototype into a non-number rank. +const CHECK_SEVERITY_RANK = new Map([ + ['failure', 0], + ['timed_out', 0], + ['action_required', 0], + ['cancelled', 1], + ['pending', 2], + ['success', 3], + ['neutral', 4], + ['skipped', 5] +]) + +// Why: an unrecognized conclusion sinks to the bottom instead of masquerading as `neutral`. +const UNKNOWN_CHECK_RANK = 6 + +export function getCheckSeverityRank(conclusion: string | null | undefined): number { + return CHECK_SEVERITY_RANK.get(conclusion ?? 'pending') ?? UNKNOWN_CHECK_RANK +} + +export function sortChecksBySeverity>( + checks: readonly T[] +): T[] { + return checks + .map((check, index) => ({ check, index })) + .sort( + (a, b) => + getCheckSeverityRank(a.check.conclusion) - getCheckSeverityRank(b.check.conclusion) || + a.index - b.index + ) + .map(({ check }) => check) +} diff --git a/src/shared/pr-check-status.test.ts b/src/shared/pr-check-status.test.ts new file mode 100644 index 00000000000..b924b68bc19 --- /dev/null +++ b/src/shared/pr-check-status.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { derivePRCheckStatus, derivePRCheckStatusFromRollup } from './pr-check-status' +import type { PRCheckDetail } from './types' + +const check = ( + status: PRCheckDetail['status'], + conclusion: PRCheckDetail['conclusion'] +): PRCheckDetail => ({ name: 'ci', status, conclusion, url: null }) + +describe('provider-neutral check status', () => { + it('keeps explicit nonterminal and missing-conclusion checks pending', () => { + expect(derivePRCheckStatus([check('queued', null)])).toBe('pending') + expect(derivePRCheckStatus([check('in_progress', null)])).toBe('pending') + expect(derivePRCheckStatus([check('completed', 'pending')])).toBe('pending') + }) + + it('keeps completed unknown conclusions neutral while preserving attention states', () => { + expect(derivePRCheckStatus([check('completed', null)])).toBe('neutral') + expect( + derivePRCheckStatus([check('completed', 'future_state' as PRCheckDetail['conclusion'])]) + ).toBe('neutral') + expect(derivePRCheckStatus([check('completed', 'action_required')])).toBe('failure') + }) + + it('normalizes GitHub-style rollups without turning malformed data into success', () => { + expect(derivePRCheckStatusFromRollup([{ status: 'IN_PROGRESS', conclusion: null }])).toBe( + 'pending' + ) + expect( + derivePRCheckStatusFromRollup([{ status: 'COMPLETED', conclusion: 'future_state' }]) + ).toBe('neutral') + expect(derivePRCheckStatusFromRollup([{}])).toBe('neutral') + expect(derivePRCheckStatusFromRollup([{ state: 'PENDING' }])).toBe('pending') + expect(derivePRCheckStatusFromRollup([{ state: 'ERROR' }])).toBe('failure') + }) + + it.each(['ERROR', 'STARTUP_FAILURE'])('treats raw %s conclusions as failures', (conclusion) => { + expect(derivePRCheckStatusFromRollup([{ status: 'COMPLETED', conclusion }])).toBe('failure') + }) +}) diff --git a/src/shared/pr-check-status.ts b/src/shared/pr-check-status.ts new file mode 100644 index 00000000000..2f8e68e1546 --- /dev/null +++ b/src/shared/pr-check-status.ts @@ -0,0 +1,80 @@ +import type { CheckStatus, PRCheckDetail } from './types' + +/** Derives the review status from the normalized check contract. */ +export function derivePRCheckStatus(checks: readonly PRCheckDetail[]): CheckStatus { + if (checks.length === 0) { + return 'neutral' + } + + let hasPending = false + let hasSuccess = false + for (const check of checks) { + if ( + check.conclusion === 'failure' || + check.conclusion === 'timed_out' || + check.conclusion === 'cancelled' || + check.conclusion === 'action_required' + ) { + return 'failure' + } + if ( + check.status === 'queued' || + check.status === 'in_progress' || + check.conclusion === 'pending' + ) { + hasPending = true + } + if (check.conclusion === 'success') { + hasSuccess = true + } + } + + if (hasPending) { + return 'pending' + } + return hasSuccess ? 'success' : 'neutral' +} + +type RawCheckRollup = { status?: unknown; conclusion?: unknown; state?: unknown } + +function normalizeRollupCheck(raw: RawCheckRollup, index: number): PRCheckDetail { + const status = String(raw.status ?? '').toLowerCase() + const state = String(raw.state ?? '').toLowerCase() + const conclusion = String(raw.conclusion ?? '').toLowerCase() + const normalizedConclusion = + conclusion === 'error' || conclusion === 'startup_failure' + ? 'failure' + : conclusion || + (state === 'failure' || state === 'error' + ? 'failure' + : state === 'success' + ? 'success' + : '') + const isPending = + status === 'queued' || + status === 'in_progress' || + status === 'pending' || + state === 'pending' || + conclusion === 'pending' + + return { + name: `check-${index}`, + status: isPending ? (status === 'in_progress' ? 'in_progress' : 'queued') : 'completed', + conclusion: (isPending + ? 'pending' + : normalizedConclusion || null) as PRCheckDetail['conclusion'], + url: null + } +} + +/** Derives status from provider rollups while retaining status/conclusion semantics. */ +export function derivePRCheckStatusFromRollup(rollup: unknown): CheckStatus { + if (!Array.isArray(rollup) || rollup.length === 0) { + return 'neutral' + } + return derivePRCheckStatus( + rollup.map((raw, index) => + normalizeRollupCheck(raw && typeof raw === 'object' ? (raw as RawCheckRollup) : {}, index) + ) + ) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index f68fc7b86d9..c2f005ddef1 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -65,6 +65,7 @@ export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-comman export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'worktree.create-idempotency.v1' as const export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const +export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const @@ -108,6 +109,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts new file mode 100644 index 00000000000..089487f0ad9 --- /dev/null +++ b/src/shared/release-channel.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { + formatHourlyVersion, + getReleaseRepoForChannel, + getVersionChannel, + isChannelSupportedOnPlatform, + isHourlyVersion, + isReleaseChannel, + parseHourlyVersionStamp, + sortReleaseBuildsNewestFirst, + type ReleaseBuild +} from './release-channel' +import { compareAppVersions } from './app-version' + +describe('release channel', () => { + it('classifies versions by channel', () => { + expect(getVersionChannel('1.4.160')).toBe('stable') + expect(getVersionChannel('v1.4.160')).toBe('stable') + expect(getVersionChannel('1.4.160-rc.3')).toBe('rc') + expect(getVersionChannel('1.4.160-hourly.202607281400')).toBe('hourly') + expect(getVersionChannel('not-a-version')).toBeNull() + }) + + // Why: hourly tags must never resolve to the main repo — the releases atom feed + // exposes only 10 entries, so 24 hourly tags a day would evict every stable/RC + // entry and leave real users with nothing to update to. + it('keeps hourly builds out of the main release repo', () => { + expect(getReleaseRepoForChannel('hourly')).toBe('stablyai/orca-hourly') + expect(getReleaseRepoForChannel('stable')).toBe('stablyai/orca') + expect(getReleaseRepoForChannel('rc')).toBe('stablyai/orca') + }) + + it('round-trips an hourly version stamp as UTC', () => { + const version = formatHourlyVersion('1.4.160', '202607281405') + expect(isHourlyVersion(version)).toBe(true) + expect(parseHourlyVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:00.000Z') + }) + + it('rejects malformed hourly identifiers', () => { + expect(isHourlyVersion('1.4.160-hourly')).toBe(false) + expect(isHourlyVersion('1.4.160-hourly.2026')).toBe(false) + expect(isHourlyVersion('1.4.160-rc.3')).toBe(false) + expect(parseHourlyVersionStamp('1.4.160-rc.3')).toBeNull() + }) + + // Why: an unanchored tail match also accepted garbage prefixes, and Date.UTC + // rolls impossible dates forward, so `...hourly.202602300000` rendered as + // March 2 rather than being rejected. + it('rejects a bad base version and impossible calendar stamps', () => { + expect(parseHourlyVersionStamp('not-a-version-hourly.202601010000')).toBeNull() + expect(parseHourlyVersionStamp('1.4-hourly.202601010000')).toBeNull() + expect(parseHourlyVersionStamp('1.4.160-hourly.202602300000')).toBeNull() + expect(parseHourlyVersionStamp('1.4.160-hourly.202613010000')).toBeNull() + expect(parseHourlyVersionStamp('1.4.160-hourly.202601012500')).toBeNull() + // Leap day 2028 is real and must still parse. + expect(parseHourlyVersionStamp('1.4.160-hourly.202802290000')?.toISOString()).toBe( + '2028-02-29T00:00:00.000Z' + ) + }) + + // Why: the hourly workflow is macOS-only, so the channel has no artifact to + // offer elsewhere. Both the picker and the main-process check read this, so a + // regression here would silently re-expose an uninstallable channel. + it('offers hourly only on macOS', () => { + expect(isChannelSupportedOnPlatform('hourly', 'darwin')).toBe(true) + expect(isChannelSupportedOnPlatform('hourly', 'linux')).toBe(false) + expect(isChannelSupportedOnPlatform('hourly', 'win32')).toBe(false) + }) + + it('offers stable and rc on every platform', () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + expect(isChannelSupportedOnPlatform('stable', platform)).toBe(true) + expect(isChannelSupportedOnPlatform('rc', platform)).toBe(true) + } + }) + + it('accepts only known channels', () => { + expect(isReleaseChannel('hourly')).toBe(true) + expect(isReleaseChannel('stable')).toBe(true) + expect(isReleaseChannel('nightly')).toBe(false) + expect(isReleaseChannel(null)).toBe(false) + expect(isReleaseChannel(undefined)).toBe(false) + }) + + // Why: consecutive hourlies differ only in the timestamp tail, so semver + // ordering must follow the clock or the picker offers them out of order. + it('sorts consecutive hourly builds newest first', () => { + const build = (version: string): ReleaseBuild => ({ + tag: `v${version}`, + version, + channel: 'hourly', + publishedAt: null, + releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/v${version}` + }) + const sorted = sortReleaseBuildsNewestFirst([ + build('1.4.160-hourly.202607280900'), + build('1.4.160-hourly.202607281400'), + build('1.4.160-hourly.202607281000') + ]) + expect(sorted.map((entry) => entry.version)).toEqual([ + '1.4.160-hourly.202607281400', + '1.4.160-hourly.202607281000', + '1.4.160-hourly.202607280900' + ]) + }) + + // Why: an hourly is cut from main and must not read as newer than the stable it + // is based on, or stable users would be offered it by an ordinary check. + it('orders an hourly below its own stable release', () => { + expect(compareAppVersions('1.4.160-hourly.202607281400', '1.4.160')).toBeLessThan(0) + }) +}) diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts new file mode 100644 index 00000000000..fc0c09cefb9 --- /dev/null +++ b/src/shared/release-channel.ts @@ -0,0 +1,96 @@ +import { compareAppVersions, isValidAppVersion } from './app-version' + +export type ReleaseChannel = 'stable' | 'rc' | 'hourly' + +export const RELEASE_CHANNELS: readonly ReleaseChannel[] = ['stable', 'rc', 'hourly'] + +/** Hourly builds live in their own repo so their tags never enter the main + * releases atom feed, which only exposes the 10 newest entries — 24 hourly + * tags a day would evict every stable/RC entry and strand real users. */ +export const HOURLY_RELEASE_REPO = 'stablyai/orca-hourly' +export const MAIN_RELEASE_REPO = 'stablyai/orca' + +export const HOURLY_PRERELEASE_IDENTIFIER = 'hourly' + +export function isReleaseChannel(value: unknown): value is ReleaseChannel { + return typeof value === 'string' && RELEASE_CHANNELS.includes(value as ReleaseChannel) +} + +/** + * Hourly builds are produced only by the macOS workflow, so the channel has + * nothing to offer elsewhere. Shared so the picker, the main-process check, and + * any future surface cannot drift on where it is available. + */ +export function isChannelSupportedOnPlatform( + channel: ReleaseChannel, + platform: NodeJS.Platform +): boolean { + return channel !== 'hourly' || platform === 'darwin' +} + +export function getReleaseRepoForChannel(channel: ReleaseChannel): string { + return channel === 'hourly' ? HOURLY_RELEASE_REPO : MAIN_RELEASE_REPO +} + +export function normalizeTagToVersion(tag: string): string { + return tag.replace(/^v/i, '') +} + +/** `1.4.160-hourly.202607281400` — a timestamp identifier keeps every build + * uniquely versioned so electron-updater never reads one as "same version". */ +export function isHourlyVersion(version: string): boolean { + return /^\d+\.\d+\.\d+-hourly\.\d{12}$/.test(normalizeTagToVersion(version)) +} + +export function formatHourlyVersion(baseVersion: string, stamp: string): string { + return `${baseVersion}-${HOURLY_PRERELEASE_IDENTIFIER}.${stamp}` +} + +/** Returns the build's UTC timestamp, or null when the version isn't hourly. */ +export function parseHourlyVersionStamp(version: string): Date | null { + const normalized = normalizeTagToVersion(version) + // Why anchored on the whole version: an unanchored tail match also accepts + // garbage prefixes, so `not-a-version-hourly.202601010000` would parse. + const match = normalized.match(/^\d+\.\d+\.\d+-hourly\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/) + if (!match) { + return null + } + const [year, month, day, hour, minute] = match.slice(1).map(Number) + const parsed = new Date(Date.UTC(year, month - 1, day, hour, minute)) + // Why the round-trip: Date.UTC silently rolls impossible dates forward, so a + // corrupt `...hourly.202602300000` would render as March 2 rather than fail. + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day || + parsed.getUTCHours() !== hour || + parsed.getUTCMinutes() !== minute + ) { + return null + } + return parsed +} + +export function getVersionChannel(version: string): ReleaseChannel | null { + const normalized = normalizeTagToVersion(version) + if (!isValidAppVersion(normalized)) { + return null + } + if (isHourlyVersion(normalized)) { + return 'hourly' + } + return normalized.includes('-') ? 'rc' : 'stable' +} + +export type ReleaseBuild = { + tag: string + version: string + channel: ReleaseChannel + publishedAt: string | null + releaseUrl: string +} + +/** Newest first, so the picker's first row is always the channel's current tip. */ +export function sortReleaseBuildsNewestFirst(builds: ReleaseBuild[]): ReleaseBuild[] { + return [...builds].sort((left, right) => compareAppVersions(right.version, left.version)) +} diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts index fe3c94272f3..24bbcc9df38 100644 --- a/src/shared/skill-freshness.ts +++ b/src/shared/skill-freshness.ts @@ -86,17 +86,44 @@ export type SkillFreshnessInstallation = { errorCategory: string | null } +// A scope whose contents belong to someone other than the user: a project's own checkout, +// or a plugin's bundled copy. Orca's global update never writes here, so the owner's +// content is not the user's drift — which is why ownership outranks byte status when +// labelling a location. +const OWNER_MANAGED_SKILL_SCOPES: ReadonlySet = new Set([ + 'repo-scope', + 'plugin-cache' +]) + +export function isOwnerManagedSkillScope(topology: SkillInstallationTopology): boolean { + return OWNER_MANAGED_SKILL_SCOPES.has(topology) +} + +// Why: Orca's updater only ever passes --global, so a copy a project owns is not +// something Orca can act on — it has no remedy by design. Reporting it as global drift +// produced an amber badge no user action could clear, over a copy their own repo +// legitimately owns. Stated by scope rather than by byte status on purpose: an outdated +// or unreadable project copy is just as far outside the global updater's reach as an +// unrecognized one. +export function skillPlacementParticipatesInGlobalFreshness( + installation: SkillFreshnessInstallation +): boolean { + return installation.topology !== 'repo-scope' +} + /** * Whether a copy is wrong in a way running the update would not resolve. * * Shared so the badge and the review dialog cannot disagree about what counts: the * badge points at the dialog for the explanation, so a copy that turns the badge * amber must also produce a row there. An out-of-date copy the command converges is - * ordinary work, not a problem, and a plugin's own copy of a same-named skill is the - * vendor's business rather than the user's drift. + * ordinary work, not a problem; a plugin's own copy of a same-named skill is the vendor's + * business rather than the user's drift, and a project's own copy is outside the reach of + * the only update Orca runs. */ export function isSkillCopyNeedingAttention(installation: SkillFreshnessInstallation): boolean { return ( + skillPlacementParticipatesInGlobalFreshness(installation) && installation.status !== 'current' && // Why: 'newer-known' is recognized official content ahead of this build — the // updater's own install or a newer release's bytes. There is nothing to fix and diff --git a/src/shared/source-control-create-review-intent.test.ts b/src/shared/source-control-create-review-intent.test.ts new file mode 100644 index 00000000000..4763117a5be --- /dev/null +++ b/src/shared/source-control-create-review-intent.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { + resolveCreateReviewIntentEligibility, + type CreateReviewIntentKind +} from './source-control-create-review-intent' +import type { HostedReviewCreationBlockedReason } from './hosted-review' +import type { GitUpstreamStatus } from './git-status-types' + +function unavailableEligibility(blockedReason: HostedReviewCreationBlockedReason) { + return { + provider: 'github' as const, + review: null, + canCreate: false, + blockedReason, + nextAction: null, + defaultBaseRef: 'main', + reviewLookupOutcome: 'unavailable' as const + } +} + +describe('resolveCreateReviewIntentEligibility', () => { + it('rejects unavailable eligibility when the default branch is unknown', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 1, + hasStageableChanges: true, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + ...unavailableEligibility('dirty'), + defaultBaseRef: null + } + }) + ).toEqual({ eligible: false, kind: null }) + }) + + it('rejects unavailable eligibility when the default branch is blank', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 1, + hasStageableChanges: true, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + ...unavailableEligibility('dirty'), + defaultBaseRef: ' ' + } + }) + ).toEqual({ eligible: false, kind: null }) + }) + + it('stays ineligible when no local blocker remains under unavailable lookup', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 0, + hasStageableChanges: false, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + ...unavailableEligibility('dirty'), + blockedReason: null + } + }) + ).toEqual({ eligible: false, kind: null }) + }) + + it('keeps dirty local preparation eligible when review lookup is unavailable', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 0, + hasStageableChanges: true, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: unavailableEligibility('dirty') + }) + ).toEqual({ eligible: true, kind: 'dirty' }) + }) + + it('still requires a message before committing staged changes without lookup authority', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 1, + hasStageableChanges: false, + hasMessage: false, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: unavailableEligibility('dirty') + }) + ).toEqual({ eligible: true, kind: 'message_required' }) + }) + + it.each<{ + blockedReason: HostedReviewCreationBlockedReason + blockedKind: CreateReviewIntentKind + stagedCount?: number + hasStageableChanges?: boolean + branchCommitsAhead?: number + upstreamStatus?: GitUpstreamStatus + }>([ + { + blockedReason: 'no_upstream', + blockedKind: 'no_upstream', + branchCommitsAhead: 1, + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }, + { + blockedReason: 'needs_push', + blockedKind: 'needs_push', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 1, + behind: 0 + } + }, + { + blockedReason: 'needs_sync', + blockedKind: 'needs_sync', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + }, + { + blockedReason: 'needs_sync', + blockedKind: 'force_push', + branchCommitsAhead: 1, + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 2, + behind: 1, + behindCommitsArePatchEquivalent: true + } + } + ])( + 'keeps recoverable $blockedKind preparation eligible when review lookup is unavailable', + ({ + blockedReason, + blockedKind, + stagedCount = 0, + hasStageableChanges = false, + branchCommitsAhead, + upstreamStatus + }) => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount, + hasStageableChanges, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus, + hostedReviewCreation: unavailableEligibility(blockedReason), + branchCommitsAhead + }) + ).toEqual({ eligible: true, kind: blockedKind }) + } + ) +}) diff --git a/src/shared/source-control-create-review-intent.ts b/src/shared/source-control-create-review-intent.ts index 5d1f517280b..b7266708b25 100644 --- a/src/shared/source-control-create-review-intent.ts +++ b/src/shared/source-control-create-review-intent.ts @@ -41,16 +41,20 @@ export function resolveCreateReviewIntentEligibility({ !hasCurrentBranch || !hostedReviewCreation || hostedReviewCreation.canCreate || - // Fail closed when the existing-review lookup could not prove there is no - // review: a local blocker (e.g. needs_push) returned after a failed lookup - // must not offer a Create PR intent that would push under a false promise — - // the main preflight would refuse the create anyway (invariant 8). - hostedReviewCreation.reviewLookupOutcome === 'unavailable' || !supportsHostedReviewCreation(hostedReviewCreation.provider) ) { return { eligible: false, kind: null } } + if ( + hostedReviewCreation.reviewLookupOutcome === 'unavailable' && + !hostedReviewCreation.defaultBaseRef?.trim() + ) { + return { eligible: false, kind: null } + } + + // Why: safe branch preparation can continue without lookup authority; the + // main create preflight still fails closed before creating a duplicate. if (hostedReviewCreation.blockedReason === 'dirty') { if (stagedCount > 0 && !hasMessage) { return { eligible: true, kind: 'message_required' } diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 007e48cc7cf..bcf92ef54bc 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -27,6 +27,13 @@ import { DAEMON_REPLACE_REASONS, DAEMON_RETIRE_REASONS } from './daemon-lifecycle-telemetry' +import { + DAEMON_AUDIT_PROCESS_REASON_VALUES, + DAEMON_AUDIT_REASON_VALUES, + DAEMON_AUDIT_STATE_VALUES, + DAEMON_AUDIT_TRIGGER_VALUES, + DAEMON_EVIDENCE_SOURCE_VALUES +} from './daemon-audit-eligibility' import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers' import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source' import { appStarSourceSchema } from './gh-star-source' @@ -418,6 +425,29 @@ const daemonLifecycleSchema = z.discriminatedUnion('transition', [ .strict() ]) +const daemonAuditEligibilitySchema = z + .object({ + state: z.enum(DAEMON_AUDIT_STATE_VALUES), + reason: z.enum(DAEMON_AUDIT_REASON_VALUES), + trigger: z.enum(DAEMON_AUDIT_TRIGGER_VALUES), + evidence_sources: z.array(z.enum(DAEMON_EVIDENCE_SOURCE_VALUES)).min(1).max(12), + protocol_generation: z.number().int().positive().max(1_000), + provider: z.literal('local-daemon'), + endpoint_kind: z.enum(['unix-socket', 'windows-named-pipe']), + profile_scope: z.enum(['configured', 'unspecified']), + exact_incarnation: z.enum([ + 'endpoint-identity', + 'endpoint-identity-linux-ticks', + 'unavailable' + ]), + reachability: z.enum(['authenticated', 'disconnected', 'unknown']), + inventory_authority: z.enum(['authoritative', 'unavailable']), + process_liveness: z.enum(['present', 'gone', 'unknown']), + process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(), + endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown']) + }) + .strict() + // Rollout signal for granting Codex hook trust via codex app-server RPCs // instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed` // spikes mean the RPC lane is not taking; steady-state ledger skips are not @@ -1412,6 +1442,7 @@ export const eventSchemas = { daemon_start_failed: daemonStartFailedSchema, main_thread_hang_detected: mainThreadHangDetectedSchema, daemon_lifecycle: daemonLifecycleSchema, + daemon_audit_eligibility: daemonAuditEligibilitySchema, runtime_rpc_start_failed: runtimeRpcStartFailedSchema, codex_trust_grant: codexTrustGrantSchema, diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index cde876882c6..a022e2469f2 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -98,6 +98,8 @@ export type TerminalTitleTracker = { * Titles are unaffected; un-suppressing resets the scanners' cross-chunk carry. */ setTransientFactScanningSuppressed: (suppressed: boolean) => void + /** Enable consumer-only bell, PR-link, and mode-2031 scans without resetting title state. */ + setTransientSideEffectScanningEnabled: (enabled: boolean) => void /** Cancel the stale-title timer and clear accumulated tracker state. */ dispose: () => void } @@ -117,12 +119,13 @@ export function createTerminalTitleTracker( onMode2031Subscribe, onMode2031Unsubscribe } = callbacks - const bellDetector = onBell ? createBellDetector() : null + let bellDetector = onBell ? createBellDetector() : null // Why: created only when a consumer exists so headless serve never pays the per-chunk 133/URL scans. const commandFinishedScanner = onCommandFinished ? createOsc133CommandFinishedScanner(onCommandFinished) : null let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null + let transientSideEffectScanningEnabled = true let transientFactScanningSuppressed = false let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE // Why: seed both so a mid-session tracker behaves as if it had observed the pane's last live title (renderer parity). @@ -210,7 +213,7 @@ export function createTerminalTitleTracker( onPrLink?.(link) } } - if (onMode2031Subscribe || onMode2031Unsubscribe) { + if (transientSideEffectScanningEnabled && (onMode2031Subscribe || onMode2031Unsubscribe)) { const previousMode2031ReplyScanState = options.mode2031PendingSubscribe ? { ...mode2031ReplyScanState, pendingSubscribe: true } : mode2031ReplyScanState @@ -238,7 +241,11 @@ export function createTerminalTitleTracker( } } // The permission BEL rides outside the OSC title; a FRESH detector avoids touching the chunk detector's cross-chunk escape state. - if (onBell && createBellDetector().chunkContainsBell(frame)) { + if ( + transientSideEffectScanningEnabled && + onBell && + createBellDetector().chunkContainsBell(frame) + ) { onBell() } // Why: deliberately skip the 133/PR-link/2031 scanners — fabricated bytes contain none and must not perturb their cross-chunk carry. @@ -271,6 +278,16 @@ export function createTerminalTitleTracker( } } }, + setTransientSideEffectScanningEnabled(enabled: boolean): void { + if (enabled === transientSideEffectScanningEnabled) { + return + } + transientSideEffectScanningEnabled = enabled + bellDetector?.reset() + bellDetector = enabled && onBell ? createBellDetector() : null + prLinkDetector = enabled && onPrLink ? createTerminalGitHubPRLinkDetector() : null + mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE + }, dispose(): void { clearStaleTitleTimer() agentTracker?.reset() diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index ce388f730fc..d9c4ce6e46a 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -5,6 +5,7 @@ * renderer store handler owns notification/unread policy. */ +import type { ParsedAgentStatusPayload } from './agent-status-types' import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' /** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not @@ -12,6 +13,7 @@ import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' * must not schedule task-complete notifications or unread attention — a * merely-paused agent (>3s silent mid-task) is not a completion. */ export type TerminalSideEffectFact = + | { kind: 'agent-status'; payload: ParsedAgentStatusPayload } | { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean } | { kind: 'bell' } | { kind: 'agent-working' } @@ -43,7 +45,7 @@ export type TerminalSideEffectBatch = { * their title state was current at, so the handler can drop a replay title * older than the last live title fact it applied. */ seq: number - /** Facts from one chunk, in byte order: titles in sequence, then bell. + /** Facts from one chunk, in byte order: agent status, titles, then bell. * Command Code scrape facts trail the chunk's parser facts — their policy * (status-row seeding) never interacts with title/bell ordering. */ facts: TerminalSideEffectFact[] diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 76e82eafce9..5cd5f32ff96 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -28,7 +28,9 @@ export enum TerminalStreamOpcode { // Why 14: Ack already occupies 13 on current clients; older runtimes ignore // this opcode and still receive the compatibility Resize frame behind it. ClaimViewport = 14, - OutputSpan = 15 + OutputSpan = 15, + // Negotiated per stream; older hosts reject unknown opcodes, so clients send only after capability confirmation. + SetOutputPaused = 16 } export type TerminalStreamFrame = { @@ -116,6 +118,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Metadata || value === TerminalStreamOpcode.Ack || value === TerminalStreamOpcode.ClaimViewport || - value === TerminalStreamOpcode.OutputSpan + value === TerminalStreamOpcode.OutputSpan || + value === TerminalStreamOpcode.SetOutputPaused ) } diff --git a/src/shared/tui-agent-detection-commands.ts b/src/shared/tui-agent-detection-commands.ts new file mode 100644 index 00000000000..b0ca1b443b8 --- /dev/null +++ b/src/shared/tui-agent-detection-commands.ts @@ -0,0 +1,77 @@ +import type { TuiAgent } from './types' +import { + getTuiAgentDetectCommands, + TUI_AGENT_CONFIG, + type TuiAgentConfig, + type TuiAgentDetectionRuntime +} from './tui-agent-config' + +export type TuiAgentDetectionCommand = { + id: TuiAgent + cmd: string + requiredCommands?: readonly string[] + unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] +} + +export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands() + +function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] { + return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => + getTuiAgentDetectCommands(config).map((cmd) => + buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config) + ) + ) +} + +function buildTuiAgentDetectionCommand( + id: TuiAgent, + cmd: string, + config: TuiAgentConfig +): TuiAgentDetectionCommand { + return { + id, + cmd, + ...(config.detectRequiredCommands?.length + ? { requiredCommands: config.detectRequiredCommands } + : {}), + ...(config.detectUnsupportedRuntimes?.length + ? { unsupportedRuntimes: config.detectUnsupportedRuntimes } + : {}) + } +} + +export function getTuiAgentDetectionProbeCommands( + commands: readonly TuiAgentDetectionCommand[], + runtime: TuiAgentDetectionRuntime +): string[] { + return [ + ...new Set( + commands + .filter((command) => !isDetectionUnsupportedInRuntime(command, runtime)) + .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) + ) + ] +} + +export function resolveDetectedTuiAgentIds( + commands: readonly TuiAgentDetectionCommand[], + foundCommands: ReadonlySet, + runtime: TuiAgentDetectionRuntime +): TuiAgent[] { + const detected = commands + .filter( + (command) => + !isDetectionUnsupportedInRuntime(command, runtime) && + foundCommands.has(command.cmd) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + .map(({ id }) => id) + return [...new Set(detected)] +} + +export function isDetectionUnsupportedInRuntime( + command: TuiAgentDetectionCommand, + runtime: TuiAgentDetectionRuntime +): boolean { + return command.unsupportedRuntimes?.includes(runtime) === true +} diff --git a/src/shared/types.ts b/src/shared/types.ts index cbbfa0d8776..fa6ca7b9f91 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId } from './execution-host' import type { RemovedSshTargetTombstone, SshRemotePtyLease, SshTarget } from './ssh-types' import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types' import type { WorkspaceSource } from './workspace-source' +import type { ReleaseBuild, ReleaseChannel } from './release-channel' import type { GitHubProjectSettings } from './github-project-types' import type { AgentStatusState, @@ -1542,12 +1543,13 @@ export type GitHubAssignableUser = { avatarUrl: string } -export type GitHubPRCheckSummary = { - state: 'success' | 'failure' | 'pending' | 'none' +export type ProviderCheckSummary = { + state: 'success' | 'failure' | 'pending' | 'neutral' | 'none' total: number passed: number failed: number pending: number + neutral: number } export type GitHubPRReviewSummary = { @@ -1586,7 +1588,7 @@ export type GitHubWorkItem = { reviewRequests?: GitHubAssignableUser[] latestReviews?: GitHubPRReviewSummary[] assignees?: GitHubAssignableUser[] - checksSummary?: GitHubPRCheckSummary + checksSummary?: ProviderCheckSummary mergeable?: PRMergeableState autoMergeEnabled?: boolean autoMergeAllowed?: boolean | null @@ -2285,6 +2287,7 @@ export type CreateWorktreeResult = { } defaultTabs?: WorktreeDefaultTabsLaunch warning?: string + baseFallback?: WorktreeCreateBaseFallback initialBaseStatus?: WorktreeBaseStatusEvent localBaseRefRefresh?: LocalBaseRefRefreshResult localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion @@ -2299,6 +2302,11 @@ export type CreateWorktreeResult = { timing?: WorktreeCreateTiming } +export type WorktreeCreateBaseFallback = { + requestedRef: string + localRef: string +} + export type PreservedWorktreeBranch = { branchName: string head?: string @@ -2366,9 +2374,12 @@ export type UpdateCheckOptions = { includePrerelease?: boolean includePerfPrerelease?: boolean localBuild?: boolean + /** Dev channel switching; `targetTag` pins an exact build, including older ones. */ + channel?: ReleaseChannel + targetTag?: string } -export type UpdateSource = 'local' +export type UpdateSource = 'local' | 'hourly' export type UpdateStatus = ( | { state: 'idle' } @@ -2395,6 +2406,10 @@ export type UpdateStatus = ( | { state: 'error'; message: string; userInitiated?: boolean; activeNudgeId?: string } ) & { source?: UpdateSource } +export type ReleaseBuildListResult = + | { ok: true; channel: ReleaseChannel; builds: ReleaseBuild[] } + | { ok: false; channel: ReleaseChannel; message: string } + // ─── Settings ──────────────────────────────────────────────────────── export type NotificationSettings = { enabled: boolean @@ -3370,6 +3385,8 @@ export type PersistedUIState = { statusBarUsageMode?: StatusBarUsageMode dismissedUpdateVersion: string | null lastUpdateCheckAt: number | null + /** Dev-only update channel override; absent means the build's own channel. */ + releaseChannelOverride?: ReleaseChannel | null pendingUpdateNudgeId?: string | null dismissedUpdateNudgeId?: string | null /** Whether Orca already tried triggering the macOS notification permission dialog; prevents re-firing every launch. */ @@ -3422,6 +3439,8 @@ export type PersistedUIState = { _inlineAgentsDefaultedForAllUsers?: boolean /** One-shot migration flag for split-out card properties, set once so later deliberate unchecks of Linear issue/Ports stick across restarts. */ _expandedWorktreeCardPropertiesDefaulted?: boolean + /** One-shot backfill flag for 'jira-issue', which joined the defaults after the expansion migration had already stamped upgraded profiles. */ + _jiraIssueWorktreeCardPropertyDefaulted?: boolean /** totalAgentsSpawned snapshot at first sighting of the current app version, so the nag counts agents since last update (not from zero). */ starNagBaselineAgents?: number | null /** App version that set the current baseline; a version change re-captures the baseline on next spawn, restarting the nag countdown. */ diff --git a/src/shared/windows-batch-spawn.ts b/src/shared/windows-batch-spawn.ts new file mode 100644 index 00000000000..cd20dff5c79 --- /dev/null +++ b/src/shared/windows-batch-spawn.ts @@ -0,0 +1,43 @@ +import { win32 } from 'node:path' + +/** Full path to cmd.exe for GUI and service-launched processes. */ +export function getCmdExePath(): string { + return ( + process.env.ComSpec || + win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe') + ) +} + +export function isWindowsBatchScript(commandPath: string): boolean { + return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) +} + +export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + +export class UnsafeWindowsBatchArgumentsError extends Error { + constructor() { + super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) + this.name = 'UnsafeWindowsBatchArgumentsError' + } +} + +function hasUnsafeWindowsBatchSyntax(value: string): boolean { + return /[&|<>^"%!()\r\n]/.test(value) +} + +export function getSpawnArgsForWindows( + command: string, + args: string[] +): { spawnCmd: string; spawnArgs: string[] } { + if (isWindowsBatchScript(command)) { + for (const value of [command, ...args]) { + if (hasUnsafeWindowsBatchSyntax(value)) { + throw new UnsafeWindowsBatchArgumentsError() + } + } + + // Why: separate argv entries let Node quote spaces without breaking cmd. + return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } + } + return { spawnCmd: command, spawnArgs: args } +} diff --git a/src/shared/workspace-statuses.test.ts b/src/shared/workspace-statuses.test.ts index 9d3e881345a..bd12a4100e6 100644 --- a/src/shared/workspace-statuses.test.ts +++ b/src/shared/workspace-statuses.test.ts @@ -10,6 +10,29 @@ import { } from './workspace-statuses' describe('workspace status visuals', () => { + it.each([13, 20, 21, 64])('keeps all %i authored columns in order', (count) => { + const authored = Array.from({ length: count }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })) + + const statuses = normalizeWorkspaceStatuses(authored) + + expect(statuses).toHaveLength(count) + expect(statuses.map((status) => status.id)).toEqual(authored.map((status) => status.id)) + }) + + it('normalizes every valid status without truncating the workflow', () => { + const authored = Array.from({ length: 500 }, (_, index) => ({ + id: `state-${index}`, + label: `State ${index}` + })) + + expect(normalizeWorkspaceStatuses(authored).map((status) => status.id)).toEqual( + authored.map((status) => status.id) + ) + }) + it('keeps the default workflow order', () => { expect(cloneDefaultWorkspaceStatuses().map((status) => status.id)).toEqual([ 'todo', diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index b3aa587043b..430595733fc 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -9,7 +9,6 @@ export { DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults' const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:' const MAX_STATUS_LABEL_LENGTH = 32 -const MAX_WORKSPACE_STATUSES = 12 type WorkspaceStatusNormalizationOptions = { migrateDefaultWorkflowStatuses?: boolean migrateLegacyDefaultStatusVisuals?: boolean @@ -169,7 +168,7 @@ function normalizeWorkspaceStatusesInternal( const statuses: WorkspaceStatusDefinition[] = [] const usedIds = new Set() - for (const rawStatus of value.slice(0, MAX_WORKSPACE_STATUSES)) { + for (const rawStatus of value) { if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) { continue } diff --git a/tests/e2e/artificial-opencode-pane-interactions.ts b/tests/e2e/artificial-opencode-pane-interactions.ts index f93c3a0788d..a8998c7b994 100644 --- a/tests/e2e/artificial-opencode-pane-interactions.ts +++ b/tests/e2e/artificial-opencode-pane-interactions.ts @@ -1,11 +1,13 @@ import type { Page } from '@stablyai/playwright-test' import { expect } from './helpers/orca-app' -import { ensureTerminalVisible } from './helpers/store' +import { ensureTerminalVisible, getActiveWorktreeId, switchToWorktree } from './helpers/store' import { getTerminalContent, + readPaneIdentitySnapshot, splitActiveTerminalPane, + UUID_RE, waitForActiveTerminalManager, - waitForPaneIdentitySnapshot + type PaneIdentitySnapshot } from './helpers/terminal' export type TerminalLoadPane = { @@ -52,16 +54,47 @@ export async function focusPane(page: Page, paneKey: string): Promise { ) } +export async function waitForTerminalPtyVisible( + page: Page, + ptyId: string, + timeoutMs = 10_000 +): Promise { + await expect + .poll( + () => + page.evaluate((targetPtyId) => { + for (const manager of window.__paneManagers?.values() ?? []) { + const pane = manager + .getPanes?.() + .find((candidate) => candidate.container.dataset.ptyId === targetPtyId) + if (pane) { + return pane.container.isConnected && pane.container.getClientRects().length > 0 + } + } + return false + }, ptyId), + { + timeout: timeoutMs, + message: `Terminal PTY ${ptyId} did not become visible` + } + ) + .toBe(true) +} + export async function ensureActiveWorktreePaneLoad( page: Page, paneCount: number ): Promise { await ensureTerminalVisible(page) await waitForActiveTerminalManager(page, 30_000) - let snapshot = await waitForPaneIdentitySnapshot(page, 1) + const worktreeId = await getActiveWorktreeId(page) + if (!worktreeId) { + throw new Error('Active worktree is unavailable for terminal pane load') + } + let snapshot = await waitForActiveWorktreePaneLoad(page, worktreeId, 1) while (snapshot.panes.length < paneCount) { await splitActiveTerminalPane(page, snapshot.panes.length % 2 === 0 ? 'horizontal' : 'vertical') - snapshot = await waitForPaneIdentitySnapshot(page, snapshot.panes.length + 1) + snapshot = await waitForActiveWorktreePaneLoad(page, worktreeId, snapshot.panes.length + 1) } return snapshot.panes.slice(0, paneCount).map((pane) => ({ paneKey: `${snapshot.tabId}:${pane.leafId}`, @@ -69,6 +102,47 @@ export async function ensureActiveWorktreePaneLoad( })) } +async function waitForActiveWorktreePaneLoad( + page: Page, + worktreeId: string, + paneCount: number +): Promise { + let snapshot: PaneIdentitySnapshot | null = null + await expect + .poll( + async () => { + if ((await getActiveWorktreeId(page)) !== worktreeId) { + // Why: late session reconciliation can clear selection while split PTYs bind. + await switchToWorktree(page, worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + } + snapshot = await readPaneIdentitySnapshot(page) + return Boolean( + snapshot && + snapshot.panes.length === paneCount && + snapshot.panes.every( + (pane) => + UUID_RE.test(pane.leafId) && + pane.stablePaneId === pane.leafId && + pane.datasetLeafId === pane.leafId && + pane.ptyId !== null && + snapshot?.ptyIdsByLeafId[pane.leafId] === pane.ptyId + ) + ) + }, + { + timeout: 15_000, + message: 'Artificial load panes did not settle with stable PTY bindings' + } + ) + .toBe(true) + if (!snapshot) { + throw new Error('Artificial load pane snapshot is unavailable') + } + return snapshot +} + export async function waitForMarkerLatency( page: Page, marker: string, diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts index c533a395e22..48643b8d8de 100644 --- a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -17,6 +17,7 @@ import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { waitForTerminalPtyVisible } from './artificial-opencode-pane-interactions' type RevisitPressurePane = { paneKey: string; ptyId: string } @@ -161,6 +162,7 @@ export async function runRendererBackpressureRevisitScenario< await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForTerminalPtyVisible(orcaPage, typingPtyId) const measurement = await deps.measureTypingDuringLoad( orcaPage, typingScriptPath, @@ -198,6 +200,8 @@ export async function runRendererBackpressureRevisitScenario< await switchToWorktree(orcaPage, firstWorktreeId) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) + // Why: hidden PaneManagers persist, so manager readiness alone can race the reveal commit. + await waitForTerminalPtyVisible(orcaPage, revisitPane.ptyId) await deps.focusPane(orcaPage, revisitPane.paneKey) await sendToTerminal(orcaPage, revisitPane.ptyId, `printf '\\n${revisitMarker}\\n'\r`) const revisitLatencyMs = await waitForMarkerLatency(orcaPage, revisitMarker, 10_000) diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 9b937775a84..3e857d3258f 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -146,7 +146,8 @@ const MAX_TIMER_DRIFT_MS = 250 // the real baseline. const MAX_TIMER_DRIFT_UNDER_LOAD_MS = 2_500 const MAX_SCROLL_LATENCY_MS = 150 -const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 3 * 1024 * 1024 +// Why: byte-level peaks vary by drain quantum; the coarse guard matches the main-pressure scenario. +const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 5 * 1024 * 1024 function readPositiveInt(name: string, fallback: number): number { const raw = process.env[name] diff --git a/tests/e2e/browser-split-find-shortcut.spec.ts b/tests/e2e/browser-split-find-shortcut.spec.ts new file mode 100644 index 00000000000..39847bdeac5 --- /dev/null +++ b/tests/e2e/browser-split-find-shortcut.spec.ts @@ -0,0 +1,301 @@ +import { expect, test } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' +import { focusActiveTerminalInput } from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const modifier = process.platform === 'darwin' ? 'Meta' : 'Control' + +type SplitFindFixture = { + browserGroupId: string + browserTabId: string + terminalGroupId: string +} + +type BrowserSplitFixture = { + firstBrowserTabId: string + secondBrowserTabId: string +} + +async function createTerminalBrowserSplit(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('Active worktree unavailable') + } + const terminalGroupId = state.ensureWorktreeRootGroup(worktreeId) + const browserGroupId = state.createEmptySplitGroup(worktreeId, terminalGroupId, 'right') + if (!browserGroupId) { + throw new Error('Browser split unavailable') + } + const browserTab = state.createBrowserTab(worktreeId, 'about:blank', { + activate: true, + focusAddressBar: false, + targetGroupId: browserGroupId + }) + return { browserGroupId, browserTabId: browserTab.id, terminalGroupId } + }) +} + +async function createBrowserSplit(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('Active worktree unavailable') + } + const terminalGroupId = state.ensureWorktreeRootGroup(worktreeId) + const firstBrowserGroupId = state.createEmptySplitGroup(worktreeId, terminalGroupId, 'right') + if (!firstBrowserGroupId) { + throw new Error('First browser split unavailable') + } + const firstBrowserTab = state.createBrowserTab(worktreeId, 'about:blank', { + activate: true, + focusAddressBar: false, + targetGroupId: firstBrowserGroupId + }) + const secondBrowserGroupId = state.createEmptySplitGroup( + worktreeId, + firstBrowserGroupId, + 'right' + ) + if (!secondBrowserGroupId) { + throw new Error('Second browser split unavailable') + } + const secondBrowserTab = state.createBrowserTab(worktreeId, 'about:blank', { + activate: true, + focusAddressBar: false, + targetGroupId: secondBrowserGroupId + }) + return { + firstBrowserTabId: firstBrowserTab.id, + secondBrowserTabId: secondBrowserTab.id + } + }) +} + +function browserAddressBar(page: Page, browserTabId: string) { + return page.locator( + `[data-browser-overlay-tab-id="${browserTabId}"] [data-orca-browser-address-bar="true"]` + ) +} + +async function focusBrowserAddressBar(page: Page, browserTabId: string): Promise { + const browserOverlay = page.locator(`[data-browser-overlay-tab-id="${browserTabId}"]`) + const addressBar = browserAddressBar(page, browserTabId) + const addressBarForm = browserOverlay.locator( + 'form:has(> [data-orca-browser-address-bar="true"])' + ) + await expect(addressBarForm).toBeVisible() + await addressBarForm.click() + await expect(addressBar).toBeFocused() +} + +function browserFindInput(page: Page) { + return page.getByPlaceholder('Find in page...') +} + +function browserFindCloseButton(page: Page) { + return browserFindInput(page).locator('xpath=..').getByTitle('Close') +} + +function browserSplitFindInput(page: Page, browserTabId: string) { + return page + .locator(`[data-browser-overlay-tab-id="${browserTabId}"]`) + .getByPlaceholder('Find in page...') +} + +async function pressFindInBrowserGuest(page: Page, browserTabId: string): Promise { + await expect + .poll(() => + page.evaluate((targetBrowserTabId) => { + const overlay = document.querySelector( + `[data-browser-overlay-tab-id="${targetBrowserTabId}"]` + ) + const webview = overlay?.querySelector('webview') as Electron.WebviewTag | null + return webview?.getWebContentsId() ?? null + }, browserTabId) + ) + .not.toBeNull() + + await page.evaluate( + async ({ targetBrowserTabId, inputModifier }) => { + const overlay = document.querySelector( + `[data-browser-overlay-tab-id="${targetBrowserTabId}"]` + ) + const webview = overlay?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`) + } + webview.focus() + await webview.sendInputEvent({ + type: 'keyDown', + keyCode: 'F', + modifiers: [inputModifier] + }) + await webview.sendInputEvent({ + type: 'keyUp', + keyCode: 'F', + modifiers: [inputModifier] + }) + }, + { targetBrowserTabId: browserTabId, inputModifier: modifier.toLowerCase() } + ) +} + +function terminalFindInput(page: Page) { + return page.locator('[data-terminal-search-root] input:visible') +} + +async function waitForFocusedGroup(page: Page, groupId: string): Promise { + await expect + .poll(() => + page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + return worktreeId ? state.activeGroupIdByWorktree[worktreeId] : null + }) + ) + .toBe(groupId) + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) +} + +async function focusBrowserGroup(page: Page, groupId: string): Promise { + await page.evaluate((targetGroupId) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (state && worktreeId) { + state.focusGroup(worktreeId, targetGroupId) + } + }, groupId) + await waitForFocusedGroup(page, groupId) +} + +test.describe('browser split Find shortcut', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('routes repeated Find shortcuts to the focused terminal or browser split', async ({ + orcaPage + }) => { + const fixture = await createTerminalBrowserSplit(orcaPage) + + await orcaPage.evaluate(({ terminalGroupId }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (state && worktreeId) { + state.focusGroup(worktreeId, terminalGroupId) + } + }, fixture) + await focusActiveTerminalInput(orcaPage) + await waitForFocusedGroup(orcaPage, fixture.terminalGroupId) + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(terminalFindInput(orcaPage)).toBeFocused() + await expect(browserFindInput(orcaPage)).toBeHidden() + await orcaPage.keyboard.press('Escape') + + await focusBrowserGroup(orcaPage, fixture.browserGroupId) + await focusBrowserAddressBar(orcaPage, fixture.browserTabId) + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(browserFindInput(orcaPage)).toBeFocused() + await expect(terminalFindInput(orcaPage)).toBeHidden() + await browserFindCloseButton(orcaPage).click() + await expect(browserFindInput(orcaPage)).toBeHidden() + + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(browserFindInput(orcaPage)).toBeFocused() + await browserFindCloseButton(orcaPage).click() + + await orcaPage.evaluate(({ browserTabId }) => { + window.__store?.getState().closeBrowserTab(browserTabId) + }, fixture) + await expect( + orcaPage.locator(`[data-browser-overlay-tab-id="${fixture.browserTabId}"]`) + ).toHaveCount(0) + + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(terminalFindInput(orcaPage)).toBeFocused() + await expect(browserFindInput(orcaPage)).toBeHidden() + }) + + test('opens Find only in the browser split whose guest owns the shortcut', async ({ + orcaPage + }) => { + const fixture = await createBrowserSplit(orcaPage) + + await pressFindInBrowserGuest(orcaPage, fixture.firstBrowserTabId) + + await expect(browserSplitFindInput(orcaPage, fixture.firstBrowserTabId)).toBeVisible() + await expect(browserSplitFindInput(orcaPage, fixture.secondBrowserTabId)).toBeHidden() + }) + + test('keeps browser Find available when split focus state is temporarily missing', async ({ + orcaPage + }) => { + const fixture = await createTerminalBrowserSplit(orcaPage) + await focusBrowserGroup(orcaPage, fixture.browserGroupId) + const addressBar = browserAddressBar(orcaPage, fixture.browserTabId) + await focusBrowserAddressBar(orcaPage, fixture.browserTabId) + + await orcaPage.evaluate(() => { + const store = window.__store + const worktreeId = store?.getState().activeWorktreeId + if (!store || !worktreeId) { + throw new Error('Active worktree unavailable') + } + store.setState((state) => { + const activeGroupIdByWorktree = { ...state.activeGroupIdByWorktree } + delete activeGroupIdByWorktree[worktreeId] + return { activeGroupIdByWorktree } + }) + }) + await expect(addressBar).toBeFocused() + + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(browserFindInput(orcaPage)).toBeFocused() + await expect(terminalFindInput(orcaPage)).toBeHidden() + }) + + test('keeps browser Find available when the focused split ID is stale', async ({ orcaPage }) => { + const fixture = await createTerminalBrowserSplit(orcaPage) + await focusBrowserGroup(orcaPage, fixture.browserGroupId) + const addressBar = browserAddressBar(orcaPage, fixture.browserTabId) + await focusBrowserAddressBar(orcaPage, fixture.browserTabId) + + await orcaPage.evaluate(() => { + const store = window.__store + const worktreeId = store?.getState().activeWorktreeId + if (!store || !worktreeId) { + throw new Error('Active worktree unavailable') + } + store.setState((state) => ({ + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: 'removed-group' + } + })) + }) + await expect(addressBar).toBeFocused() + + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(browserFindInput(orcaPage)).toBeFocused() + await expect(terminalFindInput(orcaPage)).toBeHidden() + }) +}) diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts index 77b85929d70..e19e0dabf9f 100644 --- a/tests/e2e/headless-serve-desktop-activation.spec.ts +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -23,12 +23,15 @@ import { } from './helpers/terminal' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { RuntimeClient } from '../../src/cli/runtime/client' +import { RuntimeClientError } from '../../src/cli/runtime/types' import type { RuntimeStatus, RuntimeTerminalCreate, RuntimeTerminalRead } from '../../src/shared/runtime-types' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { parsePaneKey } from '../../src/shared/stable-pane-id' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron') const electronPath = path.join( @@ -67,6 +70,35 @@ function readDaemonPid(userDataDir: string): number { return parsed.pid } +function readPersistedPromotionBinding( + userDataDir: string, + worktreeId: string, + tabId: string, + leafId: string +): { tabId: string; leafId: string; ptyId: string } | null { + try { + const persisted = JSON.parse( + readFileSync( + path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'), + 'utf8' + ) + ) as { + workspaceSession?: { + tabsByWorktree?: Record + terminalLayoutsByTabId?: Record }> + } + } + const tab = persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.find( + (candidate) => candidate.id === tabId + ) + const ptyId = + persisted.workspaceSession?.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId?.[leafId] + return tab && ptyId ? { tabId, leafId, ptyId } : null + } catch { + return null + } +} + async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise { if (child.exitCode !== null || child.signalCode !== null) { return true @@ -119,10 +151,22 @@ test('promotes the headless owner without replacing its daemon terminal', async const client = new RuntimeClient(userDataDir, 5_000) await expect - .poll(async () => (await client.getCliStatus()).result.app.desktopWindowStatus, { - timeout: 60_000, - message: 'headless serve never became safely openable' - }) + .poll( + async () => { + try { + return (await client.getCliStatus()).result.app.desktopWindowStatus + } catch (error) { + if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') { + return 'starting' + } + throw error + } + }, + { + timeout: 60_000, + message: 'headless serve never became safely openable' + } + ) .toBe('openable') const beforeStatus = await client.call('status.get') @@ -133,8 +177,11 @@ test('promotes the headless owner without replacing its daemon terminal', async title: 'Serve promotion continuity' }) const terminal = created.result.terminal - if (!terminal.ptyId) { - throw new Error('Headless terminal did not expose its daemon PTY id') + const originalPtyId = terminal.ptyId + const originalTabId = terminal.tabId + const paneIdentity = terminal.paneKey ? parsePaneKey(terminal.paneKey) : null + if (!originalPtyId || !originalTabId || !paneIdentity) { + throw new Error('Headless terminal did not expose its durable pane and daemon PTY identity') } const beforeMarker = `SERVE_PROMOTION_BEFORE_${Date.now()}` @@ -177,6 +224,26 @@ test('promotes the headless owner without replacing its daemon terminal', async }) } + await expect + .poll( + () => + readPersistedPromotionBinding( + userDataDir, + terminal.worktreeId, + originalTabId, + paneIdentity.leafId + ), + { + timeout: 15_000, + message: 'headless terminal binding was not persisted during desktop promotion' + } + ) + .toEqual({ + tabId: originalTabId, + leafId: paneIdentity.leafId, + ptyId: originalPtyId + }) + const page = await serveApp.firstWindow({ timeout: 60_000 }) await page.waitForLoadState('domcontentloaded') await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) @@ -210,6 +277,6 @@ test('promotes the headless owner without replacing its daemon terminal', async await closeElectronAppForE2E(serveApp) } await cleanupE2EDaemons(userDataDir) - rmSync(userDataDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) } }) diff --git a/tests/e2e/helpers/paired-terminal-hidden-output-oracle.ts b/tests/e2e/helpers/paired-terminal-hidden-output-oracle.ts new file mode 100644 index 00000000000..562947d5dff --- /dev/null +++ b/tests/e2e/helpers/paired-terminal-hidden-output-oracle.ts @@ -0,0 +1,113 @@ +import type { Page } from '@stablyai/playwright-test' +import type { RuntimeTerminalRead } from '../../../src/shared/runtime-types' +import { startRendererLagProbe } from '../paired-runtime-retention-metrics' +import { expect } from './orca-app' + +const MAX_HIDDEN_FLOOD_LAG_MS = 500 + +type HiddenPairedTerminal = { + tabId: string + terminal: string +} + +async function callRuntime(page: Page, method: string, params: unknown): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +export async function verifyHiddenPairedTerminalOutputSuppression( + page: Page, + terminals: HiddenPairedTerminal[] +): Promise { + await page.evaluate(() => window.__store?.getState().setActiveView('tasks')) + await expect + .poll( + () => + page.evaluate( + (ids) => ids.filter((id) => window.__paneManagers?.has(id)).length, + terminals.map((terminal) => terminal.tabId) + ), + { timeout: 10_000 } + ) + .toBe(terminals.length) + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) + await page.evaluate(() => { + const debug = ( + window as typeof window & { + __terminalOutputSchedulerDebug?: { reset: () => void } + } + ).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('Terminal output scheduler debug API is unavailable') + } + debug.reset() + }) + + const lagProbe = await startRendererLagProbe(page) + const tokens = terminals.map((_, index) => `HIDDEN_FLOOD_${index}_${Date.now()}`) + try { + await Promise.all( + terminals.map((terminal, index) => + callRuntime(page, 'terminal.send', { + terminal: terminal.terminal, + text: `FLOOD:${tokens[index]}`, + enter: true, + client: { id: 'paired-hidden-flood-e2e', type: 'desktop' } + }) + ) + ) + await expect + .poll( + async () => + Promise.all( + terminals.map(async (terminal, index) => { + const result = await callRuntime<{ terminal: RuntimeTerminalRead }>( + page, + 'terminal.read', + { terminal: terminal.terminal, limit: 1_000 } + ) + return result.terminal.tail.join('\n').includes(`FLOODED:${tokens[index]}`) + }) + ), + { timeout: 30_000 } + ) + .toEqual(Array(terminals.length).fill(true)) + const hiddenFloodLagMs = await lagProbe.evaluate((probe) => probe.stop()) + const scheduler = await page.evaluate( + () => + ( + window as typeof window & { + __terminalOutputSchedulerDebug?: { + snapshot: () => { + backgroundEnqueueCount: number + queuedChars: number + scheduledDrainCount: number + } + } + } + ).__terminalOutputSchedulerDebug?.snapshot() ?? null + ) + expect(scheduler).not.toBeNull() + expect(scheduler?.backgroundEnqueueCount).toBe(0) + expect(scheduler?.scheduledDrainCount).toBe(0) + expect(scheduler?.queuedChars).toBe(0) + expect(hiddenFloodLagMs).toBeLessThan(MAX_HIDDEN_FLOOD_LAG_MS) + } finally { + await lagProbe.evaluate((probe) => probe.stop()).catch(() => undefined) + await lagProbe.dispose() + } + return tokens +} diff --git a/tests/e2e/helpers/paired-terminal-parking-fixture.ts b/tests/e2e/helpers/paired-terminal-parking-fixture.ts index 48444b79eb9..c49917cb24f 100644 --- a/tests/e2e/helpers/paired-terminal-parking-fixture.ts +++ b/tests/e2e/helpers/paired-terminal-parking-fixture.ts @@ -3,6 +3,7 @@ import os from 'node:os' import path from 'node:path' const FILL_ROWS = 6_000 +const FLOOD_ROWS = 4_000 function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'` @@ -30,6 +31,12 @@ export function createPairedTerminalParkingFixture(): { "process.stdin.setEncoding('utf8')", "process.stdin.on('data', (data) => {", ' for (const command of data.split(/\\r\\n|\\r|\\n/).filter(Boolean)) {', + " if (command.startsWith('FLOOD:')) {", + " const token = command.slice('FLOOD:'.length)", + ` for (let row = 0; row < ${FLOOD_ROWS}; row += 1) process.stdout.write(\`flood-${'${token}'}-${'${row}'}-${'x'.repeat(80)}\\r\\n\`)`, + ' process.stdout.write(`FLOODED:${token}\\r\\n`)', + ' continue', + ' }', " if (command === 'FILL') {", ` for (let row = 0; row < ${FILL_ROWS}; row += 1) process.stdout.write(\`fill-${'${marker}'}-${'${row}'}-${'x'.repeat(80)}\\r\\n\`)`, ' process.stdout.write(`FILLED:${marker}\\r\\n`)', diff --git a/tests/e2e/helpers/paired-terminal-parking-oracle.ts b/tests/e2e/helpers/paired-terminal-parking-oracle.ts index e976a55b0d9..070c73cceb1 100644 --- a/tests/e2e/helpers/paired-terminal-parking-oracle.ts +++ b/tests/e2e/helpers/paired-terminal-parking-oracle.ts @@ -10,6 +10,7 @@ import { startRendererLagProbe } from '../paired-runtime-retention-metrics' import { expect } from './orca-app' +import { verifyHiddenPairedTerminalOutputSuppression } from './paired-terminal-hidden-output-oracle' import { createPairedTerminalParkingFixture } from './paired-terminal-parking-fixture' import { getTerminalContent, waitForActivePanePtyId } from './terminal' @@ -133,17 +134,7 @@ export async function runPairedTerminalParkingOracle( } await expectHostTerminalsUnmounted(options.hostPage, seed.fallbackWorktreeId, remoteTabs) - await page.evaluate(() => window.__store?.getState().setActiveView('tasks')) - await expect - .poll( - () => - page.evaluate( - (ids) => ids.filter((id) => window.__paneManagers?.has(id)).length, - remoteTabs.map((tab) => tab.tabId) - ), - { timeout: 10_000 } - ) - .toBe(TARGET_WORKTREE_COUNT) + const hiddenFloodTokens = await verifyHiddenPairedTerminalOutputSuppression(page, remoteTabs) const baseline = await readPairedRetentionSample( page, remoteTabs.map((tab) => tab.tabId) @@ -217,6 +208,8 @@ export async function runPairedTerminalParkingOracle( if (!evicted) { throw new Error('Ordinary parking did not unmount a paired terminal') } + const hiddenFloodToken = + hiddenFloodTokens[remoteTabs.findIndex((tab) => tab.tabId === evicted.tabId)] const parkedMarker = `WHILE_PARKED_${Date.now()}` await callRuntime(page, 'terminal.send', { terminal: evicted.terminal, @@ -248,11 +241,18 @@ export async function runPairedTerminalParkingOracle( await restored.click() expect(await waitForActivePanePtyId(page, 30_000)).toBe(evicted.originalPtyId) await expect - .poll(() => getTerminalContent(page, 1_000_000), { timeout: 30_000 }) - .toContain(`fill-${evicted.marker}-4000-`) - await expect - .poll(() => getTerminalContent(page, 1_000_000), { timeout: 30_000 }) - .toContain(`LIVE:${parkedMarker}`) + .poll( + async () => { + const content = await getTerminalContent(page, 1_000_000) + return [ + `flood-${hiddenFloodToken}-3999-`, + `FLOODED:${hiddenFloodToken}`, + `LIVE:${parkedMarker}` + ].map((marker) => content.split(marker).length - 1) + }, + { timeout: 30_000 } + ) + .toEqual([1, 1, 1]) const liveMarker = `AFTER_RETENTION_${Date.now()}` await callRuntime(page, 'terminal.send', { terminal: evicted.terminal, diff --git a/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts new file mode 100644 index 00000000000..9cd76f0d3e5 --- /dev/null +++ b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts @@ -0,0 +1,234 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import type { + RuntimeTerminalListResult, + RuntimeTerminalRead, + RuntimeTerminalShow +} from '../../src/shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' + +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-probe-gap-')) +const fixturePath = path.join(scratch, 'probe-gap-terminal.mjs') +const processedInputPath = path.join(scratch, 'processed-input.txt') +writeFileSync(processedInputPath, '') +writeFileSync( + fixturePath, + [ + "import { appendFileSync } from 'node:fs'", + 'const processedInputPath = process.argv[2]', + "process.stdout.write('PROBE_GAP_READY\\r\\n')", + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const commands = pending.split(/\\r\\n|\\r|\\n/)', + ' pending = commands.pop() ?? ""', + ' for (const input of commands) {', + ' appendFileSync(processedInputPath, `${input}\\n`)', + ' process.stdout.write(`LIVE:${input}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function fixtureCommand(): string { + const command = [process.execPath, fixturePath, processedInputPath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map(shellQuote).join(' ') +} + +async function callRuntime(page: Page, method: string, params: unknown): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +test('replaces a stale paired stream when the PTY snapshot advanced @headful', async ({ + electronApp, + orcaPage +}) => { + test.setTimeout(90_000) + const worktreeId = await orcaPage.evaluate(() => { + const state = window.__store?.getState() + const id = state?.activeWorktreeId + if (!id || !state?.allWorktrees().some((candidate) => candidate.id === id)) { + throw new Error('Headed host did not select its seeded worktree') + } + return id + }) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedWebClient(electronApp, offer) + let terminal: string | null = null + try { + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((candidate) => candidate.id === id), + worktreeId + ), + { timeout: 30_000 } + ) + .toBe(true) + const created = await callRuntime<{ + tab: { parentTabId: string; terminal: string | null } + }>(client.page, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command: fixtureCommand(), + activate: false, + select: false, + navigation: 'caller' + }) + terminal = created.tab.terminal + if (!terminal) { + throw new Error('Paired runtime did not publish the probe-gap fixture') + } + const webTabId = toWebTerminalSurfaceTabId(created.tab.parentTabId) + await client.page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + const tab = client.page.locator(`[data-testid="sortable-tab"][data-tab-id="${webTabId}"]`) + await expect(tab).toBeVisible({ timeout: 30_000 }) + await tab.click() + await expect(tab).toHaveAttribute('data-active', 'true') + const originalPtyId = await waitForActivePanePtyId(client.page, 30_000) + const originalHostTerminal = await callRuntime<{ terminal: RuntimeTerminalShow }>( + orcaPage, + 'terminal.show', + { terminal } + ) + expect(originalHostTerminal.terminal.ptyId).not.toBeNull() + await expect + .poll(() => getTerminalContent(client.page), { timeout: 30_000 }) + .toContain('PROBE_GAP_READY') + const textarea = client.page.locator('.xterm-helper-textarea:visible').first() + await textarea.focus() + + expect( + await client.page.evaluate((target) => { + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + dropOutputUntilResubscribe: (terminals: string[]) => number + } + } + ).__remoteTerminalMultiplexAckGate + if (!gate) { + throw new Error('Remote terminal multiplex output gate is unavailable') + } + return gate.dropOutputUntilResubscribe([target]) + }, terminal) + ).toBe(1) + const missingMarker = `PROBE_GAP_MISSING_${Date.now()}` + await client.page.keyboard.type(missingMarker) + await client.page.keyboard.press('Enter') + + await expect + .poll( + () => + client.page.evaluate(() => { + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + snapshot: () => { droppedOutputFrames: number } + } + } + ).__remoteTerminalMultiplexAckGate + return gate?.snapshot().droppedOutputFrames ?? 0 + }), + { timeout: 10_000 } + ) + .toBeGreaterThan(0) + await expect + .poll(() => readFileSync(processedInputPath, 'utf8'), { timeout: 10_000 }) + .toContain(`${missingMarker}\n`) + await expect + .poll( + async () => { + const result = await callRuntime<{ terminal: RuntimeTerminalRead }>( + orcaPage, + 'terminal.read', + { terminal } + ) + return result.terminal.tail.join('\n') + }, + { timeout: 10_000 } + ) + .toContain(missingMarker) + expect(await getTerminalContent(client.page)).not.toContain(`LIVE:${missingMarker}`) + + await expect + .poll(() => getTerminalContent(client.page), { timeout: 20_000 }) + .toContain(`LIVE:${missingMarker}`) + await expect(tab).toHaveAttribute('data-active', 'true') + expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId) + const recoveredHostTerminal = await callRuntime<{ terminal: RuntimeTerminalShow }>( + orcaPage, + 'terminal.show', + { terminal } + ) + expect(recoveredHostTerminal.terminal.ptyId).toBe(originalHostTerminal.terminal.ptyId) + const hostTerminals = await callRuntime(orcaPage, 'terminal.list', { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + }) + expect( + hostTerminals.terminals + .filter((candidate) => candidate.tabId === created.tab.parentTabId) + .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) + ).toEqual([{ handle: terminal, ptyId: originalHostTerminal.terminal.ptyId }]) + + const liveMarker = `PROBE_GAP_LIVE_${Date.now()}` + await textarea.focus() + await client.page.keyboard.type(liveMarker) + await client.page.keyboard.press('Enter') + await expect + .poll(() => getTerminalContent(client.page), { timeout: 10_000 }) + .toContain(`LIVE:${liveMarker}`) + await expect + .poll(() => readFileSync(processedInputPath, 'utf8'), { timeout: 10_000 }) + .toContain(`${liveMarker}\n`) + expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId) + } finally { + await client.page + .evaluate(() => { + ;( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { release: () => void } + } + ).__remoteTerminalMultiplexAckGate?.release() + }) + .catch(() => undefined) + if (terminal) { + await callRuntime(orcaPage, 'terminal.closeTab', { terminal }).catch(() => undefined) + } + await client.dispose() + } +}) diff --git a/tests/e2e/source-control-create-pr-intent-switch.spec.ts b/tests/e2e/source-control-create-pr-intent-switch.spec.ts new file mode 100644 index 00000000000..816cf396d3c --- /dev/null +++ b/tests/e2e/source-control-create-pr-intent-switch.spec.ts @@ -0,0 +1,344 @@ +import type { TestInfo } from '@stablyai/playwright-test' +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + createStagedCommitMessageChange, + openSourceControl, + seedCreatePrComposer +} from './helpers/source-control-ai-generation' + +async function writeEvidence( + testInfo: TestInfo, + screenshotDir: string, + filename: string, + evidence: unknown +): Promise { + const evidencePath = path.join(screenshotDir, filename) + writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`) + await testInfo.attach(filename, { + path: evidencePath, + contentType: 'application/json' + }) +} + +function removeOriginRemoteIfPresent(cwd: string): void { + // Why: check presence instead of swallowing errors, so real Git failures still surface. + const remotes = execFileSync('git', ['remote'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + .split('\n') + .map((line) => line.trim()) + if (!remotes.includes('origin')) { + return + } + execFileSync('git', ['remote', 'remove', 'origin'], { cwd, stdio: 'pipe' }) +} + +test.describe('Source Control Create PR intent worktree switching', () => { + test.describe.configure({ mode: 'serial' }) + + test('keeps Create PR intent running after switching worktrees', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const { primaryWorktreeId, prWorktreeId, prWorktreePath, primaryBranch } = + await seedCreatePrComposer(orcaPage) + + const screenshotDir = path.join( + process.cwd(), + 'validation-screenshots', + `create-pr-intent-switch-${Date.now()}` + ) + mkdirSync(screenshotDir, { recursive: true }) + await testInfo.attach('validation-screenshot-dir', { + body: screenshotDir, + contentType: 'text/plain' + }) + + await orcaPage.evaluate( + ({ prWorktreeId, primaryBranch }) => { + const store = + window.__store ?? + (() => { + throw new Error('window.__store is not available') + })() + const state = store.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === prWorktreeId) + if (!worktree) { + throw new Error('Create PR intent worktree not found') + } + const repo = state.repos.find((entry) => entry.id === worktree.repoId) + if (!repo) { + throw new Error('Create PR intent repo not found') + } + const branch = worktree.branch.replace(/^refs\/heads\//, '') + + type CreatePrIntentHostedReviewCall = { + repoPath: string + input: { + base?: string + head?: string + worktreePath?: string + } + } + const testWindow = window as unknown as { + __createPRIntentPayloads: CreatePrIntentHostedReviewCall[] + __createPRIntentPushStarted: boolean + __createPRIntentPushFinished: boolean + } + testWindow.__createPRIntentPayloads = [] + testWindow.__createPRIntentPushStarted = false + testWindow.__createPRIntentPushFinished = false + store.setState((current) => ({ + getHostedReviewCreationEligibility: async () => { + // Why: eligibility stays blocked until the delayed push completes, + // so this test exercises navigation during an in-flight intent run. + if (!testWindow.__createPRIntentPushFinished) { + return { + provider: 'github' as const, + review: null, + canCreate: false, + blockedReason: 'needs_push' as const, + nextAction: 'push' as const, + defaultBaseRef: primaryBranch, + head: branch + } + } + return { + provider: 'github' as const, + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: primaryBranch, + title: 'Create PR intent after switching worktrees', + body: 'The intent flow should continue after navigation.', + head: branch + } + }, + fetchHostedReviewForBranch: async () => null, + fetchPRForBranch: async () => null, + pushBranch: async (worktreeId) => { + if (worktreeId !== prWorktreeId) { + throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`) + } + testWindow.__createPRIntentPushStarted = true + await new Promise((resolve) => setTimeout(resolve, 1500)) + testWindow.__createPRIntentPushFinished = true + }, + createHostedReview: async (repoPath, input) => { + testWindow.__createPRIntentPayloads.push({ repoPath, input }) + return { + ok: true as const, + number: 74, + url: 'https://github.com/acme/orca/pull/74' + } + }, + gitStatusByWorktree: { + ...current.gitStatusByWorktree, + [worktree.id]: [] + }, + remoteStatusesByWorktree: { + ...current.remoteStatusesByWorktree, + [worktree.id]: { + hasUpstream: true, + upstreamName: `origin/${branch}`, + ahead: 1, + behind: 0 + } + } + })) + }, + { prWorktreeId, primaryBranch } + ) + + await openSourceControl(orcaPage, prWorktreeId) + const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first() + await expect(createPr).toBeVisible({ timeout: 10_000 }) + await expect(createPr).toBeEnabled() + await createPr.click() + + await expect + .poll( + () => + orcaPage.evaluate( + () => + (window as unknown as { __createPRIntentPushStarted: boolean }) + .__createPRIntentPushStarted + ), + { timeout: 10_000 } + ) + .toBe(true) + await openSourceControl(orcaPage, primaryWorktreeId) + + await expect + .poll( + () => + orcaPage.evaluate( + () => + (window as unknown as { __createPRIntentPayloads: unknown[] }) + .__createPRIntentPayloads.length + ), + { timeout: 10_000 } + ) + .toBe(1) + + const completedWhileSwitchedEvidence = await orcaPage.evaluate(() => { + const state = window.__store?.getState() + return { + activeWorktreeId: state?.activeWorktreeId, + rightSidebarTab: state?.rightSidebarTab + } + }) + expect(completedWhileSwitchedEvidence.activeWorktreeId).toBe(primaryWorktreeId) + expect(completedWhileSwitchedEvidence.rightSidebarTab).toBe('source-control') + + await openSourceControl(orcaPage, prWorktreeId) + const payloads = await orcaPage.evaluate( + () => + ( + window as unknown as { + __createPRIntentPayloads: { + repoPath: string + input: { base?: string; head?: string; worktreePath?: string } + }[] + } + ).__createPRIntentPayloads + ) + expect(payloads).toHaveLength(1) + expect(payloads[0]).toMatchObject({ + input: { + base: primaryBranch, + head: 'e2e-secondary', + worktreePath: prWorktreePath + } + }) + await orcaPage.screenshot({ + path: path.join(screenshotDir, '01-create-pr-intent-completed-after-switch.png') + }) + await writeEvidence(testInfo, screenshotDir, 'create-pr-intent-switch-evidence.json', { + expectedOriginalWorktreeId: prWorktreeId, + expectedOtherWorktreeId: primaryWorktreeId, + completedWhileSwitched: completedWhileSwitchedEvidence, + payloads + }) + }) + + test('carries unavailable dirty intent through push to the final create preflight', async ({ + orcaPage, + registerPostElectronShutdownCleanup + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const { prWorktreeId, prWorktreePath } = await seedCreatePrComposer(orcaPage) + const remoteRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-create-pr-remote-')) + const remotePath = path.join(remoteRoot, 'origin.git') + execFileSync('git', ['init', '--bare', remotePath]) + // Why: the seeded worktree may already define origin, so make the add idempotent. + removeOriginRemoteIfPresent(prWorktreePath) + execFileSync('git', ['remote', 'add', 'origin', remotePath], { cwd: prWorktreePath }) + registerPostElectronShutdownCleanup(async () => { + removeOriginRemoteIfPresent(prWorktreePath) + rmSync(remoteRoot, { recursive: true, force: true }) + }) + createStagedCommitMessageChange(prWorktreePath) + + const finalCreateError = 'Unavailable lookup intent reached final create preflight' + await orcaPage.evaluate( + ({ prWorktreeId, finalCreateError }) => { + const store = + window.__store ?? + (() => { + throw new Error('window.__store is not available') + })() + const state = store.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === prWorktreeId) + if (!worktree) { + throw new Error('Create PR intent worktree not found') + } + const branch = worktree.branch.replace(/^refs\/heads\//, '') + const pushBranchAction = state.pushBranch + const testWindow = window as unknown as { + __unavailableIntentPushFinished: boolean + } + testWindow.__unavailableIntentPushFinished = false + + store.setState((current) => ({ + repos: current.repos.map((repo) => + repo.id === worktree.repoId + ? { + ...repo, + gitRemoteIdentity: { + canonicalKey: 'github.com/acme/orca', + remoteName: 'origin', + remoteUrl: 'https://github.com/acme/orca.git' + } + } + : repo + ), + remoteStatusesByWorktree: { + ...current.remoteStatusesByWorktree, + [prWorktreeId]: { + hasUpstream: true, + upstreamName: `origin/${branch}`, + ahead: 1, + behind: 0 + } + }, + getHostedReviewCreationEligibility: async () => { + throw new Error('Hosted review eligibility timed out') + }, + pushBranch: async (...args: Parameters) => { + const [worktreeId] = args + if (worktreeId !== prWorktreeId) { + throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`) + } + await pushBranchAction(...args) + testWindow.__unavailableIntentPushFinished = true + }, + createHostedReview: async () => ({ + ok: false as const, + code: 'validation' as const, + error: finalCreateError + }) + })) + }, + { prWorktreeId, finalCreateError } + ) + + await openSourceControl(orcaPage, prWorktreeId) + await expect(orcaPage.getByText('e2e-commit-message-generation.txt')).toBeVisible({ + timeout: 10_000 + }) + await orcaPage + .getByRole('textbox', { name: 'Commit message' }) + .fill('Exercise unavailable Create PR intent') + const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first() + await expect(createPr).toBeEnabled() + await createPr.click() + + await expect + .poll( + () => + orcaPage.evaluate( + () => + (window as unknown as { __unavailableIntentPushFinished: boolean }) + .__unavailableIntentPushFinished + ), + { timeout: 10_000 } + ) + .toBe(true) + await expect(orcaPage.getByText(finalCreateError)).toBeVisible({ timeout: 10_000 }) + }) +}) diff --git a/tests/e2e/source-control-pr-generation-switch.spec.ts b/tests/e2e/source-control-pr-generation-switch.spec.ts index 2cf0a3e9bb7..283b47416eb 100644 --- a/tests/e2e/source-control-pr-generation-switch.spec.ts +++ b/tests/e2e/source-control-pr-generation-switch.spec.ts @@ -314,196 +314,6 @@ test.describe('Source Control AI PR generation worktree switching', () => { }) }) - test('keeps Create PR intent running after switching worktrees', async ({ - orcaPage - }, testInfo) => { - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - const { primaryWorktreeId, prWorktreeId, prWorktreePath, primaryBranch } = - await seedCreatePrComposer(orcaPage) - - const screenshotDir = path.join( - process.cwd(), - 'validation-screenshots', - `create-pr-intent-switch-${Date.now()}` - ) - mkdirSync(screenshotDir, { recursive: true }) - await testInfo.attach('validation-screenshot-dir', { - body: screenshotDir, - contentType: 'text/plain' - }) - - await orcaPage.evaluate( - ({ prWorktreeId, primaryBranch }) => { - const store = - window.__store ?? - (() => { - throw new Error('window.__store is not available') - })() - const state = store.getState() - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === prWorktreeId) - if (!worktree) { - throw new Error('Create PR intent worktree not found') - } - const repo = state.repos.find((entry) => entry.id === worktree.repoId) - if (!repo) { - throw new Error('Create PR intent repo not found') - } - const branch = worktree.branch.replace(/^refs\/heads\//, '') - - type CreatePrIntentHostedReviewCall = { - repoPath: string - input: { - base?: string - head?: string - worktreePath?: string - } - } - const testWindow = window as unknown as { - __createPRIntentPayloads: CreatePrIntentHostedReviewCall[] - __createPRIntentPushStarted: boolean - __createPRIntentPushFinished: boolean - } - testWindow.__createPRIntentPayloads = [] - testWindow.__createPRIntentPushStarted = false - testWindow.__createPRIntentPushFinished = false - store.setState((current) => ({ - getHostedReviewCreationEligibility: async () => { - // Why: eligibility stays blocked until the delayed push completes, - // so this test exercises navigation during an in-flight intent run. - if (!testWindow.__createPRIntentPushFinished) { - return { - provider: 'github' as const, - review: null, - canCreate: false, - blockedReason: 'needs_push' as const, - nextAction: 'push' as const, - defaultBaseRef: primaryBranch, - head: branch - } - } - return { - provider: 'github' as const, - review: null, - canCreate: true, - blockedReason: null, - nextAction: null, - defaultBaseRef: primaryBranch, - title: 'Create PR intent after switching worktrees', - body: 'The intent flow should continue after navigation.', - head: branch - } - }, - fetchHostedReviewForBranch: async () => null, - fetchPRForBranch: async () => null, - pushBranch: async (worktreeId) => { - if (worktreeId !== prWorktreeId) { - throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`) - } - testWindow.__createPRIntentPushStarted = true - await new Promise((resolve) => setTimeout(resolve, 1500)) - testWindow.__createPRIntentPushFinished = true - }, - createHostedReview: async (repoPath, input) => { - testWindow.__createPRIntentPayloads.push({ repoPath, input }) - return { - ok: true as const, - number: 74, - url: 'https://github.com/acme/orca/pull/74' - } - }, - gitStatusByWorktree: { - ...current.gitStatusByWorktree, - [worktree.id]: [] - }, - remoteStatusesByWorktree: { - ...current.remoteStatusesByWorktree, - [worktree.id]: { - hasUpstream: true, - upstreamName: `origin/${branch}`, - ahead: 1, - behind: 0 - } - } - })) - }, - { prWorktreeId, primaryBranch } - ) - - await openSourceControl(orcaPage, prWorktreeId) - const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first() - await expect(createPr).toBeVisible({ timeout: 10_000 }) - await expect(createPr).toBeEnabled() - await createPr.click() - - await expect - .poll( - () => - orcaPage.evaluate( - () => - (window as unknown as { __createPRIntentPushStarted: boolean }) - .__createPRIntentPushStarted - ), - { timeout: 10_000 } - ) - .toBe(true) - await openSourceControl(orcaPage, primaryWorktreeId) - - await expect - .poll( - () => - orcaPage.evaluate( - () => - (window as unknown as { __createPRIntentPayloads: unknown[] }) - .__createPRIntentPayloads.length - ), - { timeout: 10_000 } - ) - .toBe(1) - - const completedWhileSwitchedEvidence = await orcaPage.evaluate(() => { - const state = window.__store?.getState() - return { - activeWorktreeId: state?.activeWorktreeId, - rightSidebarTab: state?.rightSidebarTab - } - }) - expect(completedWhileSwitchedEvidence.activeWorktreeId).toBe(primaryWorktreeId) - expect(completedWhileSwitchedEvidence.rightSidebarTab).toBe('source-control') - - await openSourceControl(orcaPage, prWorktreeId) - const payloads = await orcaPage.evaluate( - () => - ( - window as unknown as { - __createPRIntentPayloads: { - repoPath: string - input: { base?: string; head?: string; worktreePath?: string } - }[] - } - ).__createPRIntentPayloads - ) - expect(payloads).toHaveLength(1) - expect(payloads[0]).toMatchObject({ - input: { - base: primaryBranch, - head: 'e2e-secondary', - worktreePath: prWorktreePath - } - }) - await orcaPage.screenshot({ - path: path.join(screenshotDir, '01-create-pr-intent-completed-after-switch.png') - }) - await writeEvidence(testInfo, screenshotDir, 'create-pr-intent-switch-evidence.json', { - expectedOriginalWorktreeId: prWorktreeId, - expectedOtherWorktreeId: primaryWorktreeId, - completedWhileSwitched: completedWhileSwitchedEvidence, - payloads - }) - }) - test('hydrates pending PR generation after Source Control remounts', async ({ orcaPage }, testInfo) => { diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts new file mode 100644 index 00000000000..58db14bcdd1 --- /dev/null +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -0,0 +1,393 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const SEEDED_WORKSPACE_COUNT = 300 +const MARQUEE_WORKSPACE_COUNT = 102 +const MANY_LANE_COUNT = 21 +const CARDS_PER_LANE = 100 + +/** + * Why: the board used to mount every workspace card in every lane in one + * commit, which blocked the sheet open for seconds on a large workspace set. + * These assert the lane renders a window instead, and that each rendered card + * still carries its true lane index so drop targeting stays correct. + */ +test.describe('Workspace board lane virtualization', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('mounts a window of cards for a large lane and keeps lane indexes', async ({ orcaPage }) => { + await orcaPage.evaluate((count) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + + const now = Date.now() + const seeded = state.worktreesByRepo[repo.id] ?? [] + const synthetic = Array.from({ length: count }, (_, index) => { + const suffix = String(index).padStart(3, '0') + return { + id: `${repo.id}::/virtual-board-${suffix}`, + instanceId: `virtual-board-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-board-${suffix}`, + displayName: `Virtual board ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-board-${suffix}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: 'in-progress' + } + }) + + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] } + }) + }, SEEDED_WORKSPACE_COUNT) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const cards = orcaPage.locator('[data-workspace-board-card-id]') + // Why: an empty window would also satisfy "fewer than seeded"; the point of + // the change is a filled window, not a blank board. + await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + expect(await cards.count()).toBeLessThan(SEEDED_WORKSPACE_COUNT / 2) + + const indexes = await cards.evaluateAll((elements) => + elements.map((element) => + Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1) + ) + ) + expect(indexes.every((index) => Number.isInteger(index) && index >= 0)).toBe(true) + expect(new Set(indexes).size).toBe(indexes.length) + }) + + test('renders later lane indexes after the lane scrolls', async ({ orcaPage }) => { + await orcaPage.evaluate((count) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + const now = Date.now() + const seeded = state.worktreesByRepo[repo.id] ?? [] + const synthetic = Array.from({ length: count }, (_, index) => ({ + id: `${repo.id}::/virtual-scroll-${index}`, + instanceId: `virtual-scroll-${index}`, + repoId: repo.id, + path: `${repo.path}/../virtual-scroll-${index}`, + displayName: `Virtual scroll ${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-scroll-${index}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: 'in-progress' + })) + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setFilterRepoIds([]) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] } + }) + }, SEEDED_WORKSPACE_COUNT) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const cards = orcaPage.locator('[data-workspace-board-card-id]') + await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + + const readMaxIndex = (): Promise => + cards.evaluateAll((elements) => + Math.max( + ...elements.map((element) => + Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1) + ) + ) + ) + const before = await readMaxIndex() + + await orcaPage + .locator('[data-workspace-status="in-progress"] [data-workspace-board-lane-scroll]') + .first() + .evaluate((element) => { + element.scrollTop = element.scrollHeight + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + + await expect.poll(readMaxIndex, { timeout: 15_000 }).toBeGreaterThan(before) + }) + + test('bounds mounted lanes and cards while preserving a 21-status workflow', async ({ + orcaPage + }) => { + const statusIds = Array.from( + { length: MANY_LANE_COUNT }, + (_, index) => `state-${String(index + 1).padStart(2, '0')}` + ) + await orcaPage.evaluate( + ({ cardsPerLane, ids }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + const now = Date.now() + const synthetic = ids.flatMap((status, statusIndex) => + Array.from({ length: cardsPerLane }, (_, cardIndex) => { + const suffix = `${String(statusIndex + 1).padStart(2, '0')}-${String( + cardIndex + 1 + ).padStart(3, '0')}` + return { + id: `${repo.id}::/virtual-lane-${suffix}`, + instanceId: `virtual-lane-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-lane-${suffix}`, + displayName: `Virtual lane ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 20_000 - statusIndex * cardsPerLane - cardIndex, + manualOrder: 20_000 - statusIndex * cardsPerLane - cardIndex, + lastActivityAt: now - statusIndex * cardsPerLane - cardIndex, + head: '0000000000000000000000000000000000000000', + branch: `virtual-lane-${suffix}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: status + } + }) + ) + + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + state.setWorkspaceBoardColumnWidth(308) + state.setWorkspaceStatuses( + ids.map((id, index) => ({ + id, + label: `State ${index + 1}` + })) + ) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: synthetic } + }) + }, + { cardsPerLane: CARDS_PER_LANE, ids: statusIds } + ) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const board = orcaPage.locator('[data-workspace-board-selection-surface]') + const scroller = board.locator('[data-workspace-board-lane-grid]').locator('..') + const lanes = board.locator('[data-workspace-status]') + const cards = board.locator('[data-workspace-board-card-id]') + await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + + const laneBudget = await scroller.evaluate( + (element) => Math.ceil(element.clientWidth / 320) + 3 + ) + const initialLaneCount = await lanes.count() + expect(initialLaneCount).toBeLessThanOrEqual(laneBudget) + expect(await cards.count()).toBeLessThan(initialLaneCount * 40) + expect(await board.locator('*').count()).toBeLessThan(initialLaneCount * 550 + 200) + await expect(board.locator('[data-workspace-status="state-01"]')).toBeVisible() + expect(await board.locator('[data-workspace-status="state-21"]').count()).toBe(0) + + await scroller.evaluate((element) => { + element.scrollLeft = element.scrollWidth + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + + await expect(board.locator('[data-workspace-status="state-21"]')).toBeVisible() + await expect.poll(() => board.locator('[data-workspace-status="state-01"]').count()).toBe(0) + const finalIds = await lanes.evaluateAll((elements) => + elements.map((element) => (element as HTMLElement).dataset.workspaceStatus ?? '') + ) + expect(finalIds).toEqual([...finalIds].sort()) + expect(finalIds).toContain('state-21') + expect(await lanes.count()).toBeLessThanOrEqual(laneBudget) + expect(await cards.count()).toBeLessThan((await lanes.count()) * 40) + expect( + await orcaPage.evaluate(() => + window.__store?.getState().workspaceStatuses.map((status) => status.id) + ) + ).toEqual(statusIds) + + const finalLane = board.locator('[data-workspace-status="state-21"]') + const resizeHandle = finalLane.getByRole('separator', { + name: 'Resize workspace board columns' + }) + await resizeHandle.focus() + await resizeHandle.press('ArrowRight') + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().workspaceBoardColumnWidth)) + .toBe(328) + await expect(resizeHandle).toHaveAttribute('aria-valuenow', '328') + await scroller.evaluate((element) => { + element.scrollLeft = element.scrollWidth + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect(finalLane).toBeVisible() + + const sourceCard = board + .locator('[data-workspace-status="state-20"] [data-workspace-board-card-id]') + .first() + const sourceId = await sourceCard.getAttribute('data-workspace-board-card-id') + const sourceBox = await sourceCard.boundingBox() + const targetBox = await finalLane + .locator('[data-workspace-board-lane-scroll]') + .first() + .boundingBox() + if (!sourceId || !sourceBox || !targetBox) { + throw new Error('Expected visible source card and final lane drop target') + } + await orcaPage.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await orcaPage.mouse.down() + await orcaPage.mouse.move( + targetBox.x + targetBox.width / 2, + targetBox.y + Math.min(80, targetBox.height / 2), + { steps: 8 } + ) + await orcaPage.mouse.up() + await expect + .poll(() => + orcaPage.evaluate( + (worktreeId) => + window.__store?.getState().getKnownWorktreeById(worktreeId)?.workspaceStatus, + sourceId + ) + ) + .toBe('state-21') + }) + + test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => { + const statusId = 'virtual-marquee' + await orcaPage.evaluate( + ({ count, status }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + const now = Date.now() + const seeded = state.worktreesByRepo[repo.id] ?? [] + const synthetic = Array.from({ length: count }, (_, index) => ({ + id: `${repo.id}::/virtual-marquee-${index}`, + instanceId: `virtual-marquee-${index}`, + repoId: repo.id, + path: `${repo.path}/../virtual-marquee-${index}`, + displayName: `Virtual marquee ${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + manualOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-marquee-${index}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: status + })) + + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setFilterRepoIds([]) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] } + }) + state.setWorkspaceStatuses([ + { id: status, label: 'Virtual marquee' }, + ...state.workspaceStatuses.filter((entry) => entry.id !== status) + ]) + }, + { count: MARQUEE_WORKSPACE_COUNT, status: statusId } + ) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const lane = orcaPage.locator(`[data-workspace-status="${statusId}"]`) + await expect(lane.getByText(String(MARQUEE_WORKSPACE_COUNT), { exact: true })).toBeVisible() + const laneCards = lane.locator('[data-workspace-board-card-id]') + await expect.poll(() => laneCards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + const laneScroll = lane.locator('[data-workspace-board-lane-scroll]') + const box = await laneScroll.boundingBox() + if (!box) { + throw new Error('Expected the marquee lane to have a bounding box') + } + + await orcaPage.mouse.move(box.x + 2, box.y + 12) + await orcaPage.mouse.down() + await orcaPage.mouse.move(box.x + box.width - 18, box.y + 80) + await laneScroll.evaluate(async (element) => { + for (let pass = 0; pass < 4; pass++) { + element.scrollTop = element.scrollHeight + element.dispatchEvent(new Event('scroll', { bubbles: true })) + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + } + }) + await orcaPage.mouse.move(box.x + box.width - 18, box.y + box.height - 12) + await orcaPage.mouse.up() + + await expect( + orcaPage.getByText(`${MARQUEE_WORKSPACE_COUNT} selected`, { exact: true }) + ).toBeVisible() + }) +})