diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 31fd088d324..189428c47fe 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -56,17 +56,15 @@ CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-type ## Pull Requests -Each pull request should: +Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular: -- explain the user-visible change -- stay focused on a single topic when possible -- include screenshots or screen recordings for new UI or behavior changes +- open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner) +- explain what changed and why, and stay focused on a single topic when possible +- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why - include high-quality tests when behavior changes or bug fixes warrant them - include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk - mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes -- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build). - -If there is no visual change, say that explicitly in the PR description. +- **Include your X (Twitter) handle** in the PR template Author section — we shout out contributors when we merge features on [@orca_build](https://x.com/orca_build). ## Release Process diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b69e2dcc233..d620ad7c92c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,29 +1,51 @@ -## Summary +## ELI5 -Describe the user-visible change. + -## Screenshots +## What Changed -- Add screenshots or a screen recording for any new or changed UI behavior. -- If there is no visual change, say `No visual change`. + + +## Why + + + +## Linked Issue + + + +Fixes # + +## Visual Proof + + + ## Testing -- [ ] `pnpm lint` -- [ ] `pnpm typecheck` -- [ ] `pnpm test` -- [ ] `pnpm build` -- [ ] Added or updated high-quality tests that would catch regressions, or explained why tests were not needed + -## AI Review Report +- [ ] I manually tested these changes locally +- [ ] Automated tests added/updated, or explained why not below -Summarize the code review you ran with your AI coding agent. Include the main risks it checked, what it flagged, and what you changed or verified as a result. -Confirm that the review explicitly checked cross-platform compatibility for macOS, Linux, and Windows, including shortcuts, labels, paths, shell behavior, and any Electron-specific platform differences touched by this PR. -## Security Audit +## AI Disclosure + -Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed. +## Review -## Notes +Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance -Call out any platform-specific behavior, risks, or follow-up work. +## Checklist + +- [ ] This PR is small and focused +- [ ] I explained what changed and why (including ELI5) +- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason +- [ ] Self-reviewed for correctness, security, and performance +- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A) +- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred) + +## Author + +- X / Twitter: @your_handle + diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml new file mode 100644 index 00000000000..5670c634d2d --- /dev/null +++ b/.github/workflows/daily-mac-build.yml @@ -0,0 +1,471 @@ +name: Daily macOS Dev Build + +# Why: gives developers a signed macOS build of main once a day that the in-app +# updater can install directly, without waiting for an RC cut — less noise than +# hourly. +# +# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules). +# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute +# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour. +# +# 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 AND notarized, exactly like a release. The notary round trip is the +# one slow step kept: macOS anchors a notarized app's TCC grants on identifier +# + team rather than on its cdhash, so those grants survive an update. Without +# a ticket every daily reads as a new client and silently loses file access +# under Documents/Desktop/Downloads. +# +# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main +# repo's releases atom feed exposes only its 10 newest entries, so high-volume +# dev tags would evict every stable/RC entry and break updates for real users. +# Separate from orca-hourly so someone riding main's hourlies never sees the +# sparse daily series mixed into that list. +# +# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the +# same GitHub App as hourly (installed on orca-daily with Contents: Read and +# write). Provision the App secrets with +# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access +# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`: +# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc) +# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key +# +# Installation tokens live one hour, which is why this mints three times. Install +# and build need no token at all, and notarization can hold the publish step for +# tens of minutes; minting again once the build is done starts the clock at the +# first call that actually uses it rather than burning a third of it on +# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that +# second token, so a third is minted after it for verify/publish/prune/cleanup — +# without it a stuck notary run would strand a draft that cleanup gets a 401 on. +# The release stays an unpublished draft until the manifest check passes, so the +# worst case is still an invisible draft — and the build-number query counts +# drafts, so it holds its number and the next run does not reuse it. + +on: + schedule: + # Once a day, early morning Pacific. Single cron — no DST twin, no clock gate. + - cron: '15 14 * * *' + workflow_dispatch: + inputs: + force: + description: Build even if main has not moved since the last daily + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: daily-mac-build + cancel-in-progress: false + +env: + DAILY_REPO: stablyai/orca-daily + # Keep ~30 days so a regression can be bisected without holding a full year. + DAILY_RETAIN_COUNT: 30 + +jobs: + build-daily-mac: + if: github.repository == 'stablyai/orca' + runs-on: blacksmith-6vcpu-macos-15 + # Why 150: it must exceed the worst case the retry budgets below can produce + # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or + # the job is killed mid-retry and no cleanup step runs at all. A typical run + # is far shorter — this is the notary queue's tail, not its median. + timeout-minutes: 150 + 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 daily 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 daily 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-daily + + # Why: main is often idle. 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 daily + 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 daily 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 "$DAILY_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 daily release found; building $head_sha." + exit 0 + fi + last_sha="$(gh release view "$last_body" --repo "$DAILY_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 daily 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 a daily 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 daily version + id: daily + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + MAIN_REPO_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Existing titles, which carry the build number this series continues + # from. The script picks the number, because it restarts per base version + # and only the script knows which base this build resolved to. + # + # Why drafts count here but not in the freshness check: that check asks + # "did this commit ship", where a draft is a no. This one asks "is the + # number free", where a stranded draft still holds one. + names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \ + --jq '.[].name // empty')" + # Why the main repo's tags decide the base version rather than + # package.json: main's version only moves on `release:` commits, and + # stable patches are cut from release branches that never merge back, so + # package.json can sit several patches behind what users are running. A + # separate token because GH_TOKEN above is the App's, scoped to the + # daily repo. Empty on failure — the script then falls back to + # package.json, which is stale but never wrong enough to fail a build. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ + --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ + --json tagName --jq '.[].tagName' || true)" + echo "Highest published tag seen: $(head -1 <<<"$published")" + ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \ + node config/scripts/daily-build-version.mjs \ + >"$RUNNER_TEMP/daily-identity.txt" + grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt" + # Why check rather than trust: the checkout above pins `ref: main`, but a + # workflow_dispatch runs this file from whatever branch was dispatched. A + # branch that edits this step while main still has the old script yields + # an empty name and an untitled release — silent, and only visible once + # someone opens the releases page. Fail here instead. + if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then + echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync." + exit 1 + fi + cat "$RUNNER_TEMP/daily-identity.txt" >>"$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: daily 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 + + # Why a second mint: everything from here on writes to the daily repo, and + # the notary round trip inside the publish step can be tens of minutes. The + # token minted at the top has already spent install + build of its one hour + # on steps that never touched it; restarting the clock here gives the slow + # part the full budget. + - name: Re-mint daily repo token for publish + id: app_token_publish + if: steps.freshness.outputs.should_build == 'true' + 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-daily + + - name: Create daily release + id: release + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} + TAG: v${{ steps.daily.outputs.version }} + NAME: ${{ steps.daily.outputs.name }} + SHA: ${{ steps.freshness.outputs.head_sha }} + run: | + set -euo pipefail + # Kept at 12 even though the title shows 7: the freshness check above + # parses this back out of the body to decide whether main has moved. + short_sha="${SHA:0:12}" + # Why a file rather than an inline string: the publish step re-asserts + # this same body, and the freshness check only works if the two agree + # exactly. One source, written once, read twice. + notes_file="$RUNNER_TEMP/daily-release-notes.md" + cat >"$notes_file" <>"$GITHUB_OUTPUT" + echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT" + + - name: Publish daily macOS artifacts + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + # Why 45 like the release pipeline: an attempt is pack + notarize + + # upload, and the notary queue is the unbounded part. Why 2 attempts and + # not 3: a missed daily costs a day, and a third attempt buys less than + # it costs in runner time once the notary is that stuck. + timeout_minutes: 45 + max_attempts: 2 + retry_wait_seconds: 30 + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=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-daily. + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} + ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }} + ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }} + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + # Why all three: electron-builder's notarize step authenticates to the + # Apple notary service with the app-specific password, not with the + # signing cert. Omitting them fails the build rather than skipping it, + # since `notarize` is now on for this path. + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Why a third mint: the upload step above allows two 45-minute attempts, so + # it can outlive the one-hour token minted before it. Every remaining step + # writes to the daily repo, including the failure path that discards the + # draft — a 401 there is exactly the stranded draft nobody can clean up. + # + # Why always(): a failed or cancelled upload is the case that needs this + # most, since the cleanup step below runs only on that path. + - name: Re-mint daily repo token for verify and cleanup + id: app_token_final + if: always() && steps.freshness.outputs.should_build == 'true' + 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-daily + + # 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_final.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + assets="$(gh release view "$TAG" --repo "$DAILY_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::Daily draft $TAG is missing $required; the updater could not install it." + exit 1 + fi + done + if ! grep -q '\.zip$' <<<"$assets"; then + echo "::error::Daily 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_final.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + NAME: ${{ steps.daily.outputs.name }} + NOTES_FILE: ${{ steps.release.outputs.notes_file }} + run: | + set -euo pipefail + # --title again: electron-builder resolves this draft by tag and may + # rewrite its title on upload. Re-asserting here means the name the + # picker reads is the one composed above, whatever it did in between. + # --notes-file for the same reason, and it matters more: the next run's + # freshness check parses the source commit back out of this body, so a + # body the publisher overwrote would rebuild an unchanged main daily. + gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \ + --title "$NAME" --notes-file "$NOTES_FILE" + echo "Published $TAG as \"$NAME\"" + + # 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: + # Fall back to the publish token: if the re-mint itself is what failed, + # the older token is the only one left and may still have time on it. + GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.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 "$DAILY_REPO" --yes || + echo "::warning::Could not discard draft $TAG; remove it manually." + + - name: Prune old daily releases + # Only after a live publish: $TAG is then a non-draft we must not delete, + # and failed runs should not reshuffle retention around a draft that the + # failure path is about to discard. + if: steps.publish_live.outcome == 'success' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + # Protect the tag this run just shipped; at the retain cap, a bad sort + # can otherwise mark the newest release as stale and delete it. + TAG: ${{ steps.release.outputs.tag }} + 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. + # + # Sort by publishedAt (not createdAt). Many non-draft dailies can share + # one createdAt (bulk import / re-create), so createdAt ranking is + # unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is + # the deterministic tie-break. + # + # Why force $TAG to the front before slicing: a hard retain-window seat + # for this run's release. Dropping $TAG from the list *before* the slice + # would permanently keep retain+1 releases; skipping it only in the + # delete loop would under-prune when the sort is still wrong. Partition + # keeps relative order of every other tag. + jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse' + if [[ -n "${TAG:-}" ]]; then + jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))" + fi + jq_filter+=" | .[${DAILY_RETAIN_COUNT}:] | .[].tagName" + stale="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName,publishedAt,isDraft \ + --jq "$jq_filter")" + if [[ -z "$stale" ]]; then + echo "Nothing to prune; at or under $DAILY_RETAIN_COUNT retained builds." + exit 0 + fi + while read -r tag; do + [[ -n "$tag" ]] || continue + # Belt-and-suspenders: partition above should already exclude $TAG. + if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then + echo "::warning::Prune list still included just-published $tag after protect; skipping delete." + continue + fi + echo "Pruning $tag" + gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \ + echo "::warning::Could not prune $tag" + done <<<"$stale" diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 9e31a0b94f4..339505a9fe9 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -30,9 +30,11 @@ jobs: # (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it # ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26. runs-on: macos-26 - # Archive + upload is ~30–40m when healthy; 90m leaves margin without - # sitting multi-hour on ASC processing polls (see Fastfile pilot wait). + # Archive + upload is ~30–40m when healthy; 90m leaves margin for setup. timeout-minutes: 90 + outputs: + release_version: ${{ steps.release_metadata.outputs.version }} + build_number: ${{ steps.release_metadata.outputs.build_number }} defaults: run: @@ -79,6 +81,10 @@ jobs: FASTLANE_HIDE_CHANGELOG: '1' run: bundle exec fastlane ios prepare_release_version version:"$MOBILE_IOS_RELEASE_VERSION" bump_patch:"$MOBILE_IOS_BUMP_PATCH_VERSION" + - name: Capture release metadata + id: release_metadata + run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)' + - name: Expo prebuild run: npx expo prebuild --platform ios --no-install @@ -122,11 +128,10 @@ jobs: ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }} # Keep fastlane non-interactive and quiet about analytics in CI. FASTLANE_SKIP_UPDATE_CHECK: '1' FASTLANE_HIDE_CHANGELOG: '1' - run: bundle exec fastlane ios release + run: bundle exec fastlane ios build_and_upload - name: Upload .ipa artifact if: always() @@ -135,3 +140,44 @@ jobs: name: orca-mobile-ipa path: mobile/build/*.ipa if-no-files-found: ignore + + ios-distribute: + name: Distribute to external TestFlight testers + needs: ios-build + runs-on: ubuntu-latest + # Fastlane's processing wait fails after 20m; this outer bound leaves setup + # margin without allowing App Store Connect polling to hang for hours. + timeout-minutes: 30 + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Ruby and fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: mobile + + - name: Wait for processing and distribute to peeps + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} + TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }} + MOBILE_IOS_RELEASE_VERSION: ${{ needs.ios-build.outputs.release_version }} + MOBILE_IOS_BUILD_NUMBER: ${{ needs.ios-build.outputs.build_number }} + FASTLANE_SKIP_UPDATE_CHECK: '1' + FASTLANE_HIDE_CHANGELOG: '1' + run: | + if ! bundle exec fastlane ios distribute_testflight \ + version:"$MOBILE_IOS_RELEASE_VERSION" \ + build_number:"$MOBILE_IOS_BUILD_NUMBER"; then + echo "::error::TestFlight $MOBILE_IOS_RELEASE_VERSION ($MOBILE_IOS_BUILD_NUMBER) was uploaded but not distributed to peeps. If App Store Connect is still processing it, rerun only this failed job after the build becomes Ready; otherwise inspect the Fastlane error above." + exit 1 + fi diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 05a73229d69..3c9678a5a8d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -176,6 +176,12 @@ jobs: shell_contracts: name: shell contracts runs-on: ubuntu-latest + env: + # Why: the suites below gate their live fish tests on the binary, which is + # right on a developer machine and wrong here — this job is a required check + # and its fish lane is the only end-to-end guard for #9993, so a skip would + # report green with nothing exercised. Turns those skips into failures. + ORCA_REQUIRE_FISH: '1' steps: - name: Checkout @@ -186,8 +192,46 @@ jobs: # Why fish: shell-ready.test.ts gates its live fish test on the binary being # present, so without this the fish barrier is only covered by config-shape # assertions and never actually exercised. + # Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and + # fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships + # 3.7, so the PPA is what makes that lane real. - name: Install zsh and fish - run: sudo apt-get update && sudo apt-get install -y zsh fish + run: | + # Why the update/PPA/fish steps are tolerant: a repo the runner image already + # ships can lack a Release file for this suite, and a failed add-apt-repository + # still leaves its list entry behind — either makes `apt-get update` exit + # non-zero and would red this required check over something unrelated to the + # PR. Every fish outcome is judged by the version gate below instead, so only + # the zsh install (which has no such gate) stays fatal here. + sudo apt-get update || true + # Why retry only here: adding the PPA is the network-flaky step, and the + # version gate below is fatal, so a transient Launchpad blip would + # otherwise red a required check on PRs unrelated to shells. + for attempt in 1 2 3; do + sudo add-apt-repository -y ppa:fish-shell/release-4 && break + echo "add-apt-repository attempt ${attempt} failed; retrying" >&2 + sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true + sleep 5 + done + sudo apt-get update || true + # Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the + # first install command in this step to prove the lane really installs them. + sudo apt-get install -y zsh fish + + # Separate from the install so the failure names the contract, not an apt error. + # ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds + # instead of after a full dependency install. + - name: Require fish 4+ + run: | + version="$(fish --version 2>/dev/null || true)" + major="${version##*version }" + major="${major%%.*}" + case "$major" in '' | *[!0-9]*) major=0 ;; esac + echo "${version:-}" + if [ "$major" -lt 4 ]; then + echo "::error::shell contracts needs fish 4+ (DECSET 2031 arming, #9993) but got '${version:-none}'. Fix the ppa:fish-shell/release-4 install rather than letting the fish lane skip." >&2 + exit 1 + fi - uses: ./.github/actions/install-node-dependencies with: @@ -195,12 +239,14 @@ jobs: - name: Test real shell contracts run: | - pnpm exec vitest run --config config/vitest.config.ts \ + pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \ + src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ src/main/daemon/shell-ready.test.ts \ src/main/daemon/node-pty-fd-leak.test.ts \ src/main/providers/local-pty-shell-ready.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/posix-command-path-lookup.test.ts test: @@ -230,11 +276,13 @@ jobs: - name: Test shard run: | pnpm exec vitest run --config config/vitest.config.ts \ + --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ --exclude=src/main/daemon/shell-ready.test.ts \ --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ --exclude=src/main/providers/local-pty-shell-ready.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} @@ -378,6 +426,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Test Windows PowerShell CLI boundary + run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts + - name: Build package inputs run: pnpm run build:release diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml new file mode 100644 index 00000000000..8014145939b --- /dev/null +++ b/.github/workflows/release-policy.yml @@ -0,0 +1,98 @@ +name: Release Policy + +on: + release: + types: + - published + - edited + +permissions: + contents: write + +concurrency: + group: release-policy + cancel-in-progress: false + +jobs: + enforce: + if: github.repository == 'stablyai/orca' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Enforce release policy + uses: actions/github-script@v8 + with: + script: | + const release = context.payload.release; + const tag = release.tag_name; + const author = release.author?.login; + const number = "(?:0|[1-9][0-9]*)"; + const version = `${number}\\.${number}\\.${number}`; + const stableTag = new RegExp(`^v${version}$`); + const prereleaseTag = new RegExp( + `^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`, + ); + const expectedPrerelease = prereleaseTag.test(tag); + const allowed = author === "github-actions[bot]" && + (stableTag.test(tag) || expectedPrerelease); + const { owner, repo } = context.repo; + + async function restoreLatestStable() { + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, + repo, + per_page: 100, + }); + const stable = releases + .filter((candidate) => + !candidate.draft && + !candidate.prerelease && + candidate.author?.login === "github-actions[bot]" && + stableTag.test(candidate.tag_name), + ) + .sort((left, right) => { + const a = left.tag_name.slice(1).split(".").map(Number); + const b = right.tag_name.slice(1).split(".").map(Number); + return a.reduce((result, part, index) => result || part - b[index], 0); + }) + .at(-1); + if (stable) { + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: stable.id, + make_latest: "true", + }); + } + } + + if (allowed) { + if (release.prerelease !== expectedPrerelease) { + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + prerelease: expectedPrerelease, + make_latest: expectedPrerelease ? "false" : "legacy", + }); + } + await restoreLatestStable(); + return; + } + + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + draft: true, + prerelease: true, + make_latest: "false", + }); + await restoreLatestStable(); + await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id }); + try { + await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` }); + } catch (error) { + if (error.status !== 404) throw error; + } + core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`); diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index fb1d63a440a..8a645fd8ef8 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -77,7 +77,6 @@ jobs: '!config/reliability-gates.jsonc', '!config/max-lines-baseline.txt', '!config/vitest.config.ts', - 'config/build-plugins/**', 'native/**', 'resources/**', 'electron.vite.config.ts', diff --git a/.gitignore b/.gitignore index fc40e495e7a..64854199a35 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,7 @@ docs/** !docs/readme/ !docs/readme/** !docs/STYLEGUIDE.md +!docs/ai-vault-process-isolation-plan.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ !docs/reference/git-compatibility.md @@ -101,6 +102,7 @@ docs/** !docs/reference/linux-glibc-compatibility.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/remote-wire-compatibility.md +!docs/reference/renderer-agent-status-performance.md !docs/reference/windows-setup-shell.md # Stably CLI (only docs/ are tracked) diff --git a/.oxlintrc.json b/.oxlintrc.json index 0f679f17d52..97a2b68701a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -56,6 +56,7 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "typescript/array-type": "error", + "typescript/consistent-generic-constructors": "error", "typescript/consistent-indexed-object-style": "error", "typescript/consistent-type-assertions": "error", "typescript/consistent-type-definitions": ["error", "type"], @@ -68,12 +69,16 @@ "typescript/prefer-function-type": "error", "typescript/prefer-includes": "error", "typescript/prefer-optional-chain": "error", + "typescript/prefer-ts-expect-error": "error", "typescript/switch-exhaustiveness-check": [ "error", { "allowDefaultCaseForExhaustiveSwitch": false } ], "curly": "error", "no-unneeded-ternary": "error", + "no-else-return": "error", + "prefer-exponentiation-operator": "error", + "prefer-object-has-own": "error", "no-restricted-imports": [ "error", { @@ -90,11 +95,16 @@ "no-useless-return": "error", "prefer-template": "error", "unicorn/consistent-empty-array-spread": "error", + "unicorn/consistent-existence-index-check": "error", + "unicorn/empty-brace-spaces": "error", "unicorn/error-message": "error", "unicorn/no-array-fill-with-reference-type": "warn", "unicorn/no-array-reverse": "error", "unicorn/no-instanceof-builtins": "error", + "unicorn/no-typeof-undefined": "error", + "unicorn/no-unnecessary-array-splice-count": "error", "unicorn/no-useless-promise-resolve-reject": "error", + "unicorn/no-zero-fractions": "error", "unicorn/prefer-array-find": "error", "unicorn/prefer-array-flat-map": "warn", "unicorn/prefer-array-index-of": "error", diff --git a/AGENTS.md b/AGENTS.md index 67bcd37a538..58698e46a2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. +## Electron UI Validation + +Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation. + # Style ## Concise/Brief Non-obviosu comments ONLY * DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW") diff --git a/README.md b/README.md index 670b54e2835..b679cb95c3d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) +- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) --- diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index b4b86a31c31..45d34b944f7 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -1,7 +1,8 @@ -import { spawnSync } from 'node:child_process' +import { spawn } from 'node:child_process' import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' +type NormalizedInputOptions = Rollup.NormalizedInputOptions type NormalizedOutputOptions = Rollup.NormalizedOutputOptions type OutputBundle = Rollup.OutputBundle type OutputChunk = Rollup.OutputChunk @@ -42,9 +43,34 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'port-scan-command-worker-entry' ] as const +export const GUARDED_ENTRY_NAMES = [ + ...PLAIN_NODE_ENTRY_NAMES, + ...WORKER_THREAD_ENTRY_NAMES +] as const + type EntryRuntime = 'plain-Node process' | 'worker thread' -const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/ +// Subpaths (electron/main) are as unloadable as the bare module under plain Node. +const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/ + +// Why: writeBundle skips any name missing from the bundle, so a renamed or +// removed rollup input would silently drop that entry from the guard and let the +// regression back in. Pin the lists to the input keys at build start instead. +function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void { + if (typeof input === 'string' || Array.isArray(input)) { + return + } + const inputNames = new Set(Object.keys(input)) + const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name)) + if (missing.length > 0) { + throw new Error( + `[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` + + `${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` + + `Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` + + `the current entry names — a stale name silently stops guarding that entry.` + ) + } +} function collectReachableChunks( entry: OutputChunk, @@ -89,41 +115,125 @@ function assertNoElectronRequire( } } +// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync. +const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry' + +export type SmokeTimings = { + timeoutMs: number + // daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline + // needs an uncatchable follow-up to stay a deadline. + killGraceMs: number +} + +const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 } + +// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio +// can hold the pipes open long after the child is gone. +const SMOKE_STDERR_DRAIN_MS = 250 + +type SmokeResult = { + status: number | null + signal: NodeJS.Signals | null + stderr: string + error?: Error + timedOut: boolean +} + +// Why not spawnSync({ timeout }): its timeout only sends killSignal and then +// keeps blocking until the child exits, so a child that traps SIGTERM hangs the +// build forever. Escalate to SIGKILL instead. +function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = '' + let timedOut = false + let settled = false + let forceKillTimer: NodeJS.Timeout | undefined + let drainTimer: NodeJS.Timeout | undefined + + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { + stderr += chunk + }) + + const deadlineTimer = setTimeout(() => { + timedOut = true + child.kill('SIGTERM') + forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs) + }, timings.timeoutMs) + + const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(deadlineTimer) + clearTimeout(forceKillTimer) + clearTimeout(drainTimer) + resolve({ status, signal, stderr, error, timedOut }) + } + + child.on('error', (error: Error) => finish(null, null, error)) + // 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe + // cannot outlast the process itself. + child.on('close', (status, signal) => finish(status, signal)) + child.on('exit', (status, signal) => { + drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS) + }) + }) +} + // Why: proves the whole daemon-entry graph resolves under plain Node (no // unresolved requires). require("electron") does not throw in a dev tree with // node_modules present, so the static scan above — not this smoke — is the // electron regression guard; this only catches gross load failures. -function smokeLoadDaemonEntry(outputDir: string): void { +async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise { const entryPath = join(outputDir, 'daemon-entry.js') - const result = spawnSync(process.execPath, [entryPath], { - encoding: 'utf8', - timeout: 15_000 - }) + const result = await runDaemonEntry(entryPath, timings) if (result.error) { throw new Error( `[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` + `${result.error.message}` ) } - const stderr = result.stderr ?? '' + // Almost always means the daemon stopped rejecting an empty argv and started + // listening instead. + if (result.timedOut) { + throw new Error( + `[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` + + `empty argv under plain Node, so the smoke killed it.` + ) + } + if (result.signal) { + throw new Error( + `[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.` + ) + } + const stderr = result.stderr if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) { throw new Error( `[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}` ) } - if (!stderr.includes('Usage: daemon-entry')) { + if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) { throw new Error( - `[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` + - `(expected the "Usage: daemon-entry" error). stderr:\n${stderr}` + `[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` + + `(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` + + `${result.status}). stderr:\n${stderr}` ) } } -export function createPlainNodeEntryGuardPlugin(): Plugin { +export function createPlainNodeEntryGuardPlugin( + smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS +): Plugin { let daemonOutputDir: string | undefined return { name: 'orca-plain-node-entry-guard', + buildStart(options: NormalizedInputOptions) { + assertEntryNamesAreRollupInputs(options.input) + }, writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) { // Why: skip in `electron-vite dev` watch mode — the smoke would respawn on // every rebuild, and the guard only needs to gate produced builds. @@ -159,11 +269,11 @@ export function createPlainNodeEntryGuardPlugin(): Plugin { daemonOutputDir = options.dir } }, - closeBundle() { + async closeBundle() { if (daemonOutputDir) { const outputDir = daemonOutputDir daemonOutputDir = undefined - smokeLoadDaemonEntry(outputDir) + await smokeLoadDaemonEntry(outputDir, smokeTimings) } } } diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e581cd7a44e..b01c26997e1 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -20,21 +20,32 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime. // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to // swap them over an installed Orca and macOS treats each build as a new app. const isMacHourly = process.env.ORCA_MAC_HOURLY === '1' +const isMacDaily = process.env.ORCA_MAC_DAILY === '1' const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1' -const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc +const isMacRelease = + process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION const devChannelBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION - : isMacAdhoc - ? process.env.ORCA_ADHOC_BUILD_VERSION - : undefined + : isMacDaily + ? process.env.ORCA_DAILY_BUILD_VERSION + : isMacAdhoc + ? process.env.ORCA_ADHOC_BUILD_VERSION + : undefined // Why each dev channel gets its own repo rather than tagging into the main one: // the releases atom feed 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. Keeping adhoc separate from hourly too means a branch build cannot -// be picked up by someone who only meant to ride main. -const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null +// to install. Keeping adhoc/daily separate from hourly too means a branch build +// or a once-a-day cut cannot be picked up by someone who only meant to ride +// main's hourlies. +const devChannelRepo = isMacHourly + ? 'orca-hourly' + : isMacDaily + ? 'orca-daily' + : isMacAdhoc + ? 'orca-adhoc' + : null const appId = 'com.stablyai.orca' const featureWallResources = { from: 'resources/onboarding/feature-wall', @@ -155,6 +166,12 @@ module.exports = { // Why: sherpa-onnx native bindings (platform-specific subpackages) must be // unpacked because they ship .node addons + .dylib/.so files that cannot be // dlopen()'d from inside the asar archive. + // Why: the OpenCode SQLite worker entry is also spawned by the scanner + // service, which runs under ELECTRON_RUN_AS_NODE and so cannot see into + // app.asar. Left packed, that spawn fails closed and every OpenCode session + // disappears from Agent Session History in packaged builds only. Worker + // entries reached solely from the Electron main process stay packed, since + // asar redirects their app.asar paths. asarUnpack: [ 'out/package.json', 'out/cli/**', @@ -171,6 +188,8 @@ module.exports = { 'out/main/grok/**', 'out/main/hermes/**', 'out/main/daemon-entry.js', + 'out/main/session-scanner-service-entry.js', + 'out/main/session-scanner-opencode-sqlite-worker-entry.js', 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', 'out/main/parcel-watcher-process-entry.js', diff --git a/config/electron-vite-target.config.ts b/config/electron-vite-target.config.ts index 60651dd6590..490fa654f5b 100644 --- a/config/electron-vite-target.config.ts +++ b/config/electron-vite-target.config.ts @@ -8,7 +8,7 @@ const configByTarget = { renderer: { renderer: electronViteConfig.renderer } } -if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) { +if (!target || !Object.hasOwn(configByTarget, target)) { throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? ''}`) } diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index b37d3374bf5..1f445997bda 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -1,13 +1,6 @@ [ { - "filePath": "src/renderer/src/lib/agent-background-session-test-state.ts", - "kind": "object-property:title", - "text": "Terminal 1", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx", + "filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx", "kind": "jsx-attribute:label", "text": "sidebar", "dynamic": false, diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 3275e33987a..59368273624 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -232,10 +232,8 @@ inline src/renderer/src/components/settings/AgentsPane.tsx inline src/renderer/src/components/settings/RepositoryHooksSection.tsx inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx -inline src/renderer/src/components/settings/SettingsFormControls.tsx inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx -inline src/renderer/src/components/sidebar/WorktreeCard.tsx inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts inline src/renderer/src/components/sidebar/WorktreeList.tsx @@ -246,7 +244,6 @@ inline src/renderer/src/components/stats/usage-overview-model.ts inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx inline src/renderer/src/components/status-bar/StatusBar.tsx inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx -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 @@ -270,7 +267,6 @@ inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts inline src/renderer/src/hooks/useIpcEvents.test.ts inline src/renderer/src/hooks/useIpcEvents.ts -inline src/renderer/src/hooks/useIssueMetadata.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/lib/active-agent-note-send.test.ts inline src/renderer/src/lib/file-type-icons.ts @@ -342,7 +338,6 @@ mobile-config app/h/*/source-control/*.tsx mobile-config app/h/*/tasks.tsx mobile-config app/index.tsx mobile-config app/pair-scan.tsx -mobile-config app/terminal-settings.tsx mobile-config app/troubleshoot.tsx mobile-config scripts/mock-server.ts mobile-config scripts/repro-terminal-colors.ts diff --git a/config/oxlint-code-quality-native-plugins.json b/config/oxlint-code-quality-native-plugins.json index 5d9cd118e74..5f2fb031a51 100644 --- a/config/oxlint-code-quality-native-plugins.json +++ b/config/oxlint-code-quality-native-plugins.json @@ -16,6 +16,7 @@ "import/namespace": "warn", "import/no-cycle": ["warn", { "maxDepth": 3 }], "import/no-duplicates": "warn", + "import/no-empty-named-blocks": "warn", "import/no-self-import": "warn" }, "overrides": [ diff --git a/config/oxlint-code-quality-type-aware.json b/config/oxlint-code-quality-type-aware.json index 4c0e01fa748..8ae3cbab60e 100644 --- a/config/oxlint-code-quality-type-aware.json +++ b/config/oxlint-code-quality-type-aware.json @@ -12,6 +12,7 @@ }, "rules": { "typescript/await-thenable": "warn", + "typescript/no-redundant-type-constituents": "warn", "typescript/restrict-plus-operands": "warn", "typescript/restrict-template-expressions": "warn", "typescript/switch-exhaustiveness-check": [ diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3e4a32a0cfb..c9bf94a1731 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-07", + "updatedAt": "2026-08-12", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -11,101 +11,101 @@ }, "gates": [ { - "id": "ai-vault.live-session-delete-authority", - "title": "AI Vault deletion fails closed against owning-runtime session liveness", + "id": "terminal-session.shell-ready-exec-prompt-fallback", + "title": "Startup exec falls back to the identified shell's line editor", "maturity": "experimental", "protection": "partial", - "owner": "ai-vault-runtime", - "layer": "main-process-filesystem-mutation", - "surfaces": [ - "AI Vault transcript deletion", - "local and WSL agent sessions", - "paired-client-owned host sessions" - ], - "platforms": ["macos", "linux", "windows"], - "providers": ["local", "wsl", "ssh", "paired-runtime"], - "coveredPlatforms": ["macos", "linux", "windows"], - "coveredProviders": ["local", "wsl", "ssh", "paired-runtime"], - "coverageNotes": "Platform-independent contracts cover local and WSL authority, paired-owned and unmanaged live sessions absent from renderer or managed-PTY state, identity retention across UI dismissal, inventory and foreground-inspection ordering, SSH exclusion, unavailable and ambiguous inventory, bounded inspection concurrency, oversized inventories, returned rejection, and real temporary-file survival. Live headed/headless paired-runtime, physical Windows/WSL, and Docker SSH journeys remain uncollected.", - "motivatingLinks": ["https://github.com/stablyai/orca/releases/tag/v1.4.177-rc.0"], - "invariant": "Main may delete a local AI transcript only after the owning runtime freshly proves that no live local or WSL provider-session identity exists, including exact hook identities absent from managed PTY inventory; live and unknown liveness preserve the transcript regardless of renderer freshness.", - "oracle": "Open confirmation against a stale empty renderer snapshot, transition the authoritative session to live before main authorization and during a controlled foreground-inspection barrier, and separately model paired and unmanaged local owners absent from renderer or managed-PTY state. Dismiss a live row and require its identity-only authority to remain. Require session-live or session-liveness-unknown rejection and the real temporary transcript to survive. Make process inventory unavailable or ambiguous and require unknown with the file preserved; prove known other sessions permit deletion and inspection concurrency stays at or below eight.", + "owner": "terminal-session-readiness", + "layer": "posix-pty-and-shell-startup", + "surfaces": ["queued terminal startup commands", "zsh and bash startup files"], + "platforms": ["macos", "linux"], + "providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "local-daemon", "ssh-relay"], + "coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity stripping plus provider- and renderer-owned delivery. Renderer-owned relay fallback publishes the existing ready marker, so mixed client/host versions need no new wire capability. Live SSH, paired-runtime, and WSL remain gaps.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/13767"], + "invariant": "Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, prompt fallback releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger early fallback release.", + "oracle": "Run the real generated wrappers under node-pty with disposable zsh and bash startup files. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must strip the identity and release through the same prompt contract.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/relay/pty-handler.test.ts" ], "testFiles": [ - "src/main/ai-vault/session-delete-liveness.repro.test.ts", - "src/main/ai-vault/session-liveness.test.ts", - "src/main/agent-hooks/server-ai-vault-liveness.test.ts" + "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts", + "src/main/line-editor-ready-output-scanner.test.ts", + "src/main/shell-startup-identity-scanner.test.ts", + "src/main/shell-prompt-readiness-probe.test.ts", + "src/shared/pty-slave-line-discipline-echo.test.ts", + "src/shared/shell-process-readiness.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/shell-ready.test.ts", + "src/main/providers/local-pty-shell-ready.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/relay/pty-shell-launch.test.ts", + "src/relay/pty-handler.test.ts" ], "assertionRefs": [ { - "file": "src/main/ai-vault/session-delete-liveness.repro.test.ts", + "file": "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts", "assertions": [ - "confirmation TOCTOU returns session-live and preserves the real transcript", - "paired ownership missing from the renderer snapshot returns session-live and preserves the real transcript", - "exact live local hook identity missing from managed PTY inventory returns unknown and preserves the real transcript", - "dismissed live status retained as identity-only authority returns unknown and preserves the real transcript", - "unknown authoritative liveness fails closed and preserves the real transcript" + "real zsh .zshenv/.zprofile and bash profile replacement shells receive queued input exactly once", + "same-shell raw silent reads retain queued input", + "child-process raw silent reads retain queued input while the identified shell is not foreground", + "a blocking zle-line-init read retains queued input until the hook completes", + "sqlite3 and a sqlite3 image renamed zsh cannot impersonate the launched shell executable path", + "zsh configurations without a line-editor enable sequence retain the existing timeout backstop" ] }, { - "file": "src/main/ai-vault/session-liveness.test.ts", + "file": "src/relay/pty-handler.test.ts", "assertions": [ - "local, WSL, paired-runtime, and SSH host partitions resolve without local fallback", - "renderer session identity must match the identity parsed from the validated transcript", - "hook identity is captured after the controlled process-inventory barrier", - "hook identity is captured after the controlled foreground-inspection barrier", - "missing identity, unavailable inventory, unattributed ownership, and oversized inventory remain unknown", - "foreground inspection concurrency never exceeds eight and stops scheduling at the shared deadline" - ] - }, - { - "file": "src/main/agent-hooks/server-ai-vault-liveness.test.ts", - "assertions": [ - "user dismissal retains provider identity without retaining a visible turn-status row", - "restart hydration preserves dismissed identity as unconfirmed liveness-only authority" + "the SSH relay owner strips startup identity and releases provider-delivered input through prompt readiness", + "renderer-owned SSH delivery receives the existing ready marker after authoritative relay prompt readiness", + "normal renderer-owned ready markers pass through unchanged and retain no readiness state" ] } ], "evidenceRuns": [ { - "date": "2026-08-07", + "date": "2026-08-12", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts", "result": "passed", - "durationSeconds": 1, - "summary": "Twenty deterministic deletion, identity-binding, inventory/inspection-ordering, dismissal/restart-retention, unmanaged-ownership, unknown-state, filesystem-survival, host-partition, deadline, and bounded-concurrency assertions passed." + "durationSeconds": 4, + "summary": "All real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles plus identity, foreground, executable-image, protocol, and line-editor contracts passed." } ], "runtimeBudget": { - "p95Seconds": 2, - "scope": "focused main-process liveness and temporary-filesystem contracts" + "p95Seconds": 6, + "scope": "real local zsh/bash exec and silent-read integration gate" }, "flakeHistory": { "status": "unknown", - "evidence": "One local deterministic run is recorded; CI and soak history have not started." + "evidence": "Fresh deterministic local runs pass; CI and soak history have not started." }, "redGreenEvidence": { "status": "complete", - "evidence": "The byte-identical two-scenario oracle failed on v1.4.177-rc.0 (9e948fbdf4) and latest main (7a867f12aa): both returned deleted and both real transcripts were removed. On PR commit 8e2fcfd681, an exact live local hook identity absent from empty managed-PTY inventory returned deleted and removed the real transcript. Independent review then proved a session-other to session-live transition during foreground inspection returned not-live, and live-row dismissal erased the only external identity. The corrected candidate passed all 20 assertions, including restart hydration of dismissed identity; temporarily disabling only the original liveness decision made all three original deletion-safety tests red again." + "evidence": "Oracle SHA-256 b13b08006a38b2adc2acb51ae9342909514e55ab4822fac4fbb76cd49b344cc6 is byte-identical across runs. Latest main 09ec516ae5 and the candidate with its production implementation reverted both failed exactly the three zsh .zprofile/.zshenv and bash exec cases by deterministic 5-second timeout; the zle-line-init oracle also exposed unsafe baseline delivery, while all ten other safety controls passed. The candidate passed all 14 on macOS." }, "performanceBudget": { "required": true, - "evidence": "Deletion parses only the validated target through the incremental parse cache, performs one local-provider process listing, makes no SSH or paired-runtime request, caps inventory at 512 PTYs, bounds foreground inspections to eight concurrent calls under one shared three-second fail-closed deadline, and adds no polling or background work." + "evidence": "The normal ready-marker path cancels before external probing. A missing marker triggers a debounced stty read only after a line-editor enable sequence, followed by narrow PID status and executable-image inspection only when termios matches. Slow ordinary startup output triggers zero probes; rejected protocol emissions are capped at four probes per startup. There is no per-command hook or steady-state polling." }, "promotionCriteria": [ - "Collect CI soak history for the deterministic gate.", - "Collect headed and headless paired-runtime deletion-refusal evidence.", - "Collect physical Windows/WSL and Docker SSH host-partition evidence." + "Collect Linux, SSH-daemon, and paired-runtime live evidence.", + "Collect CI and soak history without unexplained flakes.", + "Keep same-shell, child-process, line-init-hook, and non-shell readline contracts green." ], "knownGaps": [ - "No live headed/headless paired-runtime journey was run.", - "No physical Windows/WSL or Docker SSH journey was run.", - "The authorization check cannot make an external provider process start atomic with filesystem trashing; it closes the reported renderer-confirmation window at the main mutation boundary." + "The fallback requires a readable POSIX PTY slave; WSL through wsl.exe, ConPTY, and native Windows retain the existing timeout backstop.", + "An exec integration that forks a replacement shell instead of retaining the identified shell PID retains the existing timeout backstop.", + "A zsh configuration that disables bracketed-paste output emits no safe post-hook protocol signal and retains the existing timeout backstop.", + "The pre-existing timeout backstop can still deliver queued input to a non-shell replacement after it expires; this fallback prevents indefinite startup hangs and is unchanged by the prompt fallback.", + "The fallback restores safe startup-command delivery but cannot recreate OSC 133 hooks or overlay restores already discarded by exec.", + "Startup code running in the launched shell can deliberately spoof the public line-editor enable sequence after configuring matching termios; the existing timeout path already trusts a public OSC marker from the same startup code." ], - "demotionRule": "Keep experimental or demote if live or unknown ownership can reach trashing, remote ownership falls back locally, process inspection becomes unbounded, or the deterministic filesystem oracle flakes without a product or harness defect." + "demotionRule": "Keep experimental or demote if prompt fallback enters a normal, child, or line-init read; a non-shell executable triggers prompt fallback; the identified shell PID is not foreground; delivery duplicates; ordinary startup output spawns probes; probe attempts exceed four; or the focused gate flakes without an identified harness defect." }, { "id": "editor.restored-sibling-owner-reparent", @@ -1458,6 +1458,319 @@ ], "demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect." }, + { + "id": "browser-session.remote-terminal-link-ownership", + "title": "Paired terminal links open one browser on their owning runtime", + "maturity": "experimental", + "protection": "partial", + "owner": "browser-runtime", + "layer": "renderer-paired-runtime-routing", + "surfaces": [ + "paired remote terminal HTTP links", + "terminal link action destinations", + "runtime-owned managed browser creation" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime", "ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime", "ssh"], + "coverageNotes": "A headed macOS desktop host and separate paired Electron client exercise a real mirrored terminal link, owner-asserted host browser creation, exact host/client page identity, rendered DOM, explicit browser-tab activation, client external-browser suppression, exact host/client close convergence, and continued original-PTY liveness. Unit contracts cover OSC, WebLinksAddon, click fallback, explicit System Browser routing, failed creation, incapable runtimes, and unchanged direct SSH behavior.", + "motivatingLinks": [ + "https://stablygroup.slack.com/archives/C0AK2T3JEF4/p1786567689897109", + "https://github.com/stablyai/orca/issues/12418" + ], + "invariant": "Selecting Orca Browser for an HTTP link in a runtime-owned terminal creates exactly one browser page on that owning runtime and exactly one mirrored client browser workspace, with matching remote page identity and no client external-browser call. Explicit System Browser remains client-owned, direct SSH remains system-browser-only, and unknown or mismatched owners fail closed.", + "oracle": "Use the focused routing contracts as the byte-identical baseline/candidate oracle. Require runtime OSC, WebLinksAddon, click fallback, action-menu, and modifier paths to target the exact owner without client fallback, while direct SSH and generic remote document links stay external. Separately run one byte-identical headed paired Electron journey on the candidate and with only runtime browser registration disabled. Require one new host browser tab, one new client mirror with exact page identity, rendered fixture DOM, explicit browser-tab activation, zero client shell.openExternal calls, exact close convergence, unchanged terminal counts, and continued output on the original PTY.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts src/renderer/src/lib/http-link-routing.test.ts src/renderer/src/lib/http-link-modifier-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts", + "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts", + "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts", + "src/renderer/src/lib/http-link-routing.test.ts", + "src/renderer/src/lib/http-link-modifier-routing.test.ts", + "src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "tests/e2e/paired-remote-terminal-browser-link.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts", + "assertions": [ + "OSC, WebLinksAddon, and click-fallback activations route through the exact runtime owner", + "direct SSH continues to use only the system browser" + ] + }, + { + "file": "src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "assertions": [ + "an asserted runtime cannot fall back to a client browser", + "mismatched and incapable runtime owners fail closed" + ] + }, + { + "file": "tests/e2e/paired-remote-terminal-browser-link.spec.ts", + "assertions": [ + "one terminal action produces one host page and one client mirror with exact identity", + "the host browser renders the fixture while client external navigation stays empty", + "an explicit browser-tab click reveals the exact mirror", + "closing the mirror returns host and client inventories to baseline", + "the original remote PTY accepts and renders fresh output after browser close" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "result": "failed", + "durationSeconds": 2.58, + "summary": "Latest main e4c278eb523 failed 6/25 owner-routing assertions: runtime terminal links called the viewing client's external browser and the managed-browser helper did not enforce an asserted runtime owner." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts src/renderer/src/lib/http-link-routing.test.ts src/renderer/src/lib/http-link-modifier-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "result": "passed", + "durationSeconds": 1.83, + "summary": "Candidate passed 84/84 across six focused routing, modifier, availability, owner assertion, failure, SSH, and system-browser contract files." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 32.9, + "summary": "Candidate created one host browser and one client mirror with matching page identity, rendered the fixture, activated on an explicit tab click, made zero client external-browser calls, converged both inventories after exact UI close, and delivered fresh output on the original remote PTY." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 60, + "summary": "The byte-identical final Electron oracle failed at its exact host/client identity barrier when the sole source delta replaced src/renderer/src/store/index.ts registration lines 110-113 with registerRuntimeHttpLinkBrowserOpener(null); no host-owned browser appeared within the deterministic 60-second bound." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "six focused renderer contract files plus one isolated headed paired-runtime journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The focused contracts are deterministic and the headed paired-runtime journey passed locally after its PTY namespace oracle was corrected; longer CI and soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The focused owner-routing oracle is red on latest main e4c278eb523 and green on the candidate. The byte-identical headed Electron oracle is green on the candidate and red when the sole source delta replaces the runtime browser opener registration with registerRuntimeHttpLinkBrowserOpener(null); candidate evidence proves exact host/client identity, rendered content, explicit activation, exact close convergence, original-PTY survival, and absence of client external navigation." + }, + "performanceBudget": { + "required": true, + "evidence": "The production change adds no timer, polling, retry, subprocess, stream opcode, RPC method, or host publication. Each link hover or activation performs bounded in-memory owner and capability lookups; an Orca Browser action issues the same single browser creation and snapshot reconciliation already used by the tab bar." + }, + "promotionCriteria": [ + "Collect Linux and physical Windows paired-server evidence.", + "Collect a live headless-serve terminal-link journey.", + "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake." + ], + "knownGaps": [ + "Live paired-server validation currently covers macOS only.", + "A live headless-serve journey and multiple simultaneous viewers are not exercised by the Electron journey.", + "Direct SSH intentionally retains its existing system-browser-only semantics.", + "Terminal-link activation does not change the existing asynchronous host/client focus policy." + ], + "demotionRule": "Demote if one runtime terminal link creates a client-owned or duplicate browser, targets a runtime other than the pane owner, invokes the client external browser for Orca Browser, or removes the source terminal." + }, + { + "id": "browser-session.remote-html-preview-ownership", + "title": "Paired HTML previews keep one host page and one stable client split", + "maturity": "experimental", + "protection": "partial", + "owner": "browser-runtime", + "layer": "renderer-paired-runtime-reconciliation", + "surfaces": [ + "paired remote HTML file preview", + "background preview creation and explicit browser activation", + "client-only split placement", + "remote browser close convergence" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "A headed macOS desktop host and separate paired web client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, and browser close. Unit contracts cover owning-runtime routing, one-shot split placement, focused and unfocused creation, manual moves, overlapping opens, failure cleanup, and teardown. Linux, Windows, and a live headless-server journey remain gaps.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", + "https://github.com/stablyai/orca/pull/13876" + ], + "invariant": "Opening one HTML preview from a paired client creates exactly one browser page on the worktree-owning runtime and exactly one mirrored client browser workspace. The preview remains in the requested client-side split without taking focus from the editor, an explicit browser click activates it authoritatively, and closing it removes only the browser identities while the source editor and terminal survive.", + "oracle": "Open a real HTML file through Explorer on a separate client paired to an isolated headed desktop host. Require one new host browser tab, one client browser workspace and unified tab, two visible split groups, an exact client-handle-to-host-page identity, rendered fixture DOM, editor focus after preview creation, host and client browser focus only after an explicit click, and browser-only close convergence with the editor and terminal still visible. Run the byte-identical Electron test on latest main, the candidate, and the candidate with the claimed fix disabled.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/main/runtime/orca-runtime-browser.test.ts", + "src/main/runtime/rpc/methods/browser.test.ts", + "src/renderer/src/lib/file-preview.test.ts", + "src/renderer/src/runtime/web-session-browser-placement.test.ts", + "src/renderer/src/runtime/web-runtime-session.test.ts", + "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "src/renderer/src/runtime/remote-server-parity.test.ts", + "tests/e2e/paired-remote-html-browser-focus.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/runtime/web-session-browser-placement.test.ts", + "assertions": [ + "pending page placements are bounded and independently reserve shared groups", + "worktree and environment teardown clear only their own placement state" + ] + }, + { + "file": "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "assertions": [ + "a host browser page stays in its exact client-owned preview group", + "a reserved empty split survives a pre-publication host snapshot", + "later client moves supersede initial preview placement" + ] + }, + { + "file": "src/renderer/src/runtime/remote-server-parity.test.ts", + "assertions": [ + "a caller-created browser gains focus when the exact predecessor tab is still visible" + ] + }, + { + "file": "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "assertions": [ + "one HTML open produces one client workspace, one client unified tab, and one host page", + "the source editor and remote frame remain visible in separate groups", + "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", + "result": "passed", + "durationSeconds": 3.48, + "summary": "Seven focused host and renderer contract files passed 183/183 tests, covering owning-runtime routing, focused and unfocused creation, placement, reconciliation, failure cleanup, and teardown." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", + "result": "passed", + "durationSeconds": 16.1, + "summary": "The candidate passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 11.6, + "summary": "Latest-main a90a18d43da333e1cc327779a3f9912a78308848 failed the byte-identical 5806b23f oracle because the HTML preview collapsed into the source editor group instead of retaining its requested split." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 6.5, + "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e passed the byte-identical 5806b23f oracle: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 10.9, + "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e with only the side-preview owning-runtime routing seam disabled failed the byte-identical 5806b23f oracle with two client workspaces." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 7.6, + "summary": "Final candidate d5767481b982b736e7cc5b4653b2a32a61280638 on merge-base 36d45af062ec4022265aabac8fc6a9d36eecedf1 passed the byte-identical 5806b23f oracle after final review and rebase: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 11.8, + "summary": "Latest-main 346e59c879 failed the byte-identical 2ec1dcf1 oracle with two client HTML browser workspaces instead of one." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 8.4, + "summary": "Candidate d05217c40d passed the byte-identical 2ec1dcf1 oracle: one host page, one client workspace, stable split, rendered DOM, editor focus after creation, explicit-click browser focus, and browser-only close." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 12.3, + "summary": "Candidate d05217c40d with only the side-preview focusOnCreate:false call disabled failed the byte-identical 2ec1dcf1 oracle because preview creation activated the browser instead of preserving editor focus." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "seven focused host/renderer contracts plus one isolated headed paired-runtime journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The candidate passed isolated local macOS Electron runs before and after the final rebase; longer CI and soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical 2ec1dcf1 Electron oracle is red on latest main 346e59c879, green on candidate d05217c40d, and red when only the candidate's side-preview focusOnCreate:false call is disabled. Latest main created two client browser workspaces; the disabled candidate focused the browser before an explicit click. Historical 5806b23f evidence separately proves the owning-runtime routing seam." + }, + "performanceBudget": { + "required": true, + "evidence": "The change adds no timer, polling, retry, subprocess, RPC method, stream opcode, receipt ledger, or host publication. Side previews install no focus listener and record no focus intent. Focused browser creation performs one bounded scan of the current worktree's local tabs. Pending client placements are capped at 128 and cleared on adoption, failure, or worktree/environment teardown." + }, + "promotionCriteria": [ + "Collect Linux and physical Windows paired-server evidence.", + "Collect a live headless-serve HTML preview journey.", + "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake." + ], + "knownGaps": [ + "Live paired-server validation currently covers macOS only.", + "A live headless-serve journey and multiple simultaneous viewers are not exercised by the Electron journey.", + "Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path." + ], + "demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close." + }, { "id": "browser-stream.bounded-reconnect", "title": "Remote browser streams recover within a bounded retry budget", @@ -2066,7 +2379,7 @@ "invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.", "commands": [ - "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1" @@ -2086,11 +2399,18 @@ "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "tests/e2e/terminal-parked-close-retirement.spec.ts", "tests/e2e/agent-descendant-process-kill.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery" + ] + }, { "file": "tests/e2e/terminal-parked-close-retirement.spec.ts", "assertions": [ @@ -2530,7 +2850,7 @@ "platform": "macos", "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "result": "passed", - "durationSeconds": 0.4, + "durationSeconds": 0.34, "summary": "1 test file passed, 58 tests passed; covers cwdFallback forwarding only for local fresh spawns and metadata handoff." }, { @@ -2808,11 +3128,13 @@ "oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/main/providers/local-pty-provider.test.ts", "src/main/daemon/terminal-host.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", @@ -2824,6 +3146,13 @@ "tests/e2e/live-background-terminal-mount-authority.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "completed and explicitly retired workers retain no provider-resume authority on first workspace activation", + "the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart" + ] + }, { "file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "assertions": [ @@ -3314,6 +3643,7 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/runtime/runtime-server-directory-browser.test.ts src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" @@ -3327,11 +3657,14 @@ "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts", "src/renderer/src/components/status-bar/SshStatusSegment.test.ts", "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts", + "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts", "src/renderer/src/hooks/useIpcEvents.test.ts", "src/renderer/src/web/web-preload-api.test.ts", "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx", "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx", "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts", + "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx", + "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx", "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts", "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts", "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts", @@ -3343,6 +3676,7 @@ "src/renderer/src/lib/resolved-worktree-execution-host.test.ts", "src/renderer/src/lib/worktree-runtime-owner.test.ts", "src/renderer/src/store/selectors.test.ts", + "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts", "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts", "src/renderer/src/store/slices/repos-project-groups.test.ts", "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts", @@ -3352,6 +3686,34 @@ "tests/e2e/pr11346-selected-runtime-add.spec.ts" ], "assertionRefs": [ + { + "file": "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts", + "assertions": [ + "paired web hides the viewing computer as an Add Project filesystem authority and selects the paired runtime" + ] + }, + { + "file": "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts", + "assertions": [ + "paired runtime browsing opens the host browser without invoking native pickFolders while desktop local and SSH routes remain intact" + ] + }, + { + "file": "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx", + "assertions": ["paired runtime browsing renders the in-app host filesystem browser"] + }, + { + "file": "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx", + "assertions": [ + "host-scoped Add Project actions remain disabled until a paired runtime is selectable" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts", + "assertions": [ + "an explicitly selected paired runtime submits repo.add to that runtime without native repo or folder-picker calls" + ] + }, { "file": "src/main/ipc/settings.test.ts", "assertions": [ @@ -3753,9 +4115,7 @@ }, { "file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", - "assertions": [ - "risky-output atlas recovery is bounded, coalesced, and survives a throwing manager" - ] + "assertions": ["continues recovery when a manager throws after scheduling"] }, { "file": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", @@ -4412,14 +4772,16 @@ "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, 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. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. 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, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, 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, 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, then drive 180 timed real-PTY spinner frames through a second production `session.tabs.subscribeAll` transport and the real Zustand store. Each worktree must publish and apply only its exact initial-working and final-idle titles. Both topologies 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. Production-parity React contracts reproduce and remove the independent cold-park watcher/title feedback loop, including memo-cache discard, StrictMode cleanup replay, detached split-PTY replacement, and the zero-park fast path. A deterministic cross-boundary contract drives 24 real PTY trackers in distinct worktrees through 2,880 decorative spinner and bare-native redraw chunks and counts host publications, serialized bytes, renderer apply calls, and store mutations while preserving exact per-PTY raw chunks and per-worktree semantic transitions. Fake-clock continuation beyond the 30-minute stale boundary requires one deduplicated refresh per worktree every 15 minutes, globally spaced by at least 50 ms, with every mirrored working status still fresh. Exact-identical Pi plus rotating normalized Pi/Grok frames renew coarse working/permission state; rich prompt/tool/question fields retain their original 30-minute lease, and `stateStartedAt`/acknowledgement identity remain stable across title/hook source swaps. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. 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, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, 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, 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. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. 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. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live 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.", + "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. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. 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, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. 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. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live 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/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", "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/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", @@ -4432,11 +4794,20 @@ "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 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/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-headless --workers=1", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" ], "testFiles": [ + "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts", + "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts", + "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts", + "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts", + "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx", + "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx", + "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx", + "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/renderer/src/web/web-preload-api.test.ts", "src/main/ipc/runtime-environments.test.ts", @@ -4468,6 +4839,59 @@ "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts", + "assertions": [ + "one globally spaced FIFO refreshes each continuously active worktree before status expiry", + "sibling PTYs deduplicate to one worktree refresh while semantic transitions bypass the queue", + "sole-source PTY teardown removes queued work while sibling PTY sources remain deduplicated", + "worktree, pending-queue, and timer state is removed on teardown" + ] + }, + { + "file": "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts", + "assertions": [ + "a live pending question remains visible under the current live shell title but cannot cross a later working interval, identity-only owner title, or provider generation", + "inherited tool names, stale rows, resume-only rows, and unconfirmed restored rows cannot reopen a shell-reclaimed pane" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx", + "assertions": [ + "parked terminal and unified-label title republication settles without a maximum-depth loop", + "equivalent semantic inputs remain stable when React discards memoized identities" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx", + "assertions": [ + "StrictMode cleanup replay restores watcher ownership", + "detached split-PTY and layout replacement reconciles", + "unparked unrelated writes perform zero PTY-map reads" + ] + }, + { + "file": "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts", + "assertions": [ + "2,880 decorative spinner and bare-native real-PTY OSC chunks publish zero session-tabs snapshots or serialized bytes after semantic convergence", + "the viewer performs zero snapshot applies and zero store mutations during decorative activity", + "after 30 minutes, one globally spaced refresh per worktree keeps every mirrored working status fresh", + "exact Pi and rotating normalized Pi/Grok frames renew explicit and fallback status without changing stateStartedAt or acknowledgement semantics", + "matching title evidence renews retained OSC hook status without resetting stateStartedAt, then drops unverifiable prompt metadata after the hook row expires", + "renderer-authored rich status keeps its original lease, then renews only coarse title-confirmed state and provider identity", + "ordinary-shell title transitions replace working stateStartedAt with the completion transition time", + "decorative heartbeats remain blocked until unknown foreground ownership resolves, then resume at the bounded interval", + "tracked terminal state reset clears delayed foreground ownership markers", + "raw chunks, semantic idle transitions, and visible output remain exact" + ] + }, + { + "file": "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts", + "assertions": [ + "pending renderer, retained OSC, and live-hook questions survive the shell title that currently obscures them", + "a new working interval, provider generation, or identity owner strips the prior question" + ] + }, { "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "assertions": ["retries an existing subscriber when provider inventory becomes ready"] @@ -4522,6 +4946,7 @@ "assertions": [ "ordinary-parks paired terminals and restores authoritative host scrollback", "warm-mounted hidden paired streams schedule zero renderer output work under sustained host output", + "six hidden real PTYs publish and apply only exact working and idle title transitions across paired transport", "client recovery completes while every target terminal remains unmounted in the host renderer" ] }, @@ -4530,6 +4955,7 @@ "assertions": [ "ordinary-parks paired terminals against an isolated headless Orca host", "cold-activates only visible paired terminals against an isolated headless host", + "the headless host bounds real-PTY decorative title transport and production store application", "the headless host shares the zero-hidden-renderer-work and exact-restoration contract" ] }, @@ -4624,6 +5050,42 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 1.56, + "summary": "All 16 production-parity parking/watcher contracts passed on the combined candidate, covering the maximum-depth loop, semantic stability, StrictMode ownership, split-PTY reconciliation, and zero-park fast path." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=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": 27.6, + "summary": "The isolated headed desktop host and separate encrypted paired client drove 180 timed decorative real-PTY titles across six hidden worktrees and observed exactly 12 semantic transport envelopes and 12 production store title mutations, then reduced six managers to one, stayed within heap/cell/timer budgets, restored the bounded host tail exactly, and continued input/output while the host renderer retained zero target managers." + }, + { + "date": "2026-08-10", + "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-headless --workers=1", + "result": "passed", + "durationSeconds": 33.9, + "summary": "The isolated headless `orca serve` host passed the same six-worktree real-title transport/store bound, parking, and exact-restoration oracle; a second scenario proved cold activation mounted only the visible paired terminal." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 5.62, + "summary": "Forty-two deterministic contracts passed. The real-PTY cross-boundary contract delivered all 2,880 decorative spinner and bare-native redraw chunks with zero hot-path host publications, serialized bytes, renderer applies, or store mutations; continued past 30 minutes with one globally spaced freshness refresh per worktree every 15 minutes; kept all 24 mirrored statuses working; and preserved stable stateStartedAt. Exact-identical Pi and rotating normalized Pi/Grok frames renewed renderer-authored and retained-hook status without changing acknowledgement semantics. Rich status could not cross task completion, permission, provider-generation, or identity-owner boundaries, while neutral titles preserved same-task prompt and acknowledgement identity until authoritative shell evidence. Fresh renderer, retained OSC, and live-hook questions remained visible under the current shell title, but a later working interval, identity-only owner title, or provider generation stripped them; inherited tools and stale, resume-only, restored, or prior-interval questions stayed suppressed. After each rich source's original lease expired, title evidence kept only coarse working state and provider identity fresh while dropping unverifiable prompts. Unknown foreground ownership held publications at zero until resolution, then resumed the bounded heartbeat; tracked terminal state reset removed its delayed marker. The focused heartbeat contracts proved worktree deduplication, FIFO fairness, semantic bypass, PTY-source teardown, and timer cleanup. Causal-disable controls made ownership, teardown, retained source-switch, renderer rich-TTL, completion-transition, boundary-ordering, and live-question oracles fail; restoring the production corrections returned green." + }, { "date": "2026-08-07", "runner": "local", @@ -4788,11 +5250,11 @@ }, "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. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. 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. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output." + "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. The parked-title production-parity control throws Maximum update depth exceeded on the affected parking synchronization and settles with semantic dependencies; forced memo-cache discard, StrictMode cleanup replay, and split-PTY replacement are independently red before their corrections and green on the combined candidate. The real-PTY session-tabs oracle produces 96 full snapshots, 64,032 serialized bytes, 96 renderer applies, and 96 store mutations on unmodified main and on a one-line causal revert before reaching the alternating native-title phase; the candidate produces zero in all four dimensions while preserving all 2,880 redraw chunks and 24 exact semantic transitions. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. 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. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output." }, "performanceBudget": { "required": true, - "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. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton 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 but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active." + "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. The headed and headless production transports each admit only the initial and final semantic title snapshots per worktree across 180 timed decorative frames, and the real store performs only those 12 exact title mutations. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked watcher synchronization is keyed by bounded parking semantics rather than fresh object identities; unrelated store writes perform zero PTY-map reads when no tab is parked, while parked reconciliation remains bounded by the worktree's terminal tabs and assignments. Decorative title ticks are collapsed both on the paired client-event channel and at the authoritative real-PTY session-tabs classifier before snapshot construction, serialization, transport, recovery, or store application; semantic facts remain exact. 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. The forced hidden-worktree retention policy keeps at most four otherwise un-parkable worktrees mounted and force-parks all after 15 minutes; the separate ordinary hot-retain policy keeps at most four recently visible worktrees warm for five minutes. Legacy hosts still gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.", @@ -4809,7 +5271,7 @@ "The legacy protocol shape is deterministic unit coverage; a packaged pre-v1.4.163 host has not yet run the full headed memory oracle.", "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 decorative title frequency changes paired client-event volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." + "demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event or session-tabs publication volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." }, { "id": "terminal-performance.no-hot-list-sessions", @@ -6682,7 +7144,7 @@ }, { "id": "terminal-input.ime-and-synthetic-forwarding", - "title": "IME, native text, and synthetic input commit exactly once and do not leak preedit bytes", + "title": "IME, native text, and synthetic input commit exactly once, do not leak preedit bytes, and pair every claimed press with one release", "maturity": "experimental", "protection": "partial", "owner": "terminal-input", @@ -6699,18 +7161,19 @@ "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos", "linux"], "coveredProviders": [], - "coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, and Electron/CDP live-PTY Sogou-style repros. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", + "coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, Electron/CDP live-PTY Sogou-style repros, and a macOS renderer/main slice for Preview kitty commits: claimed press/release pairing in both keyup orderings, and snapshot flag provenance where a proven 0, a proven non-zero, and an absent field stay three distinct facts from daemon through the sequenced remote SnapshotStart frame. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6699", "https://github.com/stablyai/orca/pull/6682", "https://github.com/stablyai/orca/pull/6513", "https://github.com/stablyai/orca/pull/6999" ], - "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.", - "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY. When the snapshot source proves kitty state, macOS Preview commits must use the application's effective flags after the sequenced snapshot and every proven replay/live transition, and a claimed press delivered under report_event_types must produce exactly one matching release regardless of whether keyup arrives before or after insertText and regardless of xterm's defensive internal reset.", + "oracle": "Exact outbound bytes from the REAL native-text forwarder behind installPreviewImeBridge after initial snapshot restore, replacement resync, live transitions, and both keydown->insertText->keyup and keydown->keyup->insertText orderings; a wiring-only mock is supporting evidence, not the oracle. The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts", "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ @@ -6723,6 +7186,14 @@ "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts", + "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx", + "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts", + "src/main/ipc/terminal-preview.test.ts", + "src/main/ipc/terminal-preview-output-stream.test.ts", + "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts", "tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "assertionRefs": [ @@ -6787,6 +7258,65 @@ "macOS standalone Process key behavior and composition-owned key suppression stay intact" ] }, + { + "file": "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts", + "assertions": [ + "the real forwarder behind the Preview bridge encodes the commit from the snapshot-proven flags before any live output", + "flags 0/1 commit raw text, flags 2 add one CSI-u release, flags 8 emit one CSI-u press, and flags 10 emit a CSI-u press followed by exactly one release", + "both keydown->insertText->keyup and keydown->keyup->insertText orderings produce the same bytes", + "the release is emitted by the forwarder while xterm's internal kitty flags stay 0", + "the mutable flags getter is read exactly once, at commit time, never on keydown or keyup", + "auto-repeat, two-key rollover, a swallowed commit, a retired claim, blur, and disposal each emit at most one release and never synthesize one" + ] + }, + { + "file": "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts", + "assertions": [ + "a Preview opened on a snapshot carrying flags 8 adopts 8 before any live output", + "a replacement snapshot whose ANSI carries no kitty bytes keeps its proven flags, and a proven 0 clears an earlier 8", + "a missing field leaves the mirror unproven at the raw-text fallback and a later live negotiation still upgrades it", + "restored flags land on the screen the snapshot scan selected", + "a proven post-snapshot suffix advances the stack while redelivered bytes apply idempotently" + ] + }, + { + "file": "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx", + "assertions": [ + "the Preview hands the forwarder the live kitty mirror seeded from the snapshot's proven flags" + ] + }, + { + "file": "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts", + "assertions": [ + "a fresh PTY's known 0 is distinguishable from a snapshot's unproven 0 fallback", + "restoreSnapshotFlags proves only the active screen and rejects non-safe-integer values", + "provenance survives absolute sets, screen switches, RIS and DECSTR, but a pop past omitted push history returns to unknown rather than a manufactured 0" + ] + }, + { + "file": "src/main/ipc/terminal-preview.test.ts", + "assertions": [ + "terminalPreview:connect returns image, sequence, and kitty flags from the same capture, including after an overflow retry" + ] + }, + { + "file": "src/main/ipc/terminal-preview-output-stream.test.ts", + "assertions": [ + "fully covered chunks are dropped, a sliced or strictly newer sequenced suffix gets live mode semantics, and an unsliceable or metadata-less overlap gets replay semantics" + ] + }, + { + "file": "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts", + "assertions": [ + "SnapshotStart decodes proven 0 and 8 with their seq into requested and host-pushed snapshot paths", + "negative, fractional, unsafe, and non-numeric values are treated as absent rather than clamped", + "an old host that omits the field stays usable and is never coerced to a proven 0" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": ["daemon provider snapshots publish proven kitty flags including a known 0"] + }, { "file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts", "assertions": [ @@ -6798,6 +7328,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts", + "result": "passed", + "durationSeconds": 9.84, + "summary": "8 test file(s) passed, 288 tests, covering the Preview kitty slice: real forwarder bytes at snapshot-proven flags, press/release pairing across both keyup/insertText orderings, and flag provenance where a proven 0, a proven non-zero, and an absent field stay three distinct facts — asserted on the daemon snapshot and on a host-pushed remote snapshot that carries the flags with their sequence. The emitter-side rule that a remote SnapshotStart withholds flags without a seq has no direct assertion yet; the client-side gate is what these files pin." + }, { "date": "2026-08-09", "runner": "local", @@ -6863,7 +7402,7 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." + "evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, live-PTY Sogou-style candidate commits, and the Preview kitty slice (press/release pairing in both keyup orderings, and snapshot flag provenance separating a proven 0 from an absent field). Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." }, "performanceBudget": { "required": true, @@ -6876,7 +7415,9 @@ ], "knownGaps": [ "Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.", - "Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage." + "Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage.", + "An older remote host omits the optional SnapshotStart kitty field, so a new client falls back to raw commits until live negotiation is observed.", + "Snapshot authorities expose only the active screen's effective flags, not kitty's complete two-screen push stacks; a pop that crosses the restored boundary degrades to unknown rather than a proven value." ], "demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence." }, @@ -7219,14 +7760,16 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon"], - "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.", + "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, verified managed-Claude launch identity, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal when no verified launch identity exists.", "motivatingLinks": ["https://github.com/stablyai/orca/issues/8303"], - "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", - "oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", + "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; a generic busy title requires independent agent identity from the title, a verified managed launch bound to the current PTY incarnation, or the foreground process; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", + "oracle": "A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" ], "testFiles": [ + "src/main/runtime/quarter-circle-title-send-authorization.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/rpc/terminal-send.test.ts", "src/main/ipc/pty.test.ts", @@ -7239,6 +7782,14 @@ "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" ], "assertionRefs": [ + { + "file": "src/main/runtime/quarter-circle-title-send-authorization.test.ts", + "assertions": [ + "a verified managed-Claude launch authorizes a task-text quarter-circle title without foreground-process work", + "a bare pane, an unverified launch hint, and a replacement PTY incarnation remain refused", + "quarter circles remain working signals and braille authorization is unchanged" + ] + }, { "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ @@ -7306,6 +7857,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", + "result": "passed", + "durationSeconds": 20.9, + "summary": "The focused file passed 9 tests, including current-incarnation managed-Claude authorization with zero foreground calls and fail-closed bare, unverified, and replacement-incarnation quarter-circle titles." + }, { "date": "2026-07-11", "runner": "local", @@ -7326,11 +7886,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing." + "evidence": "Before the verified-launch fallback, the managed-Claude unavailable-foreground case failed with isRunningAgent false; the focused file passed 7 tests after the fix. Removing the launch-incarnation comparison makes the replacement-incarnation refusal fail. Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; physical Windows evidence remains missing." }, "performanceBudget": { "required": true, - "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." + "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove verified managed-Claude launch identity, permission/title blockers, and ordinary recognized-agent evidence add zero provider calls; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." }, "promotionCriteria": [ "Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.", @@ -7343,7 +7903,7 @@ "SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.", "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change." ], - "demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect." + "demotionRule": "Keep non-blocking or demote to protection none if a generic busy title authorizes without exact title, verified-launch, or foreground identity; provider confirmation becomes unconditional; exact-PTY mismatch can write bytes; unsupported providers fail open; or the focused gate flakes without an actionable product or harness defect." }, { "id": "terminal-input.agent-prompt-injection", @@ -7488,19 +8048,25 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh"], - "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"], - "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", + "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, exact legacy Task/Dispatch/report-receipt verification, JSON rejection envelopes, and capability-negotiated Run-home settlement with lost-ack replay. Three isolated macOS Electron journeys launch fake Codex workers through the real RPC path. The compiled-CLI journey spawns fresh out/cli processes for rejected and accepted worker_done plus worker-release, then independently verifies runtime and SQLite state. The presentation and restart journeys assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/11107#discussion_r3663321387", + "https://github.com/stablyai/orca/issues/13858" + ], + "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. A worker_done caller may report success only after the owning runtime returns an explicit lifecycle verdict or authoritative reads prove that the exact Task, Dispatch, and worker report receipt settled the expected outcome. A federated worker may accept only the Run-home runtime's settlement verdict. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", "oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1", - "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1" + "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1", + "pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts", @@ -7509,13 +8075,18 @@ "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/cli/handlers/orchestration.test.ts", + "src/cli/handlers/orchestration-lifecycle-rejection.test.ts", + "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts", + "src/cli/handlers/orchestration-migration.test.ts", "src/cli/handlers/orchestration-check-identity.test.ts", "src/cli/handlers/orchestration-worker-cli.test.ts", "src/main/runtime/rpc/methods/orchestration.test.ts", "src/main/runtime/rpc/methods/orchestration-federation.test.ts", + "src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts", "src/main/ssh/ssh-remote-orca-cli.test.ts", "tests/e2e/orchestration-worker-terminal-visibility.spec.ts", - "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts" + "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts", + "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts" ], "assertionRefs": [ { @@ -7558,6 +8129,27 @@ "current formatting remains unchanged" ] }, + { + "file": "src/cli/handlers/orchestration-lifecycle-rejection.test.ts", + "assertions": [ + "an explicit current worker_done verdict completes without a compatibility read", + "a worker_done response without a lifecycle verdict checks the exact authoritative Dispatch and report receipt", + "legacy succeeded and failed reports require matching terminal Task and Dispatch state", + "stopped or stale Dispatches cannot impersonate report settlement", + "an idempotent retry accepts the first authoritative report from the same worker", + "a Run-home relay rejection reaches the CLI error boundary" + ] + }, + { + "file": "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts", + "assertions": ["a rejected lifecycle verdict is emitted as a JSON ok=false envelope"] + }, + { + "file": "src/cli/handlers/orchestration-migration.test.ts", + "assertions": [ + "first and replayed legacy worker_done settlements are accepted from their authoritative runtime verdict" + ] + }, { "file": "src/cli/handlers/orchestration-check-identity.test.ts", "assertions": [ @@ -7581,6 +8173,20 @@ "a live handle cannot be retargeted by mismatched pane metadata" ] }, + { + "file": "src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts", + "assertions": [ + "a remote worker_done waits for Run-home settlement even when an older CLI omits the wait hint", + "older negotiated federation protocols reject before queueing", + "a committed settlement is replayed after an acknowledgment is lost", + "an aborted settlement wait returns operation_unknown instead of accepted success", + "an already-aborted signal registers no settlement waiter", + "a Run-home terminal verdict settles the remote attachment and revokes its capability", + "a terminal verdict cannot settle an unrelated relay item", + "a terminal verdict cannot contradict the exact queued worker report outcome", + "a rejected report replays its exact verdict without mutating durable rejection mail twice" + ] + }, { "file": "src/main/runtime/rpc/methods/orchestration-federation.test.ts", "assertions": ["federated worker placement explicitly sets activate=false"] @@ -7611,9 +8217,27 @@ "sleeping-session, resume-claim, and pending-startup state are cleared after adoption", "no second spawn, resume argv, input, signal, interruption, or duplicate tab occurs after workspace re-entry" ] + }, + { + "file": "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts", + "assertions": [ + "a compiled CLI worker_done with the wrong identity exits nonzero with ok=false", + "the exact Task and Dispatch remain dispatched after rejection", + "the correct worker identity receives an explicit completed lifecycle verdict", + "an independent runtime read observes the Dispatch completed" + ] } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 20, + "summary": "The combined candidate passed one real Electron and compiled-CLI journey: wrong worker identity exited nonzero with ok=false while Dispatch stayed dispatched, the exact worker received an explicit completed verdict, and runtime state independently reached completed." + }, { "date": "2026-08-03", "runner": "local", @@ -7659,6 +8283,24 @@ "durationSeconds": 5.27, "summary": "Five focused files passed with 216 tests, covering visible inactive local worker creation, reveal-failure warnings, stable-pane mailbox routing, live-handle precedence, and SSH fallback parity." }, + { + "date": "2026-08-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts", + "result": "passed", + "durationSeconds": 0.4, + "summary": "Three focused files passed 12 deterministic tests covering explicit current and Run-home verdicts, JSON failure output, exact report and terminal-state verification for stripped responses, stale/stopped rejection, idempotent retry, and first/replayed attested legacy settlements." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 4.58, + "summary": "Nine deterministic tests passed for protocol negotiation, Run-home completion and rejection, already-aborted waits, authoritative remote-attachment settlement bound to the exact queued worker_done outcome, and exact verdict replay after lost acknowledgments without mutating durable rejection mail twice." + }, { "date": "2026-07-28", "runner": "local", @@ -7693,11 +8335,11 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "The deterministic units and two isolated Electron journeys pass locally; CI and soak history are not yet available." + "evidence": "The deterministic units and three isolated Electron journeys pass locally; CI and soak history are not yet available." }, "redGreenEvidence": { - "status": "partial", - "evidence": "The byte-identical 5f45c270f1 service oracle is red as the sole overlay 063340a8ba on origin/main@34291f07e9, green on candidate@2748b0b29b, red with current-authority propagation actually disabled in child 08a7db37bf, and green again after revert d2a4e2e024. The focused presentation and stale-handle tests failed against the earlier pre-fix implementation. The restart journey additionally failed first on empty retained output while the original PTY/PID remained live, then passed after the scoped recovered-worker snapshot fallback. The live incident and pre-fix Electron topology showed workspace re-entry replaying provider resume against a worker whose tab binding was missing. Distinct installed A/B and CI artifacts are still needed." + "status": "complete", + "evidence": "The byte-identical compiled-CLI Electron oracle failed on latest main 5ea7df1a5b because a wrong worker identity exited zero with ok=true while Dispatch stayed dispatched, passed on combined candidate d697666ce8 with a nonzero ok=false rejection and explicit completed verdict for the exact worker, and reproduced the main failure when the claimed production files were disabled at merge-base 64aec94cb2. The earlier byte-identical service oracle was also red on main, green on candidate, red with authority propagation disabled, and green after restore. Distinct installed A/B and CI artifacts are still needed." }, "performanceBudget": { "required": true, @@ -7858,25 +8500,55 @@ "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], - "coveredProviders": ["local"], - "coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, conservative schema-v23 backfill, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. Live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/12355", "STA-905"], - "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", - "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", + "coveredProviders": ["local", "ssh"], + "coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, dead external/user-owned/transferred/stopped/abandoned reconciliation, conservative unknown provider and legacy metadata handling, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. A macOS Electron journey invokes the freshly compiled worker-release CLI after the worker process disappears, then independently checks released SQLite state and coordinator liveness. Injected inventories cover local and SSH provider routing; live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/12355", + "https://github.com/stablyai/orca/issues/13860", + "STA-905", + "STA-3932" + ], + "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Once the authoritative owning provider positively excludes the resource's exact immutable process incarnation, even an external, user-owned, or transferred dead resource must converge to released without any process close. Unknown host scope, missing incarnation metadata, or unavailable inventory must remain retained. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", + "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. For retained external, user-owned, transferred, stopped, and abandoned resources, run one fresh inventory against the exact local/WSL or SSH provider: an exact live incarnation and every unknown inventory shape stay retained, while positive absence atomically sets ownership_state and release_state to released with processAction none and zero closeTerminal calls. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", + "pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ + "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts", "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", "src/main/runtime/orchestration/worker-transcript-read.test.ts", - "src/renderer/src/lib/worker-terminal-takeover-report.test.ts" + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts", + "assertions": [ + "classifies only an exact incarnation as live on its owning provider", + "keeps missing or malformed identity and unavailable inventory unknown", + "does not inspect an unproven host scope", + "uses the local provider inventory for local and WSL scopes" + ] + }, + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record" + ] + }, { "file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "assertions": [ + "reconciles a dead external terminal without closing a process", + "reconciles a dead user-taken-over terminal without closing a process", + "reconciles a dead stopped or abandoned worker without closing a process", + "reconciles dead transferred ownership after the current owner settles", "rejects exact reuse after release intent instead of closing the new worker", "lets an explicit retain cancel a release while output capture is pending", "retains when the terminal host scope changed instead of closing", @@ -7896,9 +8568,36 @@ "coalesces overlapping reconciliation passes and closes each resource once", "keeps live terminals bounded across 50 settled workers while controls survive" ] + }, + { + "file": "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts", + "assertions": [ + "compiled worker-release reconciles a dead external retained resource to released", + "the CLI reports processAction none instead of closing a process", + "SQLite independently records released ownership and release state", + "the unrelated coordinator terminal remains live" + ] } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 20, + "summary": "The combined candidate's compiled worker-release returned released with processAction none after positive process absence; an independent SQLite read showed both states released and the coordinator terminal remained live." + }, + { + "date": "2026-08-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 4.98, + "summary": "Six focused files passed 67 tests on the rebased candidate, covering dead external, user-owned, stopped, abandoned, and transferred reconciliation; exact local/WSL/SSH provider routing; malformed, missing, and unavailable inventory retention; zero process closes; existing lease, archive, recovery, mutation, and renderer-input contracts." + }, { "date": "2026-08-03", "runner": "local", @@ -7910,20 +8609,20 @@ } ], "runtimeBudget": { - "p95Seconds": 8, - "scope": "deterministic runtime, SQLite, transcript, renderer-input, and mutation contracts" + "p95Seconds": 45, + "scope": "deterministic runtime, SQLite, transcript, renderer-input, mutation contracts, and one compiled-CLI Electron journey" }, "flakeHistory": { "status": "unknown", - "evidence": "First local deterministic run only; focused CI and soak history are not yet available." + "evidence": "Repeated local deterministic runs pass; focused CI and soak history are not yet available." }, "redGreenEvidence": { "status": "complete", - "evidence": "The byte-identical 56-test oracle produced 12 deterministic failures and 44 passes with production restored to exact PR head 4aa66b62e3, including ready reuse during release, unsafe migration, stale archives, mutable transcript output, host/identity close gaps, and mutation replay loss. Candidate 457bc4edd1 passed all 56; restoring the same five production files to 4aa66b62e3 reproduced the same red state." + "evidence": "The byte-identical compiled-CLI Electron oracle left the dead resource external/retained on latest main 5ea7df1a5b, passed on combined candidate d697666ce8 with released/released SQLite state and processAction none, and reproduced external/retained after disabling the claimed production files at merge-base 64aec94cb2. The earlier unchanged three-case dead external/user-owned/transferred service oracle likewise failed 3/3 on main, passed 3/3 on candidate, and failed 3/3 with production restored; every run asserted durable state and zero terminal close calls." }, "performanceBudget": { "required": true, - "evidence": "Release performs constant-count indexed resource and identity queries plus one bounded archive capture. Worker-list uses two set queries rather than one resource lookup per worker. Reconciliation remains serial and adds no polling, timers, subprocesses, renderer subscriptions, or provider-wide listing." + "evidence": "Normal owned release performs constant-count indexed resource and identity queries plus one bounded archive capture. A retained release performs exactly one bounded inventory against its authoritative local/WSL or specific SSH provider, with no retry, polling, timer, subprocess, renderer subscription, or per-session follow-up fanout. Worker-list uses two set queries rather than one resource lookup per worker." }, "promotionCriteria": [ "Collect 100 consecutive focused CI passes or 14 days of soak history.", @@ -7934,7 +8633,7 @@ "knownGaps": [ "Provider close has no durable cross-process operation receipt, so a crash after host mutation but before SQLite settlement remains release_pending until exact inventory returns.", "Federated release remains explicitly unsupported and retained.", - "Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised." + "Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised; the current fix changes provider inventory classification and SQLite state only, not ConPTY, filesystem, update, sleep, firewall, or window lifecycle semantics." ], "demotionRule": "Keep experimental or demote if release can overlap exact reuse, close a conflicting lease, lose immutable output, retain reset archives, fan out per worker, or any focused ordering test flakes without a product or harness defect." }, @@ -8348,6 +9047,166 @@ "knownGaps": ["No manifest command yet.", "No startup color-query contract is wired."], "demotionRule": "Cannot promote if success is based only on absence of visible artifacts." }, + { + "id": "terminal-input.cooked-reply-queue", + "title": "Cooked-echo-safe terminal reply backlog stays bounded without evicting ordinary input", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-provider-contract", + "surfaces": [ + "desktop terminal input", + "OSC 10/11 replies", + "mode-2031 replies", + "local PTY", + "daemon and SSH PTYs" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh"], + "coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves a real Linux SSH process receives xterm's OSC reply through the same queue. No live daemon, WSL, physical Linux/Windows client, or mixed-version run is registered.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/13137", + "https://github.com/stablyai/orca/issues/7329" + ], + "invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending cooked-echo-safe replies and 4096 UTF-16 code units. Overflow removes the oldest pending query-reply entries, never ordinary input or reply-shaped bytes from the ordinary path, so later user input has a finite reply backlog; every retained reply reaches the established provider path as one atomic write. Drain failures settle without an unhandled rejection and cannot clear a newer queue generation.", + "oracle": "Synchronously enqueue 10,000 cooked-echo-safe query replies before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Repeat behind 10,000 ordinary inputs and through the real IPC transport, requiring ordinary and reply-shaped ordinary-path bytes to survive while only the newest 64 explicit replies remain. Exercise the text ceiling, real xterm OSC 10/11 generation, provider-write failure, rejected yield, and clear/reuse generation fencing.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts", + "pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "src/shared/terminal-query-reply.test.ts", + "src/shared/pty-startup-ingress-live-query-reply.test.ts", + "tests/e2e/terminal-osc-color-queries.spec.ts", + "tests/e2e/terminal-typing-latency.spec.ts", + "tests/e2e/pty-input-write-queue-ssh.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts", + "assertions": [ + "a synchronous 10,000-reply flood writes only the immediate first and newest 64 pending replies before a trailing keystroke", + "10,000 ordinary queued inputs survive a later reply flood while reply retention remains bounded", + "reply-shaped bytes from the ordinary input path never enter the shed-able reply budget", + "reply count and text overflow remove only the oldest pending replies", + "real xterm OSC 10/11 query handlers remain subject to the same retention ceiling", + "retained cooked-echo-safe replies stay one provider write each", + "provider-write and yield failures settle without unhandled rejection, repeated same-generation admission, or stale-generation clearing", + "clear releases saturated reply accounting and fences in-flight validation before later input" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "assertions": [ + "sendInputImmediate applies the reply ceiling while sendInput preserves a reply-shaped ordinary payload in exact IPC write order", + "a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation" + ] + }, + { + "file": "src/shared/terminal-query-reply.test.ts", + "assertions": [ + "only complete cooked-echo-risk replies are classified and consecutive replies extract losslessly" + ] + }, + { + "file": "src/shared/pty-startup-ingress-live-query-reply.test.ts", + "assertions": [ + "repeated live query replies are delivered independently and their cooked echoes stay hidden" + ] + }, + { + "file": "tests/e2e/terminal-osc-color-queries.spec.ts", + "assertions": [ + "real Electron xterm OSC 10/11 replies reach the active local PTY as separate writes" + ] + }, + { + "file": "tests/e2e/terminal-typing-latency.spec.ts", + "assertions": [ + "real keyboard input reaches and echoes from the active local PTY within the interactive latency ceilings" + ] + }, + { + "file": "tests/e2e/pty-input-write-queue-ssh.spec.ts", + "assertions": [ + "a real Linux SSH process emits an OSC query and receives xterm's synthetic reply through the live PTY queue" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-09", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts", + "result": "passed", + "durationSeconds": 2.35, + "summary": "Four files and 138 tests passed, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing." + }, + { + "date": "2026-08-09", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 13.2, + "summary": "The real Electron/xterm OSC 10/11 query path wrote the exact foreground and background replies separately to the active local PTY." + }, + { + "date": "2026-08-09", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 10.6, + "summary": "Real keyboard events traversed xterm and the input queue to a local interactive PTY with every sampled key echoed inside the latency ceiling." + }, + { + "date": "2026-08-09", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 36.8, + "summary": "A real Linux process behind Docker OpenSSH emitted an OSC 10 query and observed xterm's synthetic RGB reply after it traversed the live renderer queue, Electron IPC, SSH provider, and relay PTY." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused unit, local Electron, and Docker SSH contracts" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Ten consecutive local unit-gate runs passed 1,380 tests in 29.57 seconds total with no fixed timing sleeps. The exact Docker SSH OSC oracle passed three consecutive runs before extraction into its dedicated spec and then passed the registered dedicated command once. CI and the normal soak window are not yet available." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Without the branch's admission cap, the 10,000-reply fixture writes all replies before the trailing keystroke. Before the source-routing repair, the queue had no API capable of distinguishing reply-shaped ordinary input; before failure containment, a thrown provider write rejected waitForDrain and Vitest recorded an unhandled rejection; the first containment pass retried a failed generation and invoked recovery twice; before generation fencing, a rejected stale yield cleared fresh input. No saved intentional-break artifact is attached yet." + }, + "performanceBudget": { + "required": true, + "evidence": "Pending cooked replies are capped at 64 and 4096 UTF-16 code units. Explicit source routing and separate sequence-merged FIFOs keep ordinary input out of the fixed-size reply store; oldest-reply eviction uses an amortized O(1) head advance without scanning ordinary input, consumed prefixes compact at a fixed threshold, and drain coalescing reads the stored classification without repeated reply-regex scans. A synchronous flood forwards only the immediate first and newest 64 replies before the trailing key. Ten complete gate runs, including two 10,000-entry flood shapes each, finished in 29.57 seconds with zero failures. The change adds no polling, timers, listeners, subprocesses, or network retries." + }, + "promotionCriteria": [ + "Run the gate on Windows and Linux CI.", + "Exercise a live daemon PTY with cooked echo enabled and record exact shell-visible and provider-visible bytes.", + "Attach an intentional-break run proving reply overflow cannot evict ordinary input or exceed either ceiling.", + "Accumulate the normal soak window with zero unexplained flakes." + ], + "knownGaps": [ + "No live daemon, WSL, physical Linux/Windows client, mixed-version, or network-fault run is registered.", + "The remote-runtime transport has a separate immediate-input queue and is unaffected by this gate.", + "Ordinary-input queue growth predates this change and is outside this reply-only gate.", + "Reply overload intentionally sheds the oldest synthetic replies; live application behavior under a pathological query flood is not measured." + ], + "demotionRule": "Keep experimental or revert if ordinary input is evicted, pending replies exceed either ceiling, retained replies are coalesced, a trailing key sits behind more than the bounded reply window, or the gate develops unexplained flakes." + }, { "id": "terminal-query.mobile-view-authority", "title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects", @@ -8818,7 +9677,7 @@ }, { "id": "terminal-render.atlas-recovery-fanout", - "title": "Terminal atlas recovery stays bounded to visible renderers", + "title": "Ordinary terminal output avoids shared atlas recovery fanout", "maturity": "experimental", "protection": "partial", "owner": "terminal-rendering", @@ -8827,31 +9686,33 @@ "WebGL rendering", "document visibility", "hidden terminal output", + "alternate-screen redraws", "paired terminal traffic" ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local"], - "coverageNotes": "A deterministic renderer contract models one visible manager plus 64 mounted hidden managers, hidden synchronized/TUI output, and five minutes of sustained streaming recovery requests. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels. Production v1.4.163 evidence linked the same 49-manager fanout to paired traffic, but the candidate has not been rerun against an isolated live paired server.", + "coverageNotes": "Deterministic renderer contracts require foreground renderer-risk, synchronized, hidden, and alternate-screen PTY output to stay on pane-local repaint paths without a global atlas reset. Separate contracts preserve explicit paste, reveal, visibility-resume, system-resume, and confirmed-desync recovery. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels; the candidate has not been rerun against an isolated live paired server.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7054", "https://github.com/stablyai/orca/pull/7604", "https://github.com/stablyai/orca/issues/12094" ], - "invariant": "Ordinary document visibility transitions and hidden terminal output must not clear the shared WebGL glyph atlas. Heavy reset-and-refresh recovery may touch only managers with visible terminal surfaces; hidden managers recover when revealed. Sustained streaming recovery requests must keep resets rate-bounded without starving repair. Genuine OS resume remains a heavy recovery trigger.", - "oracle": "Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require exactly one reset and one refresh; parse hidden synchronized and high-confidence TUI output and require zero global atlas-recovery schedules. Across five minutes of 300ms streaming recovery requests, require more than one but at most 75 resets, no internal or trailing repair gap above 6.5 seconds, atlas-preserving presentation while a reset is suppressed, and one delayed wipe for a final suppressed settle. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.", + "invariant": "Ordinary PTY output must never clear the shared WebGL glyph atlas, including synchronized frames, hidden output, renderer-risk glyphs, and alternate-screen entry, redraw, or exit. Affected visible panes still receive pane-local refresh after parsing. Explicit paste, reveal, visibility-resume, system-resume, context-loss, and confirmed-desync recovery remain available and may reset visible renderers.", + "oracle": "Parse foreground renderer-risk, synchronized, hidden, and alternate-screen PTY output and require pane-local refresh where needed with zero calls to shared reset-and-refresh recovery. Require the atlas-recovery module to expose only explicit lifecycle entry points, then verify paste and tab reveal still perform their recovery bursts. Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require heavy recovery to reset and refresh only the visible manager. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" ], "testFiles": [ "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts", - "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", + "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts", + "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", "tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts" ], @@ -8867,22 +9728,28 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", - "assertions": [ - "sustained streaming requests keep atlas resets rate-bounded with no repair starvation", - "suppressed resets present live buffers, cancel repair during resumed output, receive a trailing repair after settle, and clean up pending timers" - ] + "file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", + "assertions": ["limits atlas recovery to explicit renderer lifecycle events"] }, { "file": "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", "assertions": ["bounds atlas recovery to visible managers"] }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts", + "assertions": ["persists and recovers after the same cells stay missing twice"] + }, + { + "file": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", + "assertions": ["recovers a context-lost pane on the next rendering resume"] + }, { "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", "assertions": [ - "defers hidden synchronized-output atlas recovery until reveal", - "defers hidden high-confidence TUI redraw recovery until reveal", - "advances hidden rewrite state without scheduling atlas recovery" + "keeps hidden synchronized output off the global atlas recovery path", + "refreshes renderer-risk output without clearing the shared glyph atlas", + "refreshes alternate-screen redraws without clearing the shared glyph atlas", + "avoids atlas clears when one chunk enters and exits alternate screen" ] }, { @@ -8894,22 +9761,13 @@ ], "evidenceRuns": [ { - "date": "2026-08-04", + "date": "2026-08-10", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", "result": "passed", - "durationSeconds": 0.5, - "summary": "Seven deterministic tests passed for sustained reset cadence, bounded repair gaps, suppressed presentation with trailing repair, clock rollback, one-shot bypasses, and pending-timer cleanup." - }, - { - "date": "2026-08-01", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", - "result": "passed", - "durationSeconds": 26, - "summary": "All 577 focused renderer contracts passed, including atlas-preserving visibility, visible-only recovery fanout, and hidden-output rewrite-state coverage." + "durationSeconds": 47, + "summary": "All 625 focused renderer contracts passed on the final head for atlas-preserving visibility, visible-only heavy recovery fanout, confirmed-desync and context-loss recovery, explicit lifecycle recovery, pane-local foreground redraws, and zero ordinary-output atlas resets." }, { "date": "2026-08-01", @@ -8940,15 +9798,15 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "With the fix disabled, the byte-identical unit oracle observed clearGlyphAtlases=true, reset/refreshed all 65 managers, and scheduled hidden synchronized/TUI recovery one to three times. Restoring the fix made every assertion pass." + "evidence": "Before the change, foreground renderer-risk and alternate-screen PTY writes requested terminal-output atlas recovery after parsing; hidden synchronized/TUI output also drove the same path. The updated oracle spies on that request before its removed 200ms debounce and on the eventual shared reset, while explicit lifecycle recovery remains required." }, "performanceBudget": { "required": true, - "evidence": "A recovery with one visible and 64 hidden managers performs one reset and one refresh instead of 65 of each. Hidden synchronized/TUI output schedules zero global recovery work. Sustained 300ms recovery requests keep full atlas resets at or below 75 over five minutes while preserving a 6.5-second maximum repair gap. A final suppressed settle schedules one cancelable repair at the next budget boundary; resumed streaming cancels it. The rate budget adds no polling, recurring timer, provider call, or parking change." + "evidence": "A 120-frame foreground redraw at 300ms cadence produced 13 terminal-output atlas reset cycles before the change and zero afterward over 37.470 seconds. Ordinary PTY output now adds no atlas debounce, retry timer, buffer-switch listener, global manager traversal, provider call, or recurring work; pane-local refresh remains for renderer-risk output. Explicit heavy recovery still touches visible managers only." }, "promotionCriteria": [ "Accumulate stable macOS Electron runs with real BrowserWindow visibility transitions.", - "Run an isolated headed paired-server terminal flood and verify bounded renderer CPU and atlas diagnostics.", + "Run an isolated headed paired-server terminal flood and verify renderer CPU and atlas diagnostics remain flat under ordinary output.", "Add Linux and Windows WebGL visibility evidence before claiming cross-platform visual coverage." ], "knownGaps": [ @@ -8956,7 +9814,7 @@ "No isolated live paired-server candidate run is recorded; remote-runtime coverage is a provider-agnostic renderer contract plus production incident evidence.", "The gate counts recovery fanout and pixel retention but does not impose an end-to-end renderer frame-latency threshold." ], - "demotionRule": "Demote if hidden managers re-enter reset/refresh recovery, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic." + "demotionRule": "Demote if ordinary PTY output re-enters shared reset/refresh recovery, pane-local redraw coverage regresses, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic." }, { "id": "terminal-render.atlas-identity-invalidation", diff --git a/config/scripts/advertised-url-watcher-benchmark.mjs b/config/scripts/advertised-url-watcher-benchmark.mjs new file mode 100644 index 00000000000..cae7195d024 --- /dev/null +++ b/config/scripts/advertised-url-watcher-benchmark.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +// Measures the no-URL PTY path that every terminal chunk enters. +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import nodeModule from 'node:module' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +if (!process.execArgv.includes('--experimental-transform-types')) { + const result = spawnSync( + process.execPath, + ['--experimental-transform-types', '--no-warnings', import.meta.filename], + { stdio: 'inherit' } + ) + process.exit(result.status ?? 1) +} + +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) { + const candidate = new URL(`${specifier}.ts`, context.parentURL) + if (existsSync(fileURLToPath(candidate))) { + return { url: candidate.href, shortCircuit: true } + } + } + return nextResolve(specifier, context) + } +}) + +const source = readFileSync( + new URL('../../src/main/ports/advertised-url-watcher.ts', import.meta.url), + 'utf8' +) +for (const marker of [ + "return mayContainHttpUrl(finalized) ? stripTerminalControls(finalized) : ''", + 'if (chunk.length >= PER_PTY_BUFFER_LIMIT)', + 'this.raw.length + chunk.length > PER_PTY_BUFFER_LIMIT' +]) { + if (!source.includes(marker)) { + throw new Error(`advertised-url-watcher.ts no longer contains \`${marker}\``) + } +} + +const { AdvertisedUrlWatcher, extractUrlCandidates, stripTerminalControls } = await import( + new URL('../../src/main/ports/advertised-url-watcher.ts', import.meta.url).href +) + +const BUFFER_LIMIT = 4096 +const ITERATIONS = Number(process.env.ORCA_ADVERTISED_URL_BENCH_ITERATIONS ?? '10000') +const ROUNDS = Number(process.env.ORCA_ADVERTISED_URL_BENCH_ROUNDS ?? '12') +const WARMUP = Number(process.env.ORCA_ADVERTISED_URL_BENCH_WARMUP ?? '1000') + +for (const [name, value] of [ + ['ORCA_ADVERTISED_URL_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_ADVERTISED_URL_BENCH_ROUNDS', ROUNDS], + ['ORCA_ADVERTISED_URL_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} +if (ROUNDS % 2 !== 0) { + throw new Error('ORCA_ADVERTISED_URL_BENCH_ROUNDS must be even') +} + +class BeforePtyBuffer { + raw = '' + + ingest(chunk) { + const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r') + this.raw += chunk + if (this.raw.length > BUFFER_LIMIT) { + this.raw = this.raw.slice(-BUFFER_LIMIT) + } + if (!chunkHasLineBreak) { + return '' + } + const lastNewline = lastLineBreak(this.raw) + if (lastNewline === -1) { + return '' + } + const finalized = this.raw.slice(0, lastNewline + 1) + this.raw = this.raw.slice(lastNewline + 1) + return stripTerminalControls(finalized) + } +} + +class BeforeWatcher { + buffers = new Map() + ptyToWorktree = new Map() + + bindPty(ptyId, worktreeId) { + this.ptyToWorktree.set(ptyId, worktreeId) + } + + ingest(ptyId, chunk) { + if (!this.ptyToWorktree.has(ptyId)) { + return 0 + } + let buffer = this.buffers.get(ptyId) + if (!buffer) { + buffer = new BeforePtyBuffer() + this.buffers.set(ptyId, buffer) + } + const finalized = buffer.ingest(chunk) + return finalized ? extractUrlCandidates(finalized).length : 0 + } +} + +function lastLineBreak(text) { + for (let index = text.length - 1; index >= 0; index -= 1) { + const code = text.charCodeAt(index) + if (code === 0x0a || code === 0x0d) { + return index + } + } + return -1 +} + +function repeatToLine(text, length, sample) { + const unit = `${text}${sample}\n` + return `${unit.repeat(Math.ceil(length / unit.length)).slice(0, length - 1)}\n` +} + +function repeatWithoutLineBreak(text, length, sample) { + const unit = `${text}${sample}` + return unit.repeat(Math.ceil(length / unit.length)).slice(0, length) +} + +const fixtures = [ + { + label: 'plain 32B line', + chunks: Array.from({ length: 32 }, (_, index) => `compiled module ${index} in 183ms\n`) + }, + { + label: 'ANSI 32B line', + chunks: Array.from({ length: 32 }, (_, index) => `\x1b[2K\x1b[1GThinking ${index}...\r\n`) + }, + { + label: 'guard fallthrough line', + chunks: Array.from( + { length: 32 }, + (_, index) => `path/to/http_server: compiled module ${index}\n` + ) + }, + { + label: 'plain 4KiB line', + chunks: Array.from({ length: 32 }, (_, index) => + repeatToLine('INFO build completed module=renderer duration=12ms ', BUFFER_LIMIT, index) + ) + }, + { + label: 'ANSI 4KiB line', + chunks: Array.from({ length: 32 }, (_, index) => + repeatToLine( + '\x1b[2K\x1b[1Gthinking about compiler output and writing patches ', + BUFFER_LIMIT, + index + ) + ) + }, + { + label: 'guard fallthrough 4KiB', + chunks: Array.from({ length: 32 }, (_, index) => + repeatToLine( + 'path/to/http_server: compiled without an advertised endpoint ', + BUFFER_LIMIT, + index + ) + ) + }, + { + label: '64KiB partial chunk', + chunks: Array.from({ length: 32 }, (_, index) => + repeatWithoutLineBreak('compiler output without a line break ', 64 * 1024, index) + ) + }, + { + label: '256KiB partial chunk', + chunks: Array.from({ length: 32 }, (_, index) => + repeatWithoutLineBreak('compiler output without a line break ', 256 * 1024, index) + ) + } +] + +let checksum = 0 + +function runBefore(chunks, iterations) { + const watcher = new BeforeWatcher() + watcher.bindPty('pty', 'repo::/bench') + let found = 0 + for (let index = 0; index < iterations; index += 1) { + found += watcher.ingest('pty', chunks[index % chunks.length]) + } + checksum = Math.imul(checksum ^ found ^ watcher.buffers.get('pty').raw.length, 16777619) >>> 0 +} + +function runAfter(chunks, iterations) { + const watcher = new AdvertisedUrlWatcher() + watcher.bindPty('pty', 'repo::/bench') + for (let index = 0; index < iterations; index += 1) { + watcher.ingest('pty', chunks[index % chunks.length], index) + } + const buffer = watcher.buffers.get('pty') + checksum = Math.imul(checksum ^ (buffer?.raw.length ?? 0), 16777619) >>> 0 +} + +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] +} + +function measure(fixture) { + runBefore(fixture.chunks, WARMUP) + runAfter(fixture.chunks, WARMUP) + const before = [] + const after = [] + for (let round = 0; round < ROUNDS; round += 1) { + const measureBefore = () => { + const startedAt = performance.now() + runBefore(fixture.chunks, ITERATIONS) + before.push(((performance.now() - startedAt) * 1000) / ITERATIONS) + } + const measureAfter = () => { + const startedAt = performance.now() + runAfter(fixture.chunks, ITERATIONS) + after.push(((performance.now() - startedAt) * 1000) / ITERATIONS) + } + if (round % 2 === 0) { + measureBefore() + measureAfter() + } else { + measureAfter() + measureBefore() + } + } + return { before: median(before), after: median(after) } +} + +console.log('Advertised URL watcher no-match cost in microseconds/chunk. Lower is better.') +console.log(`iterations=${ITERATIONS}, rounds=${ROUNDS}, warmup=${WARMUP}`) +console.log('fixture before after speedup') +for (const fixture of fixtures) { + const result = measure(fixture) + console.log( + `${fixture.label.padEnd(26)} ${result.before.toFixed(3).padStart(8)} ${result.after + .toFixed(3) + .padStart(10)} ${(result.before / result.after).toFixed(2).padStart(9)}x` + ) +} +console.log(`checksum=${checksum}`) +console.log( + 'Limitations: synthetic in-process scan; explicit timestamps omit the production clock read and surrounding PTY dispatch.' +) diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index fd750a9150d..e25a7bc222e 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -7,6 +7,9 @@ import process from 'node:process' import ts from 'typescript-api' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) +// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-fixtures.ts` here, not under `__tests__/`. +const TEST_SUPPORT_FILE_PATTERN = + /[.-](?:test-harness|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/ const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate']) const USER_VISIBLE_JSX_ATTRIBUTES = new Set([ @@ -73,13 +76,14 @@ function normalizePath(root, filePath) { return path.relative(root, filePath).split(path.sep).join('/') } -function isSkippedFile(root, filePath) { +export function isSkippedFile(root, filePath) { const relative = normalizePath(root, filePath) if ( relative.endsWith('.d.ts') || relative.includes('.test.') || relative.includes('.spec.') || - relative.includes('/__tests__/') + relative.includes('/__tests__/') || + TEST_SUPPORT_FILE_PATTERN.test(relative) ) { return true } diff --git a/config/scripts/audit-localization-coverage.test.mjs b/config/scripts/audit-localization-coverage.test.mjs index 291e6e29595..477054f2e8f 100644 --- a/config/scripts/audit-localization-coverage.test.mjs +++ b/config/scripts/audit-localization-coverage.test.mjs @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { collectLocalizationCandidates } from './audit-localization-coverage.mjs' +import { collectLocalizationCandidates, isSkippedFile } from './audit-localization-coverage.mjs' const ROOT = process.cwd() @@ -42,3 +42,23 @@ describe('localization coverage candidates', () => { expect(reports).toEqual([]) }) }) + +describe('localization coverage file skipping', () => { + function skipped(relativePath) { + return isSkippedFile(ROOT, `${ROOT}/${relativePath}`) + } + + it('skips test-only modules that sit beside their spec', () => { + expect(skipped('src/renderer/src/components/browser-pane/stream-test-harness.ts')).toBe(true) + expect(skipped('src/renderer/src/hooks/ipc-events-test-fixtures.ts')).toBe(true) + expect(skipped('src/renderer/src/lib/session-test-state.ts')).toBe(true) + expect(skipped('src/renderer/src/store/slices/routing-fixture.ts')).toBe(true) + expect(skipped('src/renderer/src/components/tab-bar/icon-stub.fixture.tsx')).toBe(true) + }) + + it('still scans shipped modules whose names merely mention a fixture concept', () => { + expect(skipped('src/renderer/src/components/browser-pane/fixture-picker.tsx')).toBe(false) + expect(skipped('src/renderer/src/components/settings/fixtures-panel.tsx')).toBe(false) + expect(skipped('src/renderer/src/components/browser-pane/BrowserPane.tsx')).toBe(false) + }) +}) diff --git a/config/scripts/build-notification-status-macos.mjs b/config/scripts/build-notification-status-macos.mjs index e8150e32b6f..41d4c84a4af 100644 --- a/config/scripts/build-notification-status-macos.mjs +++ b/config/scripts/build-notification-status-macos.mjs @@ -79,7 +79,7 @@ try { function readArg(name) { const index = args.indexOf(name) - return index >= 0 ? args[index + 1] : undefined + return index !== -1 ? args[index + 1] : undefined } function embeddedInfoPlist(identifier) { diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index a63cce53e95..23ad74ab47a 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -18,6 +18,7 @@ const __dirname = import.meta.dirname const ROOT = join(__dirname, '..', '..') const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts') +const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts') const MANAGED_HOOK_RUNTIME_ENTRY = join( ROOT, 'src', @@ -88,6 +89,21 @@ for (const platform of PLATFORMS) { } }) + await build({ + entryPoints: [AI_VAULT_SERVICE_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, 'relay-ai-vault-service.js'), + external: ['electron'], + sourcemap: false, + minify: true, + define: { + 'process.env.NODE_ENV': '"production"' + } + }) + await build({ entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY], bundle: true, @@ -110,10 +126,12 @@ for (const platform of PLATFORMS) { // so a companion-only change always deploys beside the matching relay host. const relayContent = readFileSync(join(outDir, 'relay.js')) const watcherContent = readFileSync(join(outDir, 'relay-watcher.js')) + const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js')) const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js')) const hash = createHash('sha256') .update(relayContent) .update(watcherContent) + .update(aiVaultServiceContent) .update(managedHookRuntimeContent) // Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory. if (platform.startsWith('win32-')) { diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index 6f9b86bdb4b..27c17b3d15c 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -56,5 +56,5 @@ function findFrameworkCompiler(env) { function readArg(name) { const index = process.argv.indexOf(name) - return index >= 0 ? process.argv[index + 1] : undefined + return index !== -1 ? process.argv[index + 1] : undefined } diff --git a/config/scripts/computer-use-modifier-safety.test.mjs b/config/scripts/computer-use-modifier-safety.test.mjs index 725f17fdf4c..2a3234d3d9c 100644 --- a/config/scripts/computer-use-modifier-safety.test.mjs +++ b/config/scripts/computer-use-modifier-safety.test.mjs @@ -11,7 +11,7 @@ function source(path) { function sourceBetween(contents, startMarker, endMarker) { const start = contents.indexOf(startMarker) const end = contents.indexOf(endMarker, start + startMarker.length) - if (start < 0 || end < 0) { + if (start === -1 || end === -1) { throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`) } return contents.slice(start, end) diff --git a/config/scripts/daily-build-version.mjs b/config/scripts/daily-build-version.mjs new file mode 100755 index 00000000000..1d27ef5fb34 --- /dev/null +++ b/config/scripts/daily-build-version.mjs @@ -0,0 +1,109 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' + +/** `1.4.160-daily.202607281300` — UTC to the minute, so tags sort chronologically + * by semver and every build is uniquely versioned. */ +export function createDailyBuildVersion(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('Daily 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 daily semver-NEWER than the + // RC it was cut from (1.4.160-rc.3-daily.X > 1.4.160-rc.3), which would let an + // ordinary RC-channel check offer untested daily builds to RC users. Stripping + // to the base parks dailies below both rc.N and stable ('daily' < 'rc' + // alphabetically), reachable only by an explicit pinned jump. + return `${match[1]}-daily.${stamp}` +} + +/** + * The next build number for `baseVersion`, counting from the titles of existing + * daily releases. + * + * Why the series restarts at 01 on every base version: the number answers "which + * build of 1.4.163 is this", so a counter shared across versions makes it + * meaningless — 1.4.164 would open at 38 for no reason a reader can see. + * + * Why the maximum rather than a count: the prune step trims to + * DAILY_RETAIN_COUNT, so a count would roll backwards and reissue a number + * already in use. Titles that predate this naming simply do not match, which is + * how the first build of a version lands on 01. + */ +export function nextDailyBuildNumber(baseVersion, releaseNames = []) { + const prefix = `${baseVersion} • ` + const highest = releaseNames.reduce((max, entry) => { + const name = String(entry ?? '') + if (!name.startsWith(prefix)) { + return max + } + const match = /^(\d+) • /.exec(name.slice(prefix.length)) + return match ? Math.max(max, Number(match[1])) : max + }, 0) + return highest + 1 +} + +/** + * `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title, + * shown verbatim in both the GitHub releases list and the in-app build picker. + */ +export function formatDailyReleaseName(version, buildNumber, commit, date) { + if (!Number.isInteger(buildNumber) || buildNumber < 1) { + throw new Error(`Daily build number must be a positive integer: ${buildNumber}`) + } + return [ + version.split('-')[0], + String(buildNumber).padStart(2, '0'), + formatReleaseTitleTimestamp(date), + commit.slice(0, 7) + ].join(' • ') +} + +// Why the number is derived here rather than passed in: it counts builds of the +// base version, and the base is only known once the published tags have been +// resolved just above. Computing it outside meant numbering against whatever +// version the caller guessed. +export function getDailyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? []) + const version = createDailyBuildVersion(base, now) + const buildNumber = nextDailyBuildNumber(base, releaseNames ?? []) + return { + commit, + version, + buildNumber, + name: formatDailyReleaseName(version, buildNumber, commit, now) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const identity = getDailyBuildIdentity(new Date(), { + publishedVersions: readPublishedVersionsFromEnv(), + // Titles are newline separated and contain spaces, so this cannot reuse the + // whitespace split the version list gets. + releaseNames: (process.env.ORCA_DAILY_RELEASE_NAMES ?? '').split('\n').filter(Boolean) + }) + // Consumed by the workflow via $GITHUB_OUTPUT. + process.stdout.write( + `version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n` + ) +} diff --git a/config/scripts/daily-build-version.test.mjs b/config/scripts/daily-build-version.test.mjs new file mode 100644 index 00000000000..7f0e71b92c9 --- /dev/null +++ b/config/scripts/daily-build-version.test.mjs @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + createDailyBuildVersion, + formatDailyReleaseName, + nextDailyBuildNumber +} from './daily-build-version.mjs' +import { compareAppVersions } from '../../src/shared/app-version' + +describe('createDailyBuildVersion', () => { + it('stamps the version with a zero-padded UTC timestamp', () => { + expect(createDailyBuildVersion('1.4.160', new Date('2026-07-28T13:00:00Z'))).toBe( + '1.4.160-daily.202607281300' + ) + }) + + // Why: main's package.json carries the in-flight RC tail. Keeping it would make + // every daily semver-NEWER than the RC it was cut from (1.4.160-rc.3-daily.X > + // 1.4.160-rc.3), so an ordinary RC-channel check would offer untested daily + // builds to RC users. Dropping it parks dailies below both rc.N and stable, + // reachable only by an explicit pinned jump. + it('drops an in-flight rc tail so dailies never outrank the rc series', () => { + const version = createDailyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T13:00:00Z')) + expect(version).toBe('1.4.160-daily.202607281300') + expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0) + expect(compareAppVersions('1.4.160-rc.3-daily.202607281300', '1.4.160-rc.3')).toBeGreaterThan(0) + }) + + // Why: 'daily' sorts before 'hourly' alphabetically, so a daily of the same + // base never outranks an hourly — both stay below rc/stable and only the + // channel picker offers them. + it('sorts below the hourly build of the same base version', () => { + expect( + compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400') + ).toBeLessThan(0) + }) + + it('rejects invalid input', () => { + expect(() => createDailyBuildVersion('nope', new Date())).toThrow(/valid semver/) + expect(() => createDailyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/) + }) +}) + +describe('formatDailyReleaseName', () => { + const name = (iso, buildNumber = 1, commit = 'e698241abcde') => + formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso)) + + it('renders version, number, Pacific timestamp, and short sha', () => { + // 13:15 UTC is 6:15AM PDT in July (the daily cut time). + expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241') + }) + + // Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so + // the offset between them is not a constant. A test pinned to one season would + // pass all summer and start failing in November. + it('follows the Pacific offset across DST', () => { + expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241') + expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241') + }) + + it('pads to two digits and grows past them', () => { + expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ') + expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ') + }) + + it('rejects a build number that is not a positive integer', () => { + expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/) + expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/) + expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/) + }) + + it('rejects an invalid timestamp', () => { + expect(() => formatDailyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow( + /invalid/ + ) + }) +}) + +describe('nextDailyBuildNumber', () => { + const titles = [ + '1.4.163 • 01 • Jul 28, 6:00AM • e698241', + '1.4.163 • 02 • Jul 29, 6:00AM • aaaaaaa', + '1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb' + ] + + it('continues the series for the version being built', () => { + expect(nextDailyBuildNumber('1.4.163', titles)).toBe(10) + }) + + it('restarts at 1 when the base version moves', () => { + expect(nextDailyBuildNumber('1.4.164', titles)).toBe(1) + expect( + nextDailyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 6:00AM • ccccccc']) + ).toBe(2) + }) + + // Why max and not count: pruning trims to DAILY_RETAIN_COUNT, so counting + // would roll backwards and reissue a number already used. + it('takes the highest number, not the count', () => { + expect(nextDailyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 6:00AM • e698241'])).toBe(10) + }) + + it('starts at 1 with no history at all', () => { + expect(nextDailyBuildNumber('1.4.163')).toBe(1) + expect(nextDailyBuildNumber('1.4.163', [])).toBe(1) + }) + + it('ignores titles that are not this version', () => { + expect(nextDailyBuildNumber('1.4.16', ['1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'])).toBe(1) + expect(nextDailyBuildNumber('1.4.163', ['v1.4.163-daily.202607311300', null, ''])).toBe(1) + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3227d957b24..edb6d6ed5db 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -4,6 +4,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +const REPO_ROOT = join(import.meta.dirname, '..', '..') +const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main') + const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') const { FileMatcher } = require('app-builder-lib/out/fileMatcher') @@ -19,42 +22,6 @@ const { verifyPackagedMainRuntimeDeps } = require('../packaged-runtime-node-modules.cjs') -const MUTABLE_BUILD_ENV = [ - 'ORCA_MAC_HOURLY', - 'ORCA_MAC_ADHOC', - 'ORCA_MAC_RELEASE', - 'ORCA_HOURLY_BUILD_VERSION', - 'ORCA_ADHOC_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) -const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert) - describe('electron-builder config', () => { it('keeps the packaged app identity aligned with local-build validation', () => { expect(electronBuilderConfig.appId).toBe( @@ -207,6 +174,29 @@ describe('electron-builder config', () => { ) }) + // Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is + // invisible to it and a packed worker entry fails closed — dropping every + // OpenCode session in packaged builds while dev stays green. Three legs must + // agree on the filename, so all three are read rather than hardcoded. + it('unpacks the OpenCode SQLite worker entry the scanner service forks', async () => { + const spawnSource = await readFile( + join(SRC_MAIN_DIR, 'ai-vault', 'session-scanner-opencode-sqlite-worker-spawn.ts'), + 'utf8' + ) + const entryFilename = spawnSource.match(/WORKER_ENTRY_FILENAME = '([^']+)'/)?.[1] + + expect(entryFilename).toBeDefined() + expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`) + + // Why: the emitted path comes from the rollup input key under + // entryFileNames '[name].js', not from the source filename — renaming the + // key alone would leave the other two legs agreeing on a file that no + // longer exists. + const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8') + expect(viteConfig).toContain("entryFileNames: '[name].js'") + expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`)) + }) + it('keeps the worker-thread hang watchdog inside app.asar', () => { expect(electronBuilderConfig.asarUnpack).not.toContain( 'out/main/main-thread-hang-watchdog-entry.js' @@ -303,89 +293,6 @@ 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 hourly must notarize despite the round trip: TCC anchors a notarized - // Developer ID app's grants on identifier + team, not on its cdhash, so they - // survive an update. An unnotarized hourly reads as a new client every build - // and loses file access under Documents/Desktop/Downloads with no re-prompt. - it('notarizes hourly builds like releases, and neither locally', () => { - withHourlyEnv((config) => { - expect(config.mac.notarize).toBe(true) - }) - withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { - expect(config.mac.notarize).toBe(true) - }) - expect(electronBuilderConfig.mac.notarize).toBe(false) - }) - - // 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' }) - } - ) - }) - - // Why adhoc carries the identical mac identity to hourly: it installs over a - // real Orca through the same updater path, so the same signing and the same TCC - // argument apply. Only the destination repo differs. - it('builds adhoc artifacts with the release identity and its own repo', () => { - withAdhocEnv((config) => { - expect(config.appId).toBe('com.stablyai.orca') - expect(config.mac.hardenedRuntime).toBe(true) - expect(config.mac.notarize).toBe(true) - expect(config.forceCodeSigning).toBe(true) - expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' }) - }) - }) - - it('stamps adhoc packages with the adhoc version', () => { - withEnv( - { ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' }, - (config) => { - expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' }) - } - ) - }) - - // Why: the two dev channels share every packaging decision except where they - // publish, so a future edit that collapses them must not also collapse the - // repos — a branch build landing in orca-hourly would be offered to everyone - // riding main. - it('keeps the two dev channels on separate repos', () => { - withHourlyEnv((hourly) => { - withAdhocEnv((adhoc) => { - expect(hourly.publish.repo).not.toBe(adhoc.publish.repo) - }) - }) - }) - it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) diff --git a/config/scripts/electron-builder-mac-channel-config.test.mjs b/config/scripts/electron-builder-mac-channel-config.test.mjs new file mode 100644 index 00000000000..dbd5a1170a1 --- /dev/null +++ b/config/scripts/electron-builder-mac-channel-config.test.mjs @@ -0,0 +1,152 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') + +const MUTABLE_BUILD_ENV = [ + 'ORCA_MAC_HOURLY', + 'ORCA_MAC_DAILY', + 'ORCA_MAC_ADHOC', + 'ORCA_MAC_RELEASE', + 'ORCA_HOURLY_BUILD_VERSION', + 'ORCA_DAILY_BUILD_VERSION', + 'ORCA_ADHOC_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) +const withDailyEnv = (assert) => withEnv({ ORCA_MAC_DAILY: '1' }, assert) +const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert) + +describe('electron-builder mac channel 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 hourly must notarize despite the round trip: TCC anchors a notarized + // Developer ID app's grants on identifier + team, not on its cdhash, so they + // survive an update. An unnotarized hourly reads as a new client every build + // and loses file access under Documents/Desktop/Downloads with no re-prompt. + it('notarizes hourly builds like releases, and neither locally', () => { + withHourlyEnv((config) => { + expect(config.mac.notarize).toBe(true) + }) + withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { + expect(config.mac.notarize).toBe(true) + }) + expect(electronBuilderConfig.mac.notarize).toBe(false) + }) + + // 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' }) + } + ) + }) + + // Why adhoc carries the identical mac identity to hourly: it installs over a + // real Orca through the same updater path, so the same signing and the same TCC + // argument apply. Only the destination repo differs. + it('builds adhoc artifacts with the release identity and its own repo', () => { + withAdhocEnv((config) => { + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + expect(config.forceCodeSigning).toBe(true) + expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' }) + }) + }) + + it('stamps adhoc packages with the adhoc version', () => { + withEnv( + { ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' }) + } + ) + }) + + it('builds daily artifacts with the release identity and its own repo', () => { + withDailyEnv((config) => { + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + expect(config.forceCodeSigning).toBe(true) + expect(config.publish).toMatchObject({ repo: 'orca-daily', releaseType: 'prerelease' }) + }) + }) + + it('stamps daily packages with the daily version', () => { + withEnv( + { ORCA_MAC_DAILY: '1', ORCA_DAILY_BUILD_VERSION: '1.4.160-daily.202607281300' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-daily.202607281300' }) + } + ) + }) + + // Why: the dev channels share every packaging decision except where they + // publish, so a future edit that collapses them must not also collapse the + // repos — a branch or daily build landing in orca-hourly would be offered to + // everyone riding main's hourlies. + it('keeps the dev channels on separate repos', () => { + withHourlyEnv((hourly) => { + withDailyEnv((daily) => { + withAdhocEnv((adhoc) => { + expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe( + 3 + ) + }) + }) + }) + }) +}) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index f59f4a80f88..818b380fc2f 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -196,7 +196,8 @@ describe('Electron Vite output contract', () => { }) it('rejects prototype properties as build targets', () => { - expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)') + // Own-property check only: an inherited key like `constructor` must not select a build target. + expect(targetConfig).toContain('Object.hasOwn(configByTarget, target)') }) it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => { diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 98b064cd30d..44261e968a9 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -45,7 +45,7 @@ function readRuntimeArg() { } const runtimeIndex = process.argv.indexOf('--runtime') - if (runtimeIndex >= 0) { + if (runtimeIndex !== -1) { return process.argv[runtimeIndex + 1] } diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index 899cb6436df..226ea74c4fb 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -227,7 +227,7 @@ function readGitBlobs(objectShas) { let offset = 0 for (const requestedSha of uniqueShas) { const headerEnd = output.indexOf(10, offset) - if (headerEnd < 0) { + if (headerEnd === -1) { throw new Error(`Missing git cat-file header for ${requestedSha}`) } const header = output.subarray(offset, headerEnd).toString('utf8') @@ -648,8 +648,8 @@ async function main() { const argv = process.argv.slice(2) const rebuildFromTags = argv.includes('--rebuild-from-tags') const releaseIndex = argv.indexOf('--release') - const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null - if (releaseIndex >= 0 && !releaseVersion) { + const releaseVersion = releaseIndex !== -1 ? argv[releaseIndex + 1] : null + if (releaseIndex !== -1 && !releaseVersion) { throw new Error('--release requires a version argument, e.g. --release 1.4.160') } diff --git a/config/scripts/idle-cpu-process-sampling.mjs b/config/scripts/idle-cpu-process-sampling.mjs new file mode 100644 index 00000000000..81f9218e5ad --- /dev/null +++ b/config/scripts/idle-cpu-process-sampling.mjs @@ -0,0 +1,323 @@ +import { execFileSync, spawnSync } from 'node:child_process' + +function parseCpuTimeSeconds(value) { + const trimmed = String(value || '').trim() + if (!trimmed) { + return null + } + const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed] + const days = dayOrTime === null ? 0 : Number(dayOrTime) + const parts = maybeTime.split(':').map(Number) + if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) { + return null + } + if (parts.length === 3) { + return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2] + } + if (parts.length === 2) { + return days * 86400 + parts[0] * 60 + parts[1] + } + if (parts.length === 1) { + return days * 86400 + parts[0] + } + return null +} + +function parseUnixProcesses(stdout) { + const rows = [] + for (const raw of stdout.split('\n')) { + const line = raw.trim() + if (!line) { + continue + } + const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/) + if (!match) { + continue + } + rows.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + percentCpu: Number(match[3]), + rssBytes: Number(match[4]) * 1024, + cpuTimeSeconds: parseCpuTimeSeconds(match[5]), + command: match[6] + }) + } + return rows +} + +function readUnixProcesses() { + const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], { + encoding: 'utf8', + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + maxBuffer: 20 * 1024 * 1024 + }) + return parseUnixProcesses(stdout) +} + +function readWindowsProcesses() { + const script = + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress' + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }) + if (result.status !== 0) { + throw new Error(result.stderr || 'PowerShell process enumeration failed') + } + const parsed = JSON.parse(result.stdout || '[]') + const entries = Array.isArray(parsed) ? parsed : [parsed] + return entries.map((entry) => ({ + pid: Number(entry.ProcessId), + ppid: Number(entry.ParentProcessId), + percentCpu: 0, + cpuTimeSeconds: null, + rssBytes: Number(entry.WorkingSetSize) || 0, + command: String(entry.CommandLine || '') + })) +} + +export function readProcessRows() { + return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses() +} + +export function descendantsOf(rows, rootPid) { + const children = new Map() + for (const row of rows) { + const list = children.get(row.ppid) ?? [] + list.push(row) + children.set(row.ppid, list) + } + const result = [] + const stack = [rootPid] + const seen = new Set() + while (stack.length > 0) { + const pid = stack.pop() + if (seen.has(pid)) { + continue + } + seen.add(pid) + const row = rows.find((candidate) => candidate.pid === pid) + if (row) { + result.push(row) + } + for (const child of children.get(pid) ?? []) { + stack.push(child.pid) + } + } + return result +} + +export function classify(row, rootPid) { + const command = row.command.toLowerCase() + if (row.pid === rootPid) { + return 'main' + } + if (command.includes('daemon-entry')) { + return 'daemon' + } + if (command.includes('--type=gpu-process')) { + return 'gpu' + } + if (command.includes('--type=renderer')) { + return 'renderer' + } + if (command.includes('--type=utility')) { + return 'utility' + } + if (command.includes('--type=')) { + return 'electron-other' + } + if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) { + return 'agent-or-node' + } + return 'other-descendant' +} + +const DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS = 120_000 + +export async function sampleProcessTreeUntilWorkloadsComplete({ + rootPid, + requestedDurationMs, + intervalMs, + workloadPromise, + maxWorkloadOverrunMs = DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS, + readRows = readProcessRows, + now = Date.now, + wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +}) { + const samplingStartedAt = now() + const requestedDeadline = samplingStartedAt + requestedDurationMs + const hardDeadline = requestedDeadline + maxWorkloadOverrunMs + let workloadSettled = false + let workloadResult + let workloadError + let workloadSettledAt = null + void workloadPromise.then( + (result) => { + workloadResult = result + workloadSettled = true + workloadSettledAt = now() + }, + (error) => { + workloadError = error + workloadSettled = true + workloadSettledAt = now() + } + ) + const samples = [] + let previousSnapshot = null + const needsFinalWorkloadSample = () => + workloadSettledAt !== null && (previousSnapshot?.at ?? -Infinity) < workloadSettledAt + while ( + now() <= requestedDeadline || + samples.length === 0 || + !workloadSettled || + needsFinalWorkloadSample() + ) { + const sampledAt = now() + if (workloadError) { + throw workloadError + } + if ( + (!workloadSettled && sampledAt >= hardDeadline) || + (workloadSettledAt !== null && workloadSettledAt > hardDeadline) + ) { + throw new Error( + `Benchmark workload exceeded the ${maxWorkloadOverrunMs}ms sampling overrun limit` + ) + } + const processRows = descendantsOf(readRows(), rootPid) + const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) })) + if (previousSnapshot) { + const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000) + const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc])) + const processes = rawProcesses.map((row) => { + const previous = previousByPid.get(row.pid) + const canComputeDelta = + typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number' + const cpu = canComputeDelta + ? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100) + : row.percentCpu + return { ...row, cpu } + }) + samples.push({ + at: sampledAt, + elapsedMs: sampledAt - previousSnapshot.at, + totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0), + totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0), + processes + }) + } + previousSnapshot = { at: sampledAt, processes: rawProcesses } + await wait(intervalMs) + } + if (workloadError) { + throw workloadError + } + const measuredDurationMs = Math.max( + 0, + (previousSnapshot?.at ?? samplingStartedAt) - samplingStartedAt + ) + return { + samples, + workloadResult, + samplingWindow: { + requestedDurationMs, + measuredDurationMs, + maxWorkloadOverrunMs, + extendedForWorkload: workloadSettledAt !== null && workloadSettledAt > requestedDeadline, + workloadSettledElapsedMs: + workloadSettledAt === null ? null : Math.max(0, workloadSettledAt - samplingStartedAt), + workloadSettledBeforeStop: workloadSettled + } + } +} + +function mean(values) { + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length +} + +function percentile(sorted, fraction) { + if (sorted.length === 0) { + return 0 + } + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1) + return sorted[index] +} + +export function summarizeSamples(samples) { + const byKind = new Map() + for (const sample of samples) { + for (const proc of sample.processes) { + const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 } + bucket.cpuValues.push(proc.cpu) + bucket.rssValues.push(proc.rssBytes) + byKind.set(proc.kind, bucket) + } + const counts = new Map() + for (const proc of sample.processes) { + counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) + } + for (const [kind, count] of counts) { + byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count) + } + } + const summary = {} + for (const [kind, values] of byKind) { + const cpuSorted = [...values.cpuValues].sort((a, b) => a - b) + const rssSumBySample = samples.map((sample) => + sample.processes + .filter((proc) => proc.kind === kind) + .reduce((sum, proc) => sum + proc.rssBytes, 0) + ) + summary[kind] = { + meanCpuPercent: mean(values.cpuValues), + p95CpuPercent: percentile(cpuSorted, 0.95), + maxCpuPercent: Math.max(0, ...values.cpuValues), + meanRssBytes: mean(rssSumBySample), + maxProcessCount: values.maxProcessCount + } + } + summary.total = { + meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)), + p95CpuPercent: percentile( + samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b), + 0.95 + ), + meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes)) + } + return summary +} + +export function summarizeProcessInventory(samples) { + const inventory = {} + for (const sample of samples) { + const counts = new Map() + for (const proc of sample.processes) { + counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) + const entry = inventory[proc.kind] ?? { + maxProcessCount: 0, + maxCpuPercent: 0, + commandSamples: [] + } + entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu) + if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) { + entry.commandSamples.push(proc.command) + } + inventory[proc.kind] = entry + } + for (const [kind, count] of counts) { + inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count) + } + } + return inventory +} + +export function terminateProcesses(processes) { + for (const proc of processes) { + try { + process.kill(proc.pid) + } catch {} + } +} diff --git a/config/scripts/idle-cpu-process-sampling.test.mjs b/config/scripts/idle-cpu-process-sampling.test.mjs new file mode 100644 index 00000000000..d452c6e3ce0 --- /dev/null +++ b/config/scripts/idle-cpu-process-sampling.test.mjs @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { sampleProcessTreeUntilWorkloadsComplete } from './idle-cpu-process-sampling.mjs' + +function createClock(workloadCompletesAt) { + let currentMs = 0 + let resolveWorkload + const workloadPromise = new Promise((resolve) => { + resolveWorkload = resolve + }) + const wait = async (durationMs) => { + currentMs += durationMs + if (currentMs >= workloadCompletesAt) { + resolveWorkload('complete') + } + await Promise.resolve() + } + const readRows = () => [ + { + pid: 10, + ppid: 0, + percentCpu: 0, + rssBytes: 1_024, + cpuTimeSeconds: currentMs / 2_000, + command: 'electron' + } + ] + return { now: () => currentMs, readRows, wait, workloadPromise } +} + +describe('idle CPU process sampling window', () => { + it('extends through a slow workload and captures a final CPU delta', async () => { + const clock = createClock(40) + const result = await sampleProcessTreeUntilWorkloadsComplete({ + rootPid: 10, + requestedDurationMs: 20, + intervalMs: 10, + maxWorkloadOverrunMs: 100, + ...clock + }) + + expect(result.workloadResult).toBe('complete') + expect(result.samples.map((sample) => sample.at)).toEqual([10, 20, 30, 40]) + expect(result.samplingWindow).toEqual({ + requestedDurationMs: 20, + measuredDurationMs: 40, + maxWorkloadOverrunMs: 100, + extendedForWorkload: true, + workloadSettledElapsedMs: 40, + workloadSettledBeforeStop: true + }) + }) + + it('invalidates a run that reaches the workload overrun guard', async () => { + const clock = createClock(Infinity) + await expect( + sampleProcessTreeUntilWorkloadsComplete({ + rootPid: 10, + requestedDurationMs: 20, + intervalMs: 10, + maxWorkloadOverrunMs: 20, + ...clock + }) + ).rejects.toThrow('exceeded the 20ms sampling overrun limit') + }) +}) diff --git a/config/scripts/idle-cpu-renderer-scale-fixture.mjs b/config/scripts/idle-cpu-renderer-scale-fixture.mjs new file mode 100644 index 00000000000..4759b768e27 --- /dev/null +++ b/config/scripts/idle-cpu-renderer-scale-fixture.mjs @@ -0,0 +1,202 @@ +export async function configureRendererScaleFixture(page, options, repoPath) { + return page.evaluate( + ({ agentsPerWorktree, lineageDepth, repoPath }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const normalizePath = (value) => + String(value ?? '') + .replaceAll('\\', '/') + .toLowerCase() + const primaryPath = normalizePath(repoPath) + const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0) + const state = store.getState() + const worktrees = Object.values(state.worktreesByRepo) + .flat() + .filter((worktree) => !worktree.isArchived) + .sort((left, right) => { + const primaryOrder = + Number(normalizePath(right.path) === primaryPath) - + Number(normalizePath(left.path) === primaryPath) + return ( + primaryOrder || + compare(normalizePath(left.path), normalizePath(right.path)) || + compare(left.id, right.id) + ) + }) + const appliedLineageDepth = Math.min(lineageDepth, Math.max(0, worktrees.length - 1)) + if (lineageDepth === 0 && agentsPerWorktree === 0) { + return { + applied: false, + requestedLineageDepth: lineageDepth, + appliedLineageDepth, + agentsPerWorktree, + seededAgentRows: 0 + } + } + + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setGroupBy('none') + state.setSortBy('recent') + state.setShowActiveOnly(false) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + + const lineageById = { ...store.getState().worktreeLineageById } + const lineageParentIds = new Set() + if (appliedLineageDepth > 0) { + for (const worktree of worktrees) { + delete lineageById[worktree.id] + if (!worktree.instanceId) { + throw new Error(`Worktree ${worktree.id} has no instanceId for lineage seeding`) + } + } + for (let index = 1; index < worktrees.length; index += 1) { + const child = worktrees[index] + const parent = worktrees[Math.min(index - 1, appliedLineageDepth - 1)] + lineageParentIds.add(parent.id) + lineageById[child.id] = { + worktreeId: child.id, + worktreeInstanceId: child.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1_700_000_000_000 + index + } + } + } + const collapsedGroups = new Set(store.getState().collapsedGroups) + for (const parentId of lineageParentIds) { + collapsedGroups.delete(`lineage:${parentId}`) + } + store.setState({ worktreeLineageById: lineageById, collapsedGroups }) + + let seededAgentRows = 0 + if (agentsPerWorktree > 0) { + store.getState().setWorktreeCardMode('Default') + store.getState().setAgentActivityDisplayMode('full') + const fixtureNow = Date.now() + worktrees.forEach((worktree, worktreeIndex) => { + const next = store.getState() + const tab = + next.tabsByWorktree[worktree.id]?.[0] ?? + next.createTab(worktree.id, undefined, undefined, { + activate: false, + id: `idle-cpu-tab-${worktreeIndex}` + }) + for (let agentIndex = 0; agentIndex < agentsPerWorktree; agentIndex += 1) { + const agentType = agentIndex % 2 === 0 ? 'codex' : 'claude' + const leafSequence = + BigInt(worktreeIndex) * BigInt(agentsPerWorktree) + BigInt(agentIndex + 1) + const leafId = `00000000-0000-4000-8000-${leafSequence.toString(16).padStart(12, '0')}` + store.getState().setAgentStatus( + `${tab.id}:${leafId}`, + { + state: 'working', + prompt: `Idle CPU agent ${worktreeIndex + 1}.${agentIndex + 1}`, + agentType + }, + agentType, + { updatedAt: fixtureNow, stateStartedAt: fixtureNow }, + { tabId: tab.id, worktreeId: worktree.id } + ) + seededAgentRows += 1 + } + }) + } + return { + applied: true, + requestedLineageDepth: lineageDepth, + appliedLineageDepth, + lineageEdges: appliedLineageDepth > 0 ? Math.max(0, worktrees.length - 1) : 0, + expandedLineageGroups: lineageParentIds.size, + agentsPerWorktree, + seededAgentRows, + orderedWorktreeIds: worktrees.map((worktree) => worktree.id) + } + }, + { agentsPerWorktree: options.agentsPerWorktree, lineageDepth: options.lineageDepth, repoPath } + ) +} + +export async function collectRendererCensus(page, configuredLineageDepth) { + return page.evaluate((configuredDepth) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('window.__store is not available') + } + const worktrees = Object.values(state.worktreesByRepo).flat() + const worktreeIds = new Set(worktrees.map((worktree) => worktree.id)) + const lineageDepthById = new Map() + const getDepth = (worktreeId, trail = new Set()) => { + if (lineageDepthById.has(worktreeId)) { + return lineageDepthById.get(worktreeId) + } + const lineage = state.worktreeLineageById[worktreeId] + if (!lineage || !worktreeIds.has(lineage.parentWorktreeId) || trail.has(worktreeId)) { + return 0 + } + const nextTrail = new Set(trail) + nextTrail.add(worktreeId) + const depth = 1 + getDepth(lineage.parentWorktreeId, nextTrail) + lineageDepthById.set(worktreeId, depth) + return depth + } + const logicalDepths = worktrees.map((worktree) => getDepth(worktree.id)) + const lineageParentIds = new Set( + Object.values(state.worktreeLineageById) + .filter((lineage) => worktreeIds.has(lineage.worktreeId)) + .map((lineage) => lineage.parentWorktreeId) + ) + const sidebar = document.querySelector('[data-worktree-sidebar]') + const mountedWorktreeIds = [ + ...new Set( + [...(sidebar?.querySelectorAll('[data-worktree-id]') ?? [])] + .map((element) => element.getAttribute('data-worktree-id')) + .filter(Boolean) + ) + ] + const mountedAgentRows = [...(sidebar?.querySelectorAll('*') ?? [])].filter( + (element) => + element.classList.contains('group/agent-row') || + element.classList.contains('compact-agent-row') + ).length + let diagnosticCensus = null + try { + diagnosticCensus = window.__orcaTypingDiagnostic?.report().census ?? null + } catch {} + const collapsedLineageGroups = [...lineageParentIds].filter((parentId) => + state.collapsedGroups.has(`lineage:${parentId}`) + ).length + return { + capturedAt: new Date().toISOString(), + worktrees: { + store: worktrees.length, + mountedCards: sidebar?.querySelectorAll('[data-worktree-card-surface]').length ?? 0, + mountedUnique: mountedWorktreeIds.length, + mountedIds: mountedWorktreeIds.slice(0, 100), + mountedIdsTruncated: Math.max(0, mountedWorktreeIds.length - 100) + }, + agentRows: { + storeLive: Object.keys(state.agentStatusByPaneKey ?? {}).length, + storeRetained: Object.keys(state.retainedAgentsByPaneKey ?? {}).length, + mounted: mountedAgentRows, + diagnosticMounted: diagnosticCensus?.agentRows.mountedDom ?? null + }, + storeListeners: diagnosticCensus?.storeListeners ?? null, + lineage: { + configuredDepth, + measuredMaxDepth: Math.max(0, ...logicalDepths), + edges: logicalDepths.filter((depth) => depth > 0).length, + groups: lineageParentIds.size, + expandedGroups: lineageParentIds.size - collapsedLineageGroups, + collapsedGroups: collapsedLineageGroups + }, + diagnostic: diagnosticCensus + } + }, configuredLineageDepth) +} diff --git a/config/scripts/idle-cpu-renderer-timing-probe.mjs b/config/scripts/idle-cpu-renderer-timing-probe.mjs new file mode 100644 index 00000000000..b010bcb3433 --- /dev/null +++ b/config/scripts/idle-cpu-renderer-timing-probe.mjs @@ -0,0 +1,191 @@ +const RENDERER_TIMER_INTERVAL_MS = 100 + +export async function startRendererTimingProbe(page) { + await page.evaluate((timerIntervalMs) => { + const maxSamples = 5_000 + const maxEntries = 80 + let phaseStartedAt = performance.now() + let phaseStartedAtIso = new Date().toISOString() + let timerId = null + let driftCount = 0 + let driftSamples = [] + let longTaskEntries = [] + let observer = null + let longTaskSupported = false + + const round = (value) => Math.round(value * 100) / 100 + const summarize = (values, totalCount = values.length) => { + const sorted = [...values].sort((left, right) => left - right) + const percentile = (fraction) => + sorted.length === 0 + ? null + : sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] + return { + count: totalCount, + retainedCount: sorted.length, + mean: + sorted.length === 0 + ? null + : round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length), + p50: sorted.length === 0 ? null : round(percentile(0.5)), + p95: sorted.length === 0 ? null : round(percentile(0.95)), + max: sorted.length === 0 ? null : round(sorted.at(-1)) + } + } + const recordLongTasks = (entries) => { + for (const entry of entries) { + longTaskEntries.push({ + startTime: entry.startTime, + duration: entry.duration, + name: entry.name + }) + if (longTaskEntries.length > maxSamples) { + longTaskEntries.shift() + } + } + } + try { + longTaskSupported = PerformanceObserver.supportedEntryTypes?.includes('longtask') === true + if (longTaskSupported) { + observer = new PerformanceObserver((list) => recordLongTasks(list.getEntries())) + observer.observe({ type: 'longtask', buffered: true }) + } + } catch { + observer = null + longTaskSupported = false + } + const scheduleTimer = () => { + const expectedAt = performance.now() + timerIntervalMs + timerId = setTimeout(() => { + driftCount += 1 + driftSamples.push(Math.max(0, performance.now() - expectedAt)) + if (driftSamples.length > maxSamples) { + driftSamples.shift() + } + scheduleTimer() + }, timerIntervalMs) + } + const snapshot = (reset) => { + if (observer) { + recordLongTasks(observer.takeRecords()) + } + const capturedAt = performance.now() + const phaseLongTasks = longTaskEntries.filter((entry) => entry.startTime >= phaseStartedAt) + const durations = phaseLongTasks.map((entry) => entry.duration) + const result = { + startedAt: phaseStartedAtIso, + capturedAt: new Date().toISOString(), + durationMs: round(capturedAt - phaseStartedAt), + timerIntervalMs, + timerDriftMs: summarize(driftSamples, driftCount), + longTasks: { + supported: longTaskSupported, + ...summarize(durations), + totalDurationMs: round(durations.reduce((sum, value) => sum + value, 0)), + entries: phaseLongTasks.slice(-maxEntries).map((entry) => ({ + startMs: round(entry.startTime - phaseStartedAt), + durationMs: round(entry.duration), + name: entry.name + })), + entriesTruncated: Math.max(0, phaseLongTasks.length - maxEntries) + } + } + if (reset) { + phaseStartedAt = capturedAt + phaseStartedAtIso = new Date().toISOString() + driftCount = 0 + driftSamples = [] + longTaskEntries = [] + } + return result + } + scheduleTimer() + window.__orcaIdleCpuTimingProbe = { + snapshot: () => snapshot(true), + stop: () => { + if (timerId !== null) { + clearTimeout(timerId) + } + const result = snapshot(false) + observer?.disconnect() + return result + } + } + }, RENDERER_TIMER_INTERVAL_MS) +} + +export async function snapshotRendererTimingProbe(page) { + return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.snapshot() ?? null) +} + +export async function stopRendererTimingProbe(page) { + return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.stop() ?? null) +} + +export async function runZustandPublications(page, count, intervalMs) { + return page.evaluate( + ({ count, intervalMs }) => + new Promise((resolve, reject) => { + const store = window.__store + if (!store) { + reject(new Error('window.__store is not available')) + return + } + const maxSamples = 5_000 + const startedAt = performance.now() + const startedAtIso = new Date().toISOString() + const schedulingDriftMs = [] + let completed = 0 + const finish = () => { + const sorted = [...schedulingDriftMs].sort((left, right) => left - right) + const percentile = (fraction) => + sorted.length === 0 + ? null + : sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] + const round = (value) => Math.round(value * 100) / 100 + resolve({ + requested: count, + completed, + intervalMs, + startedAt: startedAtIso, + completedAt: new Date().toISOString(), + durationMs: round(performance.now() - startedAt), + schedulingDriftMs: { + retainedCount: sorted.length, + p50: sorted.length === 0 ? null : round(percentile(0.5)), + p95: sorted.length === 0 ? null : round(percentile(0.95)), + max: sorted.length === 0 ? null : round(sorted.at(-1)) + } + }) + } + const publish = () => { + const publishedAt = performance.now() + const scheduledAt = startedAt + completed * intervalMs + schedulingDriftMs.push(Math.max(0, publishedAt - scheduledAt)) + if (schedulingDriftMs.length > maxSamples) { + schedulingDriftMs.shift() + } + try { + // Why: an empty partial notifies every subscriber without changing domain state. + store.setState({}) + } catch (error) { + reject(error) + return + } + completed += 1 + if (completed >= count) { + finish() + return + } + const nextAt = startedAt + completed * intervalMs + setTimeout(publish, Math.max(0, nextAt - performance.now())) + } + if (count === 0) { + finish() + } else { + setTimeout(publish, 0) + } + }), + { count, intervalMs } + ) +} diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index f75d1eae30a..314cd31710f 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -28,6 +28,8 @@ const transientDownloadErrorCodes = new Set([ 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', + // GitHub release CDN can refuse HTTP/2 streams under load; retry like socket resets. + 'ERR_HTTP2_STREAM_ERROR', 'ENETDOWN', 'ENETRESET', 'ENETUNREACH', @@ -185,7 +187,9 @@ async function downloadElectronArtifactWithRetry(downloadOptions) { function getDownloadRetryDelays() { const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS if (!configured) { - return [1_000, 3_000] + // Why: GitHub release CDN returns intermittent 503 / HTTP2 stream refusals + // under CI fan-out; a few short attempts still exhaust during outages. + return [1_000, 3_000, 5_000, 10_000] } const delays = configured.split(',').map(Number) @@ -200,7 +204,7 @@ function isTransientDownloadError(error) { if (transientDownloadErrorCodes.has(candidate?.code)) { return true } - const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode + const statusCode = getDownloadErrorStatusCode(candidate) if ( statusCode === 408 || statusCode === 425 || @@ -213,6 +217,13 @@ function isTransientDownloadError(error) { return false } +function getDownloadErrorStatusCode(error) { + // @electron/get FetchDownloader uses Fetch Response.status; older got uses statusCode. + return ( + error?.statusCode ?? error?.status ?? error?.response?.statusCode ?? error?.response?.status + ) +} + function getErrorChain(error) { const errors = [] let candidate = error @@ -225,7 +236,7 @@ function getErrorChain(error) { function formatDownloadError(error) { for (const candidate of getErrorChain(error)) { - const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode + const statusCode = getDownloadErrorStatusCode(candidate) if (statusCode) { return `HTTP ${statusCode}` } diff --git a/config/scripts/install-electron-package-binary.test.mjs b/config/scripts/install-electron-package-binary.test.mjs index 2c37f95cfbe..ad04336e8e7 100644 --- a/config/scripts/install-electron-package-binary.test.mjs +++ b/config/scripts/install-electron-package-binary.test.mjs @@ -118,6 +118,58 @@ describe('install-electron-package-binary', () => { } }) + it('retries HTTP/2 stream refusal Electron download failures', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { + downloadFailures: 1, + downloadErrorCode: 'ERR_HTTP2_STREAM_ERROR' + }) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir, { + ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0' + }) + + expect(result.status, result.stderr).toBe(0) + expect( + readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n') + ).toHaveLength(2) + expect(result.stderr).toContain( + 'Transient Electron download failure (ERR_HTTP2_STREAM_ERROR)' + ) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('retries Electron download HTTP 503 failures from Fetch Response.status', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { + downloadFailures: 1, + downloadHttpStatus: 503 + }) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir, { + ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0' + }) + + expect(result.status, result.stderr).toBe(0) + expect( + readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n') + ).toHaveLength(2) + expect(result.stderr).toContain('Transient Electron download failure (HTTP 503)') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + it('fails after exhausting transient Electron download retries', () => { const projectDir = mkTempProject() @@ -279,7 +331,12 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8')) function writeFakeElectronGet( projectDir, - { downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {} + { + downloadNeverSettles = false, + downloadFailures = 0, + downloadErrorCode = 'ECONNRESET', + downloadHttpStatus = null + } = {} ) { const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get') mkdirSync(getDir, { recursive: true }) @@ -299,6 +356,11 @@ exports.downloadArtifact = async function downloadArtifact(details) { return new Promise(() => {}) } if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) { + if (${JSON.stringify(downloadHttpStatus)} != null) { + const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)}) + error.response = { status: ${JSON.stringify(downloadHttpStatus)} } + throw error + } const cause = Object.assign(new Error('download failed'), { code: ${JSON.stringify(downloadErrorCode)} }) diff --git a/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs b/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs new file mode 100644 index 00000000000..76555d4e78e --- /dev/null +++ b/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Run: node config/scripts/legacy-worker-recovery-persistence-benchmark.mjs +import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtemp, open, readFile, rename } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) +const runtimePath = join(repoRoot, 'src/main/runtime/orca-runtime.ts') +const args = new Map( + process.argv.slice(2).map((value, index, values) => [value, values[index + 1]]) +) +const fixtureMiB = Number(args.get('--fixture-mib') ?? 24) +const trials = Number(args.get('--trials') ?? 3) +const jsonOutput = process.argv.includes('--json') + +if (!Number.isInteger(fixtureMiB) || fixtureMiB < 1 || !Number.isInteger(trials) || trials < 1) { + throw new Error('fixture-mib and trials must be positive integers') +} + +const runtimeSource = await readFile(runtimePath, 'utf8') +const recoveryStart = runtimeSource.indexOf( + 'private async persistLegacyWorkerTerminalRecoveryBatch' +) +const recoveryEnd = runtimeSource.indexOf( + 'private reconcileMissingLegacyWorkerTerminal', + recoveryStart +) +const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd) +if ( + recoveryStart === -1 || + recoveryEnd === -1 || + !recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') || + recoverySource.includes('flushOrThrow()') +) { + throw new Error('recovery persistence implementation changed; update this benchmark') +} + +const root = await mkdtemp(join(tmpdir(), 'orca-legacy-recovery-benchmark-')) +const filler = 'x'.repeat(fixtureMiB * 1024 * 1024) + +function payload(state) { + return JSON.stringify({ state, filler }) +} + +function writeDurableSync(path, body) { + const tempPath = `${path}.sync.tmp` + writeFileSync(tempPath, body) + const fd = openSync(tempPath, 'r') + try { + fsyncSync(fd) + } finally { + closeSync(fd) + } + renameSync(tempPath, path) +} + +async function writeDurableAsync(path, body) { + const tempPath = `${path}.async.tmp` + const handle = await open(tempPath, 'w') + try { + await handle.writeFile(body) + await handle.sync() + } finally { + await handle.close() + } + await rename(tempPath, path) +} + +async function measure(run) { + let maxEventLoopDelayMs = 0 + let expected = performance.now() + 1 + const timer = setInterval(() => { + const now = performance.now() + maxEventLoopDelayMs = Math.max(maxEventLoopDelayMs, now - expected) + expected = now + 1 + }, 1) + await new Promise((resolve) => setTimeout(resolve, 5)) + const startedAt = performance.now() + await run() + const durationMs = performance.now() - startedAt + await new Promise((resolve) => setTimeout(resolve, 5)) + clearInterval(timer) + return { durationMs, maxEventLoopDelayMs } +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.floor(sorted.length / 2)] +} + +async function runLegacy(path) { + const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true } + state.fenced = true + writeDurableSync(path, payload(state)) + state.surfacePresent = false + writeDurableSync(path, payload(state)) + state.recoveryRecordPresent = false + writeDurableSync(path, payload(state)) +} + +async function runBatched(path) { + const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true } + state.fenced = true + state.surfacePresent = false + state.recoveryRecordPresent = false + await writeDurableAsync(path, payload(state)) +} + +try { + const legacy = [] + const batched = [] + for (let index = 0; index < trials; index += 1) { + legacy.push(await measure(() => runLegacy(join(root, `legacy-${index}.json`)))) + batched.push(await measure(() => runBatched(join(root, `batched-${index}.json`)))) + } + const result = { + benchmark: 'legacy-worker-recovery-persistence', + fixtureMiB, + trials, + legacy: { + durableWrites: 3, + medianDurationMs: median(legacy.map((sample) => sample.durationMs)), + medianMaxEventLoopDelayMs: median(legacy.map((sample) => sample.maxEventLoopDelayMs)) + }, + batchedAsync: { + durableWrites: 1, + medianDurationMs: median(batched.map((sample) => sample.durationMs)), + medianMaxEventLoopDelayMs: median(batched.map((sample) => sample.maxEventLoopDelayMs)) + } + } + if (jsonOutput) { + console.log(JSON.stringify(result)) + } else { + console.log(JSON.stringify(result, null, 2)) + } +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/config/scripts/locale-collator-sort-benchmark.mjs b/config/scripts/locale-collator-sort-benchmark.mjs new file mode 100644 index 00000000000..8a68331bd44 --- /dev/null +++ b/config/scripts/locale-collator-sort-benchmark.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// Benchmarks optioned localeCompare calls used by renderer list comparators. +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { createJiti } from 'jiti' + +const ROUND_COUNT = 5 +const MIN_ROUND_MS = 120 +const jiti = createJiti(import.meta.url, { + alias: { '@': fileURLToPath(new URL('../../src/renderer/src', import.meta.url)) } +}) +const { compareBaseSensitivityLocaleText } = await jiti.import( + '../../src/renderer/src/lib/locale-text-collators.ts' +) +const { sortJiraIssues } = await jiti.import( + '../../src/renderer/src/components/jira-issue-sorter.ts' +) + +let randomState = 0x9e3779b9 +function random() { + randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0 + return randomState / 0x100000000 +} + +function shuffle(values) { + for (let index = values.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)) + ;[values[index], values[swapIndex]] = [values[swapIndex], values[index]] + } + return values +} + +const labels = ['alpha', 'Álpha', 'beta', 'BÉTA', 'café', 'Cafe', 'zeta', 'Ångström'] + +function makeJiraIssues(count) { + return shuffle( + Array.from({ length: count }, (_, index) => ({ + key: `TASK-${(index * 37) % (count + 17)}`, + title: labels[index % labels.length], + updatedAt: '2026-01-01T00:00:00.000Z' + })) + ) +} + +function makeBaseSensitivityValues(count) { + return shuffle( + Array.from( + { length: count }, + (_, index) => + `${labels[(index * 5) % labels.length]}-${String((index * 41) % (count + 23)).padStart(4, '0')}` + ) + ) +} + +function calibrate(run) { + for (let index = 0; index < 5; index += 1) { + run() + } + let iterations = 1 + while (true) { + const startedAt = performance.now() + for (let index = 0; index < iterations; index += 1) { + run() + } + if (performance.now() - startedAt >= MIN_ROUND_MS) { + break + } + iterations *= 2 + } + return iterations +} + +function measureRound(run, iterations) { + const startedAt = performance.now() + for (let index = 0; index < iterations; index += 1) { + run() + } + return (performance.now() - startedAt) / iterations +} + +function measurePair(before, after) { + const beforeIterations = calibrate(before) + const afterIterations = calibrate(after) + const beforeSamples = [] + const afterSamples = [] + for (let round = 0; round < ROUND_COUNT; round += 1) { + if (round % 2 === 0) { + beforeSamples.push(measureRound(before, beforeIterations)) + afterSamples.push(measureRound(after, afterIterations)) + } else { + afterSamples.push(measureRound(after, afterIterations)) + beforeSamples.push(measureRound(before, beforeIterations)) + } + } + const middle = Math.floor(ROUND_COUNT / 2) + return { + beforeMs: beforeSamples.sort((a, b) => a - b)[middle], + afterMs: afterSamples.sort((a, b) => a - b)[middle] + } +} + +function assertSameOrder(before, after, label) { + const expected = before() + const actual = after() + if ( + expected.length !== actual.length || + expected.some((value, index) => value !== actual[index]) + ) { + throw new Error(`${label} sort order changed`) + } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Renderer locale sort, ms per sort (median of 5 rounds). Lower is better.') +console.log( + `${pad('mode', 9)} ${pad('items', 7)} ${pad('per-call', 11)} ${pad('reused', 11)} ${pad('speedup', 9)}` +) + +for (const count of [36, 50, 250]) { + const issues = makeJiraIssues(count) + const before = () => + [...issues] + .sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true })) + .map((issue) => issue.key) + const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key) + assertSameOrder(before, after, `numeric ${count}`) + const { beforeMs, afterMs } = measurePair(before, after) + console.log( + `${pad('numeric', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +for (const count of [10, 50, 250]) { + const values = makeBaseSensitivityValues(count) + const before = () => + [...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + const after = () => [...values].sort(compareBaseSensitivityLocaleText) + assertSameOrder(before, after, `base ${count}`) + const { beforeMs, afterMs } = measurePair(before, after) + console.log( + `${pad('base', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + '\n36 rows matches the Linear page size, 50 matches the picker/Jira scale, and\n250 is a stress case. Both arms assert identical output before timing.' +) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index e8d13941ffa..bd0c3c15aee 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1058,36 +1058,6 @@ "auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": { "ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "auto.components.feature.wall.FeatureTourPreview.04d54d50ec": { - "ko": "orca · zsh" - }, - "auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": { - "ko": "생각 중..." - }, - "auto.components.feature.wall.FeatureTourPreview.3822d8d14b": { - "ko": "fix/worktree-picker-truncates" - }, - "auto.components.feature.wall.FeatureTourPreview.47f16ecf34": { - "ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다." - }, - "auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": { - "ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요." - }, - "auto.components.feature.wall.FeatureTourPreview.6218a9014d": { - "ko": "pnpm playwright test" - }, - "auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": { - "ko": "dashboard.spec.ts" - }, - "auto.components.feature.wall.FeatureTourPreview.70aa182266": { - "ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다." - }, - "auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": { - "ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요." - }, - "auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": { - "ko": "작업 중" - }, "auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": { "ko": "마일스톤" }, @@ -1502,12 +1472,6 @@ "auto.components.new.workspace.ProjectCombobox.search": { "ko": "프로젝트 검색..." }, - "auto.components.new.workspace.ProjectHostSetupCombobox.empty": { - "ko": "이 프로젝트에 준비된 호스트가 없습니다." - }, - "auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": { - "ko": "호스트 선택" - }, "auto.components.notification.sound.options.0acd3d384e": { "ko": "클랙" }, @@ -1520,9 +1484,6 @@ "auto.components.notification.sound.options.86af8d938c": { "ko": "봉 소리" }, - "auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": { - "ko": "기능 활성화" - }, "auto.components.onboarding.AgentStep.1eee1c7bd8": { "ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요." }, @@ -1538,12 +1499,6 @@ "auto.components.onboarding.AgentStep.yoloPermissionsTooltip": { "ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "auto.components.onboarding.FeatureSetupChecklist.01426f3a23": { - "ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다." - }, - "auto.components.onboarding.FeatureSetupChecklist.c5292c409d": { - "ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다." - }, "auto.components.onboarding.IntegrationsStep.0b4a7d23ab": { "ko": "로그인 중" }, @@ -1556,18 +1511,6 @@ "auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": { "ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요." }, - "auto.components.onboarding.RepoStep.466108ab89": { - "ko": "선택한 호스트에 존재하는 경로를 입력하세요." - }, - "auto.components.onboarding.RepoStep.6558d50c69": { - "ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요." - }, - "auto.components.onboarding.RepoStep.c33b190ca3": { - "ko": "호스트 경로만" - }, - "auto.components.onboarding.RepoStep.cf23006ba7": { - "ko": "선택한 호스트" - }, "auto.components.onboarding.ThemeStep.7ee9234e54": { "ko": "Ghostty 설정을 찾았습니다." }, @@ -4976,9 +4919,6 @@ "auto.components.ui.color.picker.faa855a582": { "ko": "Hex" }, - "auto.components.ui.team.multi.combobox.18ec58881e": { - "ko": "팀 검색..." - }, "auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": { "ko": "리뷰 필요" }, diff --git a/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs index 8724cf8faf4..b6f1d9571a5 100644 --- a/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs +++ b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs @@ -210,7 +210,7 @@ function connectInvalidPeer(socketPath) { const onData = (chunk) => { buffer += chunk const newline = buffer.indexOf('\n') - if (newline < 0) { + if (newline === -1) { return } let response diff --git a/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs b/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs index 86791745c10..5e350bb24f8 100644 --- a/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs +++ b/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs @@ -54,7 +54,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { const frames = [] while (cursor.length > 0) { const frameStart = cursor.indexOf(JPEG_START) - if (frameStart < 0) { + if (frameStart === -1) { const keepLastByte = cursor.at(-1) === 0xff return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } } @@ -62,7 +62,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { cursor = cursor.subarray(frameStart) } const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) - if (frameEnd < 0) { + if (frameEnd === -1) { const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes) return { frames, pending: trackedCopy(tail) } } @@ -79,7 +79,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { const frames = [] while (cursor.length > 0) { const frameStart = cursor.indexOf(JPEG_START) - if (frameStart < 0) { + if (frameStart === -1) { const keepLastByte = cursor.at(-1) === 0xff return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } } @@ -87,7 +87,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { cursor = cursor.subarray(frameStart) } const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) - if (frameEnd < 0) { + if (frameEnd === -1) { const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes) return { frames, pending: trackedCopy(tail) } } diff --git a/config/scripts/native-chat-live-session-benchmark.ts b/config/scripts/native-chat-live-session-benchmark.ts new file mode 100644 index 00000000000..86cd518b09f --- /dev/null +++ b/config/scripts/native-chat-live-session-benchmark.ts @@ -0,0 +1,335 @@ +/** + * Synthetic benchmark for native chat's post-incremental renderer hot paths. + * Run: pnpm exec tsx config/scripts/native-chat-live-session-benchmark.ts + * + * This excludes transcript parsing, IPC/remote latency, React, and DOM work. The + * 2 KB/message fixtures intentionally stress fallback-key normalization and are + * not estimates of average production message size or end-to-end frame latency. + */ + +import { deepStrictEqual, strictEqual } from 'node:assert' +import { performance } from 'node:perf_hooks' +import type { NativeChatMessage, NativeChatSession } from '../../src/shared/native-chat-types' +import { getVerifiedNativeChatCommands } from '../../src/shared/native-chat-agent-profiles' +import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat-command-envelope' +import { prepareNativeChatLiveMessages } from '../../src/renderer/src/components/native-chat/native-chat-live-message-preparation' +import { mergeNativeChatLiveSession } from '../../src/renderer/src/components/native-chat/native-chat-live-status' +import { + matchingNativeChatUserTexts, + selectPendingIndicesRepresentedByUserTexts +} from '../../src/renderer/src/components/native-chat/native-chat-pending-occurrence' +import { pendingSendsAsMessages } from '../../src/renderer/src/components/native-chat/native-chat-pending' +import { assembleNativeChatSession } from '../../src/renderer/src/components/native-chat/native-chat-session-assembler' + +type Operation = (index: number) => number +type ExpectedChecksum = (iterations: number) => number +type Calibration = { iterations: number; elapsedMs: number; capped: boolean } + +const TARGET_SAMPLE_MS = 50 +const MAX_ITERATIONS = 16_777_216 +const ROUNDS = 10 +let checksum = 0 +let expectedChecksum = 0 +let validatedCases = 0 +let cappedCalibrations = 0 +let sessionSink: NativeChatSession | null = null +let messageArraySink: NativeChatMessage[] | null = null +let contentSink = '' +let pendingMatchSink: Set | null = null +const benchmarkStartedAt = performance.now() + +function proseFixture(count: number, bytes: number, withTurnId: boolean): NativeChatMessage[] { + const payload = 'Ab Cd '.repeat(Math.ceil(bytes / 7)).slice(0, bytes) + return Array.from({ length: count }, (_, index) => ({ + id: `message-${index}`, + role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), + blocks: [{ type: 'text' as const, text: `${payload}-${index}` }], + timestamp: index, + source: 'transcript' as const, + ...(withTurnId ? { turnId: `turn-${index}` } : {}) + })) +} + +function toolFixture(count: number): NativeChatMessage[] { + return Array.from({ length: count }, (_, index) => ({ + id: `tool-${index}`, + role: index % 2 === 0 ? ('assistant' as const) : ('tool' as const), + blocks: + index % 2 === 0 + ? [ + { + type: 'tool-call' as const, + name: 'read', + input: { path: `${index}.txt`, context: 'x'.repeat(512) } + } + ] + : [{ type: 'tool-result' as const, output: `result-${index}-${'x'.repeat(512)}` }], + timestamp: index, + source: 'transcript' as const + })) +} + +function legacySession(messages: NativeChatMessage[]): NativeChatSession { + return assembleNativeChatSession({ + sources: { transcript: messages }, + sessionId: 'benchmark', + agent: 'claude' + }) +} + +function legacyMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession { + const commandNames = new Set( + getVerifiedNativeChatCommands('claude').map((command) => command.name) + ) + return legacySession(surfaceSkillInvocationUserTurns(messages, commandNames)) +} + +function directMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession { + return mergeNativeChatLiveSession({ + messages: prepareNativeChatLiveMessages(messages, 'claude'), + sessionId: 'benchmark', + agent: 'claude', + hookState: null + }) +} + +function oldEmptyPending(messages: NativeChatMessage[]): NativeChatMessage[] { + pendingMatchSink = selectPendingIndicesRepresentedByUserTexts( + [], + matchingNativeChatUserTexts(messages) + ) + return [] +} + +function blockContent(message: NativeChatMessage): string { + const block = message.blocks[0] + if (!block) { + return '' + } + if (block.type === 'text') { + return block.text + } + if (block.type === 'tool-call') { + return block.name + } + if (block.type === 'tool-result') { + return block.output + } + return block.path ?? block.url ?? block.alt ?? '' +} + +function messageWeight(message: NativeChatMessage, content: string): number { + const idTail = message.id.length > 0 ? message.id.charCodeAt(message.id.length - 1) : 0 + const contentTail = content.length > 0 ? content.charCodeAt(content.length - 1) : 0 + return message.id.length + idTail + message.role.charCodeAt(0) + content.length + contentTail +} + +function consumeSession(session: NativeChatSession, index: number): number { + sessionSink = session + messageArraySink = session.messages + const message = session.messages[index % session.messages.length] + if (!message) { + contentSink = '' + return session.status.charCodeAt(0) + } + contentSink = blockContent(message) + return session.status.charCodeAt(0) + messageWeight(message, contentSink) +} + +function consumePendingOutput(messages: NativeChatMessage[], index: number): number { + messageArraySink = messages + return (index % 7) + 1 +} + +function cyclicChecksum(values: readonly number[], iterations: number): number { + if (values.length === 0) { + return 0 + } + const cycle = values.reduce((sum, value) => sum + value, 0) + const fullCycles = Math.floor(iterations / values.length) + let total = cycle * fullCycles + for (let index = 0; index < iterations % values.length; index += 1) { + total += values[index]! + } + return total +} + +function sessionExpectedChecksum(session: NativeChatSession): ExpectedChecksum { + const statusWeight = session.status.charCodeAt(0) + const weights = session.messages.map((message) => { + const content = blockContent(message) + return messageWeight(message, content) + }) + return (iterations) => statusWeight * iterations + cyclicChecksum(weights, iterations) +} + +function pendingExpectedChecksum(iterations: number): number { + return cyclicChecksum([1, 2, 3, 4, 5, 6, 7], iterations) +} + +function runSample(operation: Operation, expected: ExpectedChecksum, iterations: number): number { + let sampleChecksum = 0 + const startedAt = performance.now() + for (let index = 0; index < iterations; index += 1) { + sampleChecksum += operation(index) + } + const elapsedMs = performance.now() - startedAt + checksum += sampleChecksum + expectedChecksum += expected(iterations) + return elapsedMs +} + +function median(samples: number[]): number { + return [...samples].sort((left, right) => left - right)[Math.floor(samples.length / 2)]! +} + +function calibrate(operation: Operation, expected: ExpectedChecksum): Calibration { + let iterations = 1 + let reachedTarget = false + while (true) { + const elapsedMs = runSample(operation, expected, iterations) + if (elapsedMs >= TARGET_SAMPLE_MS) { + if (reachedTarget) { + return { iterations, elapsedMs, capped: false } + } + reachedTarget = true + continue + } + if (iterations >= MAX_ITERATIONS) { + return { iterations, elapsedMs, capped: true } + } + reachedTarget = false + iterations = Math.min(iterations * 2, MAX_ITERATIONS) + } +} + +function benchmark( + name: string, + baseline: Operation, + optimized: Operation, + expected: ExpectedChecksum +): void { + const baselineValue = baseline(0) + const optimizedValue = optimized(0) + strictEqual(optimizedValue, baselineValue, `${name}: timed arms returned different checksums`) + validatedCases += 1 + const baselineCalibration = calibrate(baseline, expected) + const optimizedCalibration = calibrate(optimized, expected) + cappedCalibrations += Number(baselineCalibration.capped) + Number(optimizedCalibration.capped) + const baselineSamples: number[] = [] + const optimizedSamples: number[] = [] + for (let round = 0; round < ROUNDS; round += 1) { + if (round % 2 === 0) { + baselineSamples.push( + runSample(baseline, expected, baselineCalibration.iterations) / + baselineCalibration.iterations + ) + optimizedSamples.push( + runSample(optimized, expected, optimizedCalibration.iterations) / + optimizedCalibration.iterations + ) + } else { + optimizedSamples.push( + runSample(optimized, expected, optimizedCalibration.iterations) / + optimizedCalibration.iterations + ) + baselineSamples.push( + runSample(baseline, expected, baselineCalibration.iterations) / + baselineCalibration.iterations + ) + } + } + const baselineMs = median(baselineSamples) + const optimizedMs = median(optimizedSamples) + const speedup = baselineMs / Math.max(optimizedMs, Number.EPSILON) + console.log( + `${name}\t${baselineCalibration.iterations}\t${optimizedCalibration.iterations}\t${baselineCalibration.elapsedMs.toFixed(1)}\t${optimizedCalibration.elapsedMs.toFixed(1)}\t${baselineMs.toFixed(6)}\t${optimizedMs.toFixed(6)}\t${speedup.toFixed(1)}x` + ) +} + +function benchmarkSessionArms( + name: string, + baselineSession: () => NativeChatSession, + optimizedSession: () => NativeChatSession +): void { + const expectedSession = baselineSession() + deepStrictEqual(optimizedSession(), expectedSession, `${name}: session mismatch`) + const expected = sessionExpectedChecksum(expectedSession) + benchmark( + name, + (index) => consumeSession(baselineSession(), index), + (index) => consumeSession(optimizedSession(), index), + expected + ) +} + +function benchmarkMessageUpdate(name: string, messages: NativeChatMessage[]): void { + benchmarkSessionArms( + name, + () => legacyMessageUpdateSession(messages), + () => directMessageUpdateSession(messages) + ) +} + +const prose300 = proseFixture(300, 2_048, false) +const fixtures = [ + ['300 x 2KB prose, no turnId', prose300], + ['300 x 2KB prose, with turnId', proseFixture(300, 2_048, true)], + ['300 tool-heavy, no turnId', toolFixture(300)], + ['100 x 2KB prose, no turnId', proseFixture(100, 2_048, false)], + ['500 x 2KB prose, no turnId', proseFixture(500, 2_048, false)] +] as const + +console.log( + `Node ${process.version}; ${ROUNDS} alternating interleaved median rounds; ${TARGET_SAMPLE_MS} ms calibration target; ${MAX_ITERATIONS} iteration cap` +) +console.log( + 'case\tbaseline iters\toptimized iters\tbaseline cal ms\toptimized cal ms\tbaseline ms/op\toptimized ms/op\tspeedup' +) + +for (const [name, messages] of fixtures) { + benchmarkMessageUpdate(name, messages) +} + +benchmarkSessionArms( + '300 x 2KB status-only frame', + () => legacySession(prose300), + () => + mergeNativeChatLiveSession({ + messages: prose300, + sessionId: 'benchmark', + agent: 'claude', + hookState: null + }) +) + +deepStrictEqual(pendingSendsAsMessages([], prose300), oldEmptyPending(prose300)) +benchmark( + '300 x 2KB empty pending', + (index) => consumePendingOutput(oldEmptyPending(prose300), index), + (index) => consumePendingOutput(pendingSendsAsMessages([], prose300), index), + pendingExpectedChecksum +) + +const combinedSessionExpected = sessionExpectedChecksum(legacyMessageUpdateSession(prose300)) +benchmark( + '300 x 2KB combined', + (index) => + consumeSession(legacyMessageUpdateSession(prose300), index) + + consumePendingOutput(oldEmptyPending(prose300), index), + (index) => + consumeSession(directMessageUpdateSession(prose300), index) + + consumePendingOutput(pendingSendsAsMessages([], prose300), index), + (iterations) => combinedSessionExpected(iterations) + pendingExpectedChecksum(iterations) +) + +strictEqual(checksum, expectedChecksum, 'benchmark checksum accounting drifted') +strictEqual(sessionSink?.sessionId, 'benchmark', 'session outputs did not escape') +strictEqual(Array.isArray(messageArraySink), true, 'message arrays did not escape') +strictEqual(contentSink.length > 0, true, 'message content did not escape') +strictEqual(pendingMatchSink instanceof Set, true, 'pending baseline scan did not escape') +console.log( + `validated=${validatedCases} cases, checksum=${checksum}, capped calibrations=${cappedCalibrations}, runtime=${( + performance.now() - benchmarkStartedAt + ).toFixed(0)} ms` +) diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 080cb4fa785..b9a88ae12ad 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -281,7 +281,7 @@ describe('orchestration skill guidance', () => { expect(workerLoop).toContain('opaque provider model id with `--model`') expect(workerLoop).toContain('`--effort` requires `--model`') expect(workerLoop).toContain('neither option can combine with `--terminal`') - expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json') + expect(workerLoop).toContain('--agent claude --model opus --effort high --json') expect(workerLoop).toContain('`launch.requested` and `launch.effective`') }) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 2c8dc4555c0..1b695086808 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -197,8 +197,13 @@ describe('Electron runtime package contract', () => { expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'") expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')") expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))") + expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')") + expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))") expect(builderConfig).toContain("from: 'out/relay'") expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')") + expect(remoteCommands).toContain( + "joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')" + ) const assertRelayGate = (steps, publishStepName) => { const names = steps.map((step) => step.name) diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index d65b29cd253..673b796fd43 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' import { afterEach, describe, expect, it } from 'vitest' -import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard' +import { + createPlainNodeEntryGuardPlugin, + GUARDED_ENTRY_NAMES +} from '../build-plugins/plain-node-entry-guard' let outputDir: string | undefined @@ -45,16 +48,16 @@ function runWriteBundle(plugin: Plugin, dir: string, code = ''): void { ) } -function runCloseBundle(plugin: Plugin): void { +async function runCloseBundle(plugin: Plugin): Promise { const hook = plugin.closeBundle if (typeof hook !== 'function') { throw new Error('Expected closeBundle hook') } - hook.call({} as never) + await hook.call({} as never) } describe('plain Node entry guard', () => { - it('smoke-loads the daemon after output files are written', () => { + it('smoke-loads the daemon after output files are written', async () => { const dir = createOutputDir() const plugin = createPlainNodeEntryGuardPlugin() @@ -64,17 +67,17 @@ describe('plain Node entry guard', () => { 'console.error("Usage: daemon-entry "); process.exit(1)\n' ) - expect(() => runCloseBundle(plugin)).not.toThrow() + await expect(runCloseBundle(plugin)).resolves.toBeUndefined() }) - it('runs the deferred smoke from closeBundle', () => { + it('runs the deferred smoke from closeBundle', async () => { const dir = createOutputDir() const plugin = createPlainNodeEntryGuardPlugin() runWriteBundle(plugin, dir) writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n") - expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node') + await expect(runCloseBundle(plugin)).rejects.toThrow('failed to load under plain Node') }) it('rejects Electron imports during the static bundle scan', () => { @@ -84,6 +87,70 @@ describe('plain Node entry guard', () => { 'requires electron' ) }) + + it('rejects Electron subpath requires', () => { + const plugin = createPlainNodeEntryGuardPlugin() + + expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron/main")')).toThrow( + 'requires electron' + ) + }) + + it('fails the smoke when the daemon exits zero on an empty argv', async () => { + const dir = createOutputDir() + const plugin = createPlainNodeEntryGuardPlugin() + + runWriteBundle(plugin, dir) + writeFileSync(join(dir, 'daemon-entry.js'), 'console.error("Usage: daemon-entry ")\n') + + await expect(runCloseBundle(plugin)).rejects.toThrow('did not reject an empty argv') + }) + + // daemon-entry installs a SIGTERM handler, so the smoke deadline only holds if + // it escalates to SIGKILL — spawnSync's own timeout would block here forever. + it('kills a daemon that traps SIGTERM and never exits', async () => { + const dir = createOutputDir() + const plugin = createPlainNodeEntryGuardPlugin({ timeoutMs: 250, killGraceMs: 250 }) + + runWriteBundle(plugin, dir) + writeFileSync( + join(dir, 'daemon-entry.js'), + "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\n" + ) + + await expect(runCloseBundle(plugin)).rejects.toThrow('did not exit within 250ms') + }, 10_000) +}) + +// Why: writeBundle skips names absent from the bundle, so a renamed rollup input +// would silently stop guarding that entry instead of failing the build. +describe('guarded entry names', () => { + function runBuildStart(plugin: Plugin, input: unknown): void { + const hook = plugin.buildStart + if (typeof hook !== 'function') { + throw new Error('Expected buildStart hook') + } + hook.call({} as never, { input } as Rollup.NormalizedInputOptions) + } + + it('rejects a guarded name that is no longer a rollup input', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const input = Object.fromEntries( + GUARDED_ENTRY_NAMES.filter((name) => name !== 'stt-worker').map((name) => [ + name, + `${name}.ts` + ]) + ) + + expect(() => runBuildStart(plugin, input)).toThrow('"stt-worker"') + }) + + it('passes when every guarded name is a rollup input', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const input = Object.fromEntries(GUARDED_ENTRY_NAMES.map((name) => [name, `${name}.ts`])) + + expect(() => runBuildStart(plugin, input)).not.toThrow() + }) }) // Why (#11161): Electron's module is not registered on worker threads either — diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 5b3b66dea3f..165931dbeb1 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -8,6 +8,7 @@ const dependencyAction = parse( ) const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) const shellContractFiles = [ + 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts', 'src/main/daemon/shell-ready.test.ts', 'src/main/providers/local-pty-shell-ready.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', @@ -75,6 +76,7 @@ describe('PR workflow parallelism', () => { expect(shellStep).toBeDefined() expect(shellInstall).toBeDefined() + expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1') // Why the whole workflow, not just the general shards: any other lane installing // these shells would silently start running the real-shell tests twice. expect(jobsInstallingPackages).toEqual(['shell_contracts']) diff --git a/config/scripts/relay-json-payload-benchmark.ts b/config/scripts/relay-json-payload-benchmark.ts new file mode 100644 index 00000000000..8c05fea0227 --- /dev/null +++ b/config/scripts/relay-json-payload-benchmark.ts @@ -0,0 +1,166 @@ +import { performance } from 'node:perf_hooks' +import { platform, release } from 'node:os' +import { + HEADER_LENGTH, + MAX_MESSAGE_SIZE, + MessageType, + encodeFrame, + encodePreparedJsonRpcFrame, + prepareJsonRpcPayload, + type JsonRpcNotification +} from '../../src/relay/protocol' + +type BenchmarkCase = { + name: string + message: JsonRpcNotification + targets: number + iterations: number +} + +type BenchmarkResult = { + name: string + targets: number + payloadBytes: number + legacyMicros: number + preparedMicros: number + reductionPercent: number + speedup: number +} + +const SAMPLES = 17 +let checksum = 0 + +function encodeLegacyJsonRpcFrame(message: JsonRpcNotification, id: number, ack: number): Buffer { + const payload = Buffer.from(JSON.stringify(message), 'utf8') + if (payload.length > MAX_MESSAGE_SIZE) { + throw new Error(`Message too large: ${payload.length} bytes`) + } + return encodeFrame(MessageType.Regular, id, ack, payload) +} + +function runLegacy(message: JsonRpcNotification, targets: number, iteration: number): void { + const estimated = encodeLegacyJsonRpcFrame(message, 0, 0).length + checksum = (checksum + estimated) >>> 0 + for (let target = 0; target < targets; target++) { + consume(encodeLegacyJsonRpcFrame(message, iteration + target + 1, iteration)) + } +} + +function runPrepared(message: JsonRpcNotification, targets: number, iteration: number): void { + const payload = prepareJsonRpcPayload(message) + checksum = (checksum + HEADER_LENGTH + payload.byteLength) >>> 0 + for (let target = 0; target < targets; target++) { + consume(encodePreparedJsonRpcFrame(payload, iteration + target + 1, iteration)) + } +} + +function consume(frame: Buffer): void { + checksum = (checksum + frame.length + frame[0] + frame.at(-1)!) >>> 0 +} + +function measure(operation: (iteration: number) => void, iterations: number): number { + const startedAt = performance.now() + for (let iteration = 0; iteration < iterations; iteration++) { + operation(iteration) + } + return ((performance.now() - startedAt) * 1000) / iterations +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right) + return sorted[Math.floor(sorted.length / 2)] +} + +function benchmarkCase(entry: BenchmarkCase): BenchmarkResult { + const legacy = (iteration: number): void => runLegacy(entry.message, entry.targets, iteration) + const prepared = (iteration: number): void => runPrepared(entry.message, entry.targets, iteration) + for (let warmup = 0; warmup < 3; warmup++) { + measure(legacy, Math.min(20, entry.iterations)) + measure(prepared, Math.min(20, entry.iterations)) + } + const legacySamples: number[] = [] + const preparedSamples: number[] = [] + for (let sample = 0; sample < SAMPLES; sample++) { + if (sample % 2 === 0) { + legacySamples.push(measure(legacy, entry.iterations)) + preparedSamples.push(measure(prepared, entry.iterations)) + } else { + preparedSamples.push(measure(prepared, entry.iterations)) + legacySamples.push(measure(legacy, entry.iterations)) + } + } + const legacyMicros = median(legacySamples) + const preparedMicros = median(preparedSamples) + return { + name: entry.name, + targets: entry.targets, + payloadBytes: Buffer.byteLength(JSON.stringify(entry.message)), + legacyMicros, + preparedMicros, + reductionPercent: ((legacyMicros - preparedMicros) / legacyMicros) * 100, + speedup: legacyMicros / preparedMicros + } +} + +function terminalData(bytes: number): string { + const row = '\u001b[38;5;45mcompile src/renderer/pane.ts\u001b[0m\r\n' + return row.repeat(Math.ceil(bytes / row.length)).slice(0, bytes) +} + +function watcherEvents(count: number): { + kind: string + absolutePath: string + isDirectory: boolean +}[] { + return Array.from({ length: count }, (_, index) => ({ + kind: index % 7 === 0 ? 'create' : 'update', + absolutePath: `/repo/src/features/terminal/generated-${String(index).padStart(4, '0')}.ts`, + isDirectory: false + })) +} + +const ptyMessage: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-42', data: terminalData(16 * 1024), seq: 18_432, rawLength: 16 * 1024 } +} +const watcherMessage: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'fs.changed', + params: { events: watcherEvents(256) } +} +const fileMessage: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'fs.streamChunk', + params: { + streamId: 37, + seq: 11, + data: Buffer.alloc(256 * 1024, 0x61).toString('base64') + } +} + +const cases: BenchmarkCase[] = [ + { name: 'PTY 16KiB', message: ptyMessage, targets: 1, iterations: 800 }, + { name: 'PTY 16KiB', message: ptyMessage, targets: 2, iterations: 600 }, + { name: 'watcher 256 events', message: watcherMessage, targets: 1, iterations: 400 }, + { name: 'watcher 256 events', message: watcherMessage, targets: 2, iterations: 300 }, + { name: 'file 256KiB raw', message: fileMessage, targets: 1, iterations: 50 }, + { name: 'file 256KiB raw', message: fileMessage, targets: 2, iterations: 35 } +] + +const results = cases.map(benchmarkCase) +const lines = [ + `relay JSON payload benchmark: Node ${process.version}, ${platform()} ${release()}, ${SAMPLES} interleaved samples`, + 'case | targets | JSON bytes | legacy median us/op | prepared median us/op | reduction | speedup', + ...results.map( + (result) => + `${result.name} | ${result.targets} | ${result.payloadBytes} | ` + + `${result.legacyMicros.toFixed(2)} | ${result.preparedMicros.toFixed(2)} | ` + + `${result.reductionPercent.toFixed(1)}% | ${result.speedup.toFixed(2)}x` + ), + `checksum=${checksum}`, + 'Limitations: in-process framing only; excludes dispatcher scheduling, sink/network I/O, and tail-GC.', + 'Legacy is the exact pre-change framing composition retained in this script, not a separate checkout.', + 'The two-target rows model dispatcher fan-out; watcher batches and file streams are commonly single-target.' +] +process.stdout.write(`${lines.join('\n')}\n`) diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index b27a1f945d9..784abfc4850 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -94,7 +94,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) const sentinel = Buffer.from(protocol.RELAY_SENTINEL) const index = stdoutBuffer.indexOf(sentinel) - if (index < 0) { + if (index === -1) { return } settled = true @@ -148,7 +148,7 @@ function createRelayClient(entryPath, args, env, protocol) { stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) const sentinel = Buffer.from(protocol.RELAY_SENTINEL) const index = stdoutBuffer.indexOf(sentinel) - if (index < 0) { + if (index === -1) { return } ready = true diff --git a/config/scripts/remote-shared-control-retirement-probe.ts b/config/scripts/remote-shared-control-retirement-probe.ts index 63abd01e376..30edeb9802a 100644 --- a/config/scripts/remote-shared-control-retirement-probe.ts +++ b/config/scripts/remote-shared-control-retirement-probe.ts @@ -5,7 +5,8 @@ import { resolveEnvironment, resolveEnvironmentPairingOffer } from '../../src/shared/runtime-environment-store' -import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types' +import type { MemorySnapshot } from '../../src/shared/types' +import type { RuntimeStatus } from '../../src/shared/runtime-types' async function main(): Promise { const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME diff --git a/config/scripts/run-ai-vault-typing-bench.mjs b/config/scripts/run-ai-vault-typing-bench.mjs new file mode 100644 index 00000000000..e4dd25b24ed --- /dev/null +++ b/config/scripts/run-ai-vault-typing-bench.mjs @@ -0,0 +1,50 @@ +import { spawn } from 'node:child_process' + +const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx' +const knobByFlag = { + '--iterations': 'ORCA_AI_VAULT_BENCH_ITERATIONS', + '--sessions': 'ORCA_AI_VAULT_BENCH_SESSIONS', + '--payload-kib': 'ORCA_AI_VAULT_BENCH_PAYLOAD_KIB', + '--keys': 'ORCA_AI_VAULT_BENCH_KEYS', + '--cadence-ms': 'ORCA_AI_VAULT_BENCH_CADENCE_MS', + '--label': 'ORCA_AI_VAULT_BENCH_LABEL' +} + +const env = { ...process.env, ORCA_AI_VAULT_TYPING_BENCH: '1' } +const passthroughArgs = [] +const argv = process.argv.slice(2) +for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === '--') { + continue + } + const knob = knobByFlag[argv[index]] + if (knob) { + env[knob] = argv[++index] + } else { + passthroughArgs.push(argv[index]) + } +} + +const child = spawn( + npxCommand, + [ + 'playwright', + 'test', + 'tests/e2e/terminal-ai-vault-typing-latency.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...passthroughArgs + ], + { stdio: 'inherit', env } +) + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 1) +}) diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs index 53cabd3380b..3892de8845d 100644 --- a/config/scripts/run-idle-cpu-benchmark.mjs +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -1,15 +1,34 @@ #!/usr/bin/env node import { _electron as electron } from '@stablyai/playwright-test' -import { execFileSync, spawnSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' +import { + descendantsOf, + readProcessRows, + sampleProcessTreeUntilWorkloadsComplete, + summarizeProcessInventory, + summarizeSamples, + terminateProcesses +} from './idle-cpu-process-sampling.mjs' +import { + collectRendererCensus, + configureRendererScaleFixture +} from './idle-cpu-renderer-scale-fixture.mjs' +import { + runZustandPublications, + snapshotRendererTimingProbe, + startRendererTimingProbe, + stopRendererTimingProbe +} from './idle-cpu-renderer-timing-probe.mjs' import { installSyntheticVisibleSpinners } from './idle-cpu-synthetic-spinners.mjs' const DEFAULT_WARMUP_MS = 15_000 const DEFAULT_SAMPLE_MS = 30_000 const DEFAULT_INTERVAL_MS = 1_000 const DEFAULT_WORKTREE_COUNT = 1 +const DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS = 100 const ONBOARDING_FINAL_STEP = 3 const ONBOARDING_FLOW_VERSION = 2 @@ -19,6 +38,10 @@ function parseArgs(argv) { sampleMs: DEFAULT_SAMPLE_MS, intervalMs: DEFAULT_INTERVAL_MS, worktrees: DEFAULT_WORKTREE_COUNT, + lineageDepth: 0, + agentsPerWorktree: 0, + zustandPublications: 0, + zustandPublicationIntervalMs: DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS, skipBuild: false, headful: false, output: null, @@ -47,6 +70,14 @@ function parseArgs(argv) { options.intervalMs = Number(readValue()) } else if (arg === '--worktrees') { options.worktrees = Number(readValue()) + } else if (arg === '--lineage-depth') { + options.lineageDepth = Number(readValue()) + } else if (arg === '--agents-per-worktree') { + options.agentsPerWorktree = Number(readValue()) + } else if (arg === '--zustand-publications') { + options.zustandPublications = Number(readValue()) + } else if (arg === '--zustand-publication-interval-ms') { + options.zustandPublicationIntervalMs = Number(readValue()) } else if (arg === '--output') { options.output = readValue() } else if (arg === '--skip-build') { @@ -73,6 +104,10 @@ function parseArgs(argv) { 'sampleMs', 'intervalMs', 'worktrees', + 'lineageDepth', + 'agentsPerWorktree', + 'zustandPublications', + 'zustandPublicationIntervalMs', 'syntheticVisibleSpinners', 'syntheticSpinnerSteps' ]) { @@ -82,16 +117,33 @@ function parseArgs(argv) { } options.worktrees = Math.max(1, Math.floor(options.worktrees)) options.intervalMs = Math.max(250, Math.floor(options.intervalMs)) + options.lineageDepth = Math.floor(options.lineageDepth) + options.agentsPerWorktree = Math.floor(options.agentsPerWorktree) + options.zustandPublications = Math.floor(options.zustandPublications) + options.zustandPublicationIntervalMs = Math.max( + 1, + Math.floor(options.zustandPublicationIntervalMs) + ) options.syntheticVisibleSpinners = Math.max(0, Math.floor(options.syntheticVisibleSpinners)) options.syntheticSpinnerSteps = Math.max(1, Math.floor(options.syntheticSpinnerSteps)) if (!['smooth', 'steps'].includes(options.syntheticSpinnerAnimation)) { throw new Error(`Invalid --synthetic-spinner-animation: ${options.syntheticSpinnerAnimation}`) } + if (options.lineageDepth > 0 && options.worktrees < 2) { + throw new Error('--lineage-depth requires at least two --worktrees') + } + const publicationSpanMs = + Math.max(0, options.zustandPublications - 1) * options.zustandPublicationIntervalMs + if (publicationSpanMs > options.sampleMs) { + throw new Error( + `Zustand publication span ${publicationSpanMs}ms exceeds --sample-ms ${options.sampleMs}` + ) + } return options } function printUsage() { console.log( - `Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners Measurement-only: add visible working spinners\n --synthetic-spinner-animation Spinner animation style (default smooth)\n --synthetic-spinner-steps Step count for --synthetic-spinner-animation steps (default 12)\n` + `Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --lineage-depth Nest all worktrees under one expanded lineage, up to this depth\n --agents-per-worktree Seed this many visible inline agent rows per worktree\n --zustand-publications Publish exactly this many store updates during sampling\n --zustand-publication-interval-ms Publication cadence (default ${DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners Measurement-only: add visible working spinners\n --synthetic-spinner-animation Spinner animation style (default smooth)\n --synthetic-spinner-steps Step count for --synthetic-spinner-animation steps (default 12)\n` ) } function run(command, args, options = {}) { @@ -188,140 +240,6 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } -function parseCpuTimeSeconds(value) { - const trimmed = String(value || '').trim() - if (!trimmed) { - return null - } - const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed] - const days = dayOrTime === null ? 0 : Number(dayOrTime) - const parts = maybeTime.split(':').map(Number) - if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) { - return null - } - if (parts.length === 3) { - return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2] - } - if (parts.length === 2) { - return days * 86400 + parts[0] * 60 + parts[1] - } - if (parts.length === 1) { - return days * 86400 + parts[0] - } - return null -} - -function parseUnixProcesses(stdout) { - const rows = [] - for (const raw of stdout.split('\n')) { - const line = raw.trim() - if (!line) { - continue - } - const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/) - if (!match) { - continue - } - rows.push({ - pid: Number(match[1]), - ppid: Number(match[2]), - percentCpu: Number(match[3]), - rssBytes: Number(match[4]) * 1024, - cpuTimeSeconds: parseCpuTimeSeconds(match[5]), - command: match[6] - }) - } - return rows -} - -function readUnixProcesses() { - const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], { - encoding: 'utf8', - env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, - maxBuffer: 20 * 1024 * 1024 - }) - return parseUnixProcesses(stdout) -} - -function readWindowsProcesses() { - const script = - 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress' - const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], { - encoding: 'utf8', - maxBuffer: 20 * 1024 * 1024 - }) - if (result.status !== 0) { - throw new Error(result.stderr || 'PowerShell process enumeration failed') - } - const parsed = JSON.parse(result.stdout || '[]') - const entries = Array.isArray(parsed) ? parsed : [parsed] - return entries.map((entry) => ({ - pid: Number(entry.ProcessId), - ppid: Number(entry.ParentProcessId), - percentCpu: 0, - cpuTimeSeconds: null, - rssBytes: Number(entry.WorkingSetSize) || 0, - command: String(entry.CommandLine || '') - })) -} - -function readProcessRows() { - return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses() -} - -function descendantsOf(rows, rootPid) { - const children = new Map() - for (const row of rows) { - const list = children.get(row.ppid) ?? [] - list.push(row) - children.set(row.ppid, list) - } - const result = [] - const stack = [rootPid] - const seen = new Set() - while (stack.length > 0) { - const pid = stack.pop() - if (seen.has(pid)) { - continue - } - seen.add(pid) - const row = rows.find((candidate) => candidate.pid === pid) - if (row) { - result.push(row) - } - for (const child of children.get(pid) ?? []) { - stack.push(child.pid) - } - } - return result -} - -function classify(row, rootPid) { - const command = row.command.toLowerCase() - if (row.pid === rootPid) { - return 'main' - } - if (command.includes('daemon-entry')) { - return 'daemon' - } - if (command.includes('--type=gpu-process')) { - return 'gpu' - } - if (command.includes('--type=renderer')) { - return 'renderer' - } - if (command.includes('--type=utility')) { - return 'utility' - } - if (command.includes('--type=')) { - return 'electron-other' - } - if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) { - return 'agent-or-node' - } - return 'other-descendant' -} - async function collectRendererIdleState(page) { return page.evaluate(() => { const describeElement = (element) => { @@ -364,93 +282,6 @@ async function collectRendererIdleState(page) { }) } -function summarizeSamples(samples) { - const byKind = new Map() - for (const sample of samples) { - for (const proc of sample.processes) { - const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 } - bucket.cpuValues.push(proc.cpu) - bucket.rssValues.push(proc.rssBytes) - byKind.set(proc.kind, bucket) - } - const counts = new Map() - for (const proc of sample.processes) { - counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) - } - for (const [kind, count] of counts) { - byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count) - } - } - const summary = {} - for (const [kind, values] of byKind) { - const cpuSorted = [...values.cpuValues].sort((a, b) => a - b) - const rssSumBySample = samples.map((sample) => - sample.processes - .filter((proc) => proc.kind === kind) - .reduce((sum, proc) => sum + proc.rssBytes, 0) - ) - summary[kind] = { - meanCpuPercent: mean(values.cpuValues), - p95CpuPercent: percentile(cpuSorted, 0.95), - maxCpuPercent: Math.max(0, ...values.cpuValues), - meanRssBytes: mean(rssSumBySample), - maxProcessCount: values.maxProcessCount - } - } - summary.total = { - meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)), - p95CpuPercent: percentile( - samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b), - 0.95 - ), - meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes)) - } - return summary -} - -function summarizeProcessInventory(samples) { - const inventory = {} - for (const sample of samples) { - const counts = new Map() - for (const proc of sample.processes) { - counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) - const entry = inventory[proc.kind] ?? { - maxProcessCount: 0, - maxCpuPercent: 0, - commandSamples: [] - } - entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu) - if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) { - entry.commandSamples.push(proc.command) - } - inventory[proc.kind] = entry - } - for (const [kind, count] of counts) { - inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count) - } - } - return inventory -} -function mean(values) { - return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length -} - -function percentile(sorted, fraction) { - if (sorted.length === 0) { - return 0 - } - const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1) - return sorted[index] -} - -function terminateProcesses(processes) { - for (const proc of processes) { - try { - process.kill(proc.pid) - } catch {} - } -} - async function main() { const options = parseArgs(process.argv.slice(2)) const root = path.resolve(import.meta.dirname, '..', '..') @@ -491,6 +322,11 @@ async function main() { const page = await app.firstWindow({ timeout: 120_000 }) await page.waitForLoadState('domcontentloaded') await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + await page.waitForFunction( + () => window.__store?.getState().workspaceSessionReady === true, + null, + { timeout: 60_000 } + ) const measurementCss = [] if (options.disableRendererAnimations) { measurementCss.push( @@ -506,63 +342,111 @@ async function main() { options.syntheticSpinnerAnimation, options.syntheticSpinnerSteps ) - await page.evaluate(async (repoPath) => { - await window.api.repos.add({ path: repoPath }) + const fixtureState = await page.evaluate(async (repoPath) => { + const added = await window.api.repos.add({ path: repoPath }) + if ('error' in added) { + return { error: added.error } + } const store = window.__store await store?.getState().fetchRepos() - const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath) + const repo = store?.getState().repos.find((candidate) => candidate.id === added.repo.id) if (repo) { - await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' }) - await store.getState().fetchWorktrees(repo.id) + const detected = await store + .getState() + .fetchWorktrees(repo.id, { requireAuthoritative: true }) + const importedWorktreePaths = ( + store.getState().detectedWorktreesByRepo[repo.id]?.worktrees ?? [] + ) + .filter((worktree) => !worktree.selectedCheckout) + .map((worktree) => worktree.path) + const updated = await store.getState().updateRepo(repo.id, { + externalWorktreeVisibility: 'show', + importedExternalWorktreePaths: importedWorktreePaths, + externalWorktreeInboxBaselinePaths: importedWorktreePaths + }) + const refreshed = await store + .getState() + .fetchWorktrees(repo.id, { requireAuthoritative: true }) + return { + detected, + updated, + refreshed, + detectedCount: store.getState().detectedWorktreesByRepo[repo.id]?.worktrees.length ?? 0, + importedCount: importedWorktreePaths.length, + visibleCount: store.getState().worktreesByRepo[repo.id]?.length ?? 0 + } } + return { error: 'repo-not-found' } }, repoDir) + console.log(`[idle-cpu] fixture ${JSON.stringify(fixtureState)}`) await page.waitForFunction( - () => window.__store?.getState().workspaceSessionReady === true, + (expectedWorktrees) => { + const state = window.__store?.getState() + return ( + state?.workspaceSessionReady === true && + Object.values(state.worktreesByRepo).flat().length === expectedWorktrees + ) + }, + options.worktrees, + { timeout: 180_000 } + ) + const scaleFixtureState = await configureRendererScaleFixture(page, options, repoDir) + if (scaleFixtureState.applied) { + console.log(`[idle-cpu] scale fixture ${JSON.stringify(scaleFixtureState)}`) + } + await page.waitForFunction( + (expectedAgentRows) => { + const state = window.__store?.getState() + return ( + state !== undefined && + Object.keys(state.agentStatusByPaneKey ?? {}).length >= expectedAgentRows + ) + }, + scaleFixtureState.seededAgentRows, + { timeout: 30_000 } + ) + await page.waitForFunction( + () => Boolean(document.querySelector('[data-worktree-sidebar] [data-worktree-id]')), null, - { timeout: 60_000 } + { timeout: 30_000 } ) console.log( - `[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}` + `[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees} lineage-depth=${options.lineageDepth} agents/worktree=${options.agentsPerWorktree} publications=${options.zustandPublications}` ) + await startRendererTimingProbe(page) await sleep(options.warmupMs) const rendererIdleState = await collectRendererIdleState(page) - const deadline = Date.now() + options.sampleMs - const samples = [] - let previousSnapshot = null - while (Date.now() <= deadline || samples.length === 0) { - const sampledAt = Date.now() - const processRows = descendantsOf(readProcessRows(), rootPid) - const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) })) - if (previousSnapshot) { - const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000) - const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc])) - const processes = rawProcesses.map((row) => { - const previous = previousByPid.get(row.pid) - const canComputeDelta = - typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number' - const cpu = canComputeDelta - ? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100) - : row.percentCpu - return { ...row, cpu } - }) - samples.push({ - at: sampledAt, - elapsedMs: sampledAt - previousSnapshot.at, - totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0), - totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0), - processes - }) - } - previousSnapshot = { at: sampledAt, processes: rawProcesses } - await sleep(options.intervalMs) - } + const rendererCensusBefore = await collectRendererCensus(page, options.lineageDepth) + const rendererTimingBefore = await snapshotRendererTimingProbe(page) + const publicationPromise = runZustandPublications( + page, + options.zustandPublications, + options.zustandPublicationIntervalMs + ) + const sampled = await sampleProcessTreeUntilWorkloadsComplete({ + rootPid, + requestedDurationMs: options.sampleMs, + intervalMs: options.intervalMs, + workloadPromise: publicationPromise + }) + const samples = sampled.samples + const zustandPublications = sampled.workloadResult + const rendererTimingAfter = await stopRendererTimingProbe(page) + const rendererCensusAfter = await collectRendererCensus(page, options.lineageDepth) const report = { benchmark: 'orca-idle-cpu', createdAt: new Date().toISOString(), options, rootPid, platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length }, + fixtureState, + scaleFixtureState, rendererIdleState, + rendererCensusBefore, + rendererCensusAfter, + rendererTiming: { before: rendererTimingBefore, after: rendererTimingAfter }, + zustandPublications, + samplingWindow: sampled.samplingWindow, sampleCount: samples.length, summary: summarizeSamples(samples), processInventory: summarizeProcessInventory(samples), @@ -578,7 +462,13 @@ async function main() { { summary: report.summary, processInventory: report.processInventory, - sampleCount: report.sampleCount + sampleCount: report.sampleCount, + scaleFixtureState: report.scaleFixtureState, + rendererCensusBefore: report.rendererCensusBefore, + rendererCensusAfter: report.rendererCensusAfter, + rendererTiming: report.rendererTiming, + zustandPublications: report.zustandPublications, + samplingWindow: report.samplingWindow }, null, 2 diff --git a/config/scripts/run-ssh-staged-upload-reliability.mjs b/config/scripts/run-ssh-staged-upload-reliability.mjs index 4fadf6f3001..fb3d0040350 100644 --- a/config/scripts/run-ssh-staged-upload-reliability.mjs +++ b/config/scripts/run-ssh-staged-upload-reliability.mjs @@ -17,8 +17,8 @@ const defaultFiles = [ const cliArguments = process.argv.slice(2) const powerShellFlag = cliArguments.indexOf('--powershell') const configuredPowerShell = - powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE -if (powerShellFlag >= 0 && !configuredPowerShell) { + powerShellFlag !== -1 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE +if (powerShellFlag !== -1 && !configuredPowerShell) { console.error('--powershell requires an executable path') process.exit(2) } diff --git a/config/scripts/setup-adhoc-release-repo.sh b/config/scripts/setup-adhoc-release-repo.sh index 3e232c5fce8..1f3debf125c 100755 --- a/config/scripts/setup-adhoc-release-repo.sh +++ b/config/scripts/setup-adhoc-release-repo.sh @@ -86,7 +86,7 @@ Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in 1. Open: https://github.com/organizations/$ORG/settings/installations 2. Configure -> $APP_SLUG 3. Repository access -> Only select repositories -> add $ADHOC_REPO - (keep orca-hourly selected; both dev channels use this one App) + (keep orca-hourly/orca-daily selected; all dev channels use this one App) 4. Save. EOF fi diff --git a/config/scripts/setup-daily-release-repo.sh b/config/scripts/setup-daily-release-repo.sh new file mode 100755 index 00000000000..e9437c0c64a --- /dev/null +++ b/config/scripts/setup-daily-release-repo.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Creates stablyai/orca-daily and grants the existing release App write access to +# it, so daily-mac-build.yml can publish there. +# +# Why a separate repo rather than reusing orca-hourly: the daily channel is a +# once-a-day cut that people ride deliberately. Sharing hourly's list would mix a +# sparse daily series into the 72-entry hourly retention window and make both +# pickers harder to read. +# +# Why no secrets are set here: the daily workflow reuses the same GitHub App as +# hourly/adhoc — one App id, one private key, one thing to rotate. This script +# only has to widen that App's installation to cover the new repo. +# +# Run once, after config/scripts/setup-hourly-release-token.sh: +# bash config/scripts/setup-daily-release-repo.sh +# +set -euo pipefail + +ORG="stablyai" +DAILY_REPO="$ORG/orca-daily" +MAIN_REPO="$ORG/orca" +APP_SLUG="orca-hourly-release" + +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" + +if gh api "repos/$DAILY_REPO" --jq '.full_name' >/dev/null 2>&1; then + echo "$DAILY_REPO already exists." +else + echo "Creating $DAILY_REPO..." + # Why public: the in-app updater fetches release assets unauthenticated, exactly + # as it does for orca-hourly. A private repo would 404 for every client. + # + # Why the features are off: this repo holds releases and nothing else. Leaving + # issues open invites bug reports against an unvetted daily in a repo nobody + # watches, where they are simply lost. + # + # Why --add-readme in a repo with no source: publishing a release creates a tag, + # and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in. + gh repo create "$DAILY_REPO" \ + --public \ + --description "Daily macOS dev builds of Orca, cut from main each morning. Not a source repo." \ + --add-readme \ + --disable-issues \ + --disable-wiki || + fail "Could not create $DAILY_REPO." +fi + +# Also checked outside the create branch: a repo made before --add-readme is here. +if ! gh api "repos/$DAILY_REPO/commits" --jq 'length' >/dev/null 2>&1; then + fail "$DAILY_REPO has no commits — releases cannot be tagged. Add any file to it first." +fi + +echo +echo "Granting $APP_SLUG access to $DAILY_REPO..." + +# Why attempt the API before printing instructions: an org owner can do this in +# one call. Everyone else gets a 403 and the manual path below — GitHub does not +# let a mere admin widen an App's repository selection. +INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \ + --jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)" +REPO_ID="$(gh api "repos/$DAILY_REPO" --jq '.id' 2>/dev/null || true)" + +GRANTED=false +if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then + if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then + GRANTED=true + echo "Done — $APP_SLUG can now write to $DAILY_REPO." + fi +fi + +if [[ "$GRANTED" != "true" ]]; then + # Why no automated check afterwards: the endpoints that report an App's + # repository access (repos/*/installation, user/installations/*/repositories) + # both reject an ordinary `gh auth login` token, so any "verified" this script + # printed would be guesswork. The smoke test below is the real check. + cat < $APP_SLUG + 3. Repository access -> Only select repositories -> add $DAILY_REPO + (keep orca-hourly and orca-adhoc selected; all dev channels use this one App) + 4. Save. +EOF +fi + +echo +echo "Smoke-test the pipeline (after this merges):" +echo " gh workflow run daily-mac-build.yml --repo $MAIN_REPO -f force=true" +echo " gh run watch --repo $MAIN_REPO" diff --git a/config/scripts/setup-hourly-release-token.sh b/config/scripts/setup-hourly-release-token.sh index 3d846bc01d7..77f6b483445 100755 --- a/config/scripts/setup-hourly-release-token.sh +++ b/config/scripts/setup-hourly-release-token.sh @@ -8,9 +8,10 @@ # — no yearly rotation — and it belongs to the org rather than to the person who # created it, so it survives that person leaving. # -# The same App also serves adhoc-mac-build.yml, which reads these same two -# secrets: one credential, one rotation, both dev channels. Widening it to cover -# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job. +# The same App also serves adhoc-mac-build.yml and daily-mac-build.yml, which +# read these same two secrets: one credential, one rotation, all dev channels. +# Widening it to cover stablyai/orca-adhoc / orca-daily is +# setup-adhoc-release-repo.sh / setup-daily-release-repo.sh's job. # # 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 diff --git a/config/scripts/terminal-e2e-helpers.mjs b/config/scripts/terminal-e2e-helpers.mjs index 2f89f1a7f90..8b698416326 100644 --- a/config/scripts/terminal-e2e-helpers.mjs +++ b/config/scripts/terminal-e2e-helpers.mjs @@ -222,8 +222,8 @@ if (process.argv[1]?.endsWith('terminal-e2e-helpers.mjs')) { const args = process.argv.slice(2) const portIdx = args.indexOf('--port') const cmdIdx = args.indexOf('--command') - const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 9444 - const command = cmdIdx >= 0 ? args[cmdIdx + 1] : null + const port = portIdx !== -1 ? Number(args[portIdx + 1]) : 9444 + const command = cmdIdx !== -1 ? args[cmdIdx + 1] : null const term = new OrcaTerminal(port) console.log('Connecting to Orca on CDP port', port, '...') diff --git a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs index 06ee9734783..187b5aa3fc2 100644 --- a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs +++ b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs @@ -5,6 +5,7 @@ import { accessSync, chmodSync, constants as fsConstants, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -141,7 +142,7 @@ function readGeneratedScripts(home, minMtime) { const body = readFileSync(path, 'utf8') const captureIndex = body.indexOf('payload=$(cat)') const firstExitIndex = body.indexOf('exit 0') - if (captureIndex < 0 || firstExitIndex <= captureIndex) { + if (captureIndex === -1 || firstExitIndex <= captureIndex) { throw new Error([fileName, ' can exit before capturing stdin'].join('')) } return { body, fileName, path, source } @@ -311,43 +312,37 @@ async function verifyForwarding(scripts, home, payload) { } } -// Why: rewrite from the path embedded in the installed command, not a -// reconstructed join(home, ...). That way missing/failing-script cases cannot -// silently re-run the real script if the install layout changes. -function rewriteLauncherScriptPath(command, nextPath) { - const match = /if \[ -f '([^']+)'/.exec(command) - if (!match) { - throw new Error('Installed launcher command did not reference a quoted script path') - } - return command.replaceAll(match[1], nextPath) -} - async function verifyInstalledLauncher(home, payload) { const settingsPath = join(home, '.claude', 'settings.json') const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) const command = findStrings(settings).find( (value) => value.includes('claude-hook.sh') && value.includes('if [ -f ') ) - if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) { + if ( + !command || + !command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') || + !command.includes('] && [ -r ') || + !command.includes('else { command -p cat') + ) { throw new Error('Electron did not install the guarded Claude launcher') } const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-')) try { - const missingPath = join(scratch, 'missing-hook.sh') const missingResult = await runShell( - rewriteLauncherScriptPath(command, missingPath), + command, payload, - withoutOrcaEnvironment({ HOME: home }) + withoutOrcaEnvironment({ HOME: scratch }) ) assertSuccessfulWrite(missingResult, 'installed missing-script launcher') - const failingPath = join(scratch, 'failing-hook.sh') + const failingPath = join(scratch, '.orca', 'agent-hooks', 'claude-hook.sh') + mkdirSync(join(scratch, '.orca', 'agent-hooks'), { recursive: true }) writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8') chmodSync(failingPath, 0o755) const failingResult = await runShell( - rewriteLauncherScriptPath(command, failingPath), + command, payload, - withoutOrcaEnvironment({ HOME: home }) + withoutOrcaEnvironment({ HOME: scratch }) ) if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) { throw new Error('Installed launcher did not preserve a running script failure') diff --git a/config/scripts/verify-computer-native.mjs b/config/scripts/verify-computer-native.mjs index 91c27e03a5b..56efeb33521 100644 --- a/config/scripts/verify-computer-native.mjs +++ b/config/scripts/verify-computer-native.mjs @@ -27,6 +27,12 @@ const checks = [ ], enabled: true }, + { + name: 'Linux snapshot renderer tests', + command: 'python3', + args: ['native/computer-use-linux/runtime_render_test.py'], + enabled: true + }, { name: 'native provider argument guardrails', run: verifyNativeArgumentGuardrails, @@ -69,6 +75,19 @@ const checks = [ run: verifyWindowsProviderHandshake, enabled: process.platform === 'win32' }, + { + name: 'Windows snapshot renderer tests', + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + 'native/computer-use-windows/runtime-render.test.ps1' + ], + enabled: process.platform === 'win32' + }, { name: 'macOS helper app bundle and signature', run: verifyMacOSHelperApp, @@ -470,11 +489,11 @@ function verifyNativeArgumentGuardrails() { function powerShellFunctionBody(source, name) { const start = source.indexOf(`function ${name}`) - if (start < 0) { + if (start === -1) { return null } const bodyStart = source.indexOf('{', start) - if (bodyStart < 0) { + if (bodyStart === -1) { return null } let depth = 0 @@ -494,11 +513,11 @@ function powerShellFunctionBody(source, name) { function swiftFunctionBody(source, name) { const start = source.indexOf(`func ${name}`) - if (start < 0) { + if (start === -1) { return null } const bodyStart = source.indexOf('{', start) - if (bodyStart < 0) { + if (bodyStart === -1) { return null } let depth = 0 diff --git a/config/scripts/windows-ssh-attach-console-repro.mjs b/config/scripts/windows-ssh-attach-console-repro.mjs index db3a7de23d2..ce0b9b1d40a 100644 --- a/config/scripts/windows-ssh-attach-console-repro.mjs +++ b/config/scripts/windows-ssh-attach-console-repro.mjs @@ -153,6 +153,7 @@ function prepareRelayTree(runDir, nodePtyDir) { for (const filename of [ 'relay.js', 'relay-watcher.js', + 'relay-ai-vault-service.js', 'managed-hook-runtime.js', NODE_PTY_PATCH_FILENAME, '.version' diff --git a/config/scripts/worktree-base-pending-marker-benchmark.mjs b/config/scripts/worktree-base-pending-marker-benchmark.mjs new file mode 100644 index 00000000000..9240acd22ad --- /dev/null +++ b/config/scripts/worktree-base-pending-marker-benchmark.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// Run with: node config/scripts/worktree-base-pending-marker-benchmark.mjs +import fs from 'node:fs' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' +import os from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { createJiti } from 'jiti' + +const CANDIDATE_COUNT = Number(process.env.ORCA_PENDING_MARKER_BENCH_CANDIDATES ?? '64') +const TOTAL_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_TICKS ?? '900') +const STEADY_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_STEADY_TICKS ?? '300') + +for (const [name, value] of [ + ['ORCA_PENDING_MARKER_BENCH_CANDIDATES', CANDIDATE_COUNT], + ['ORCA_PENDING_MARKER_BENCH_TICKS', TOTAL_TICKS], + ['ORCA_PENDING_MARKER_BENCH_STEADY_TICKS', STEADY_TICKS] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} +if (STEADY_TICKS >= TOTAL_TICKS) { + throw new Error('ORCA_PENDING_MARKER_BENCH_STEADY_TICKS must be smaller than total ticks') +} + +const root = await mkdtemp(join(os.tmpdir(), 'orca-pending-marker-bench-')) +const markerPaths = new Set() +for (let index = 0; index < CANDIDATE_COUNT; index += 1) { + const candidate = join(root, `ordinary-folder-${index}`) + await mkdir(candidate) + markerPaths.add(join(candidate, '.git')) +} + +const originalStat = fs.promises.stat +let visibleTick = 0 +const markerStatTicks = [] +fs.promises.stat = async (path, ...args) => { + if (markerPaths.has(String(path))) { + markerStatTicks.push(visibleTick) + } + return originalStat(path, ...args) +} +syncBuiltinESMExports() + +let poller +let timeout +try { + const jiti = createJiti(import.meta.url) + const { startWorktreeBaseDirectoryPoller } = await jiti.import( + '../../src/main/ipc/worktree-base-directory-poller.ts' + ) + const repo = { repoId: 'repo-1', repoName: 'repo', nestWorkspaces: false } + const target = { + key: `base:local:${root}`, + kind: 'base', + path: root, + repos: new Map([[repo.repoId, repo]]) + } + let finish + let fail + const completed = new Promise((resolve, reject) => { + finish = resolve + fail = reject + }) + timeout = setTimeout(() => fail(new Error('poller benchmark timed out')), 30_000) + const startedAt = performance.now() + poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { + pollIntervalMs: 0, + visibility: { + isWindowVisible: () => { + visibleTick += 1 + if (visibleTick > TOTAL_TICKS) { + finish() + return false + } + return true + }, + onWindowBecameVisible: () => () => {} + } + } + ) + await completed + clearTimeout(timeout) + await poller.unsubscribe() + poller = undefined + + const steadyStartTick = TOTAL_TICKS - STEADY_TICKS + const steadyMarkerStats = markerStatTicks.filter((tick) => tick > steadyStartTick).length + const totalMarkerStats = markerStatTicks.length + const statsPerCandidateTick = steadyMarkerStats / CANDIDATE_COUNT / STEADY_TICKS + const elapsedMs = performance.now() - startedAt + + console.log('Worktree-base marker cooldown benchmark. Lower is better.') + console.log( + JSON.stringify({ + candidates: CANDIDATE_COUNT, + totalTicks: TOTAL_TICKS, + steadyTicks: STEADY_TICKS, + totalMarkerStats, + steadyMarkerStats, + statsPerCandidateTick: Number(statsPerCandidateTick.toFixed(4)), + elapsedMs: Number(elapsedMs.toFixed(1)) + }) + ) +} finally { + clearTimeout(timeout) + await poller?.unsubscribe() + fs.promises.stat = originalStat + syncBuiltinESMExports() + await rm(root, { recursive: true, force: true }) +} diff --git a/config/scripts/worktree-refresh-churn-benchmark.mjs b/config/scripts/worktree-refresh-churn-benchmark.mjs new file mode 100644 index 00000000000..67265305322 --- /dev/null +++ b/config/scripts/worktree-refresh-churn-benchmark.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import { performance } from 'node:perf_hooks' +import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts' +import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts' + +const WORKTREE_COUNT = 655 +const TAB_COUNT = 1_895 +const REFRESH_COUNT = 10 +const ROUNDS = 15 +const BATCHES_PER_ROUND = 25 + +function makeFixture() { + const worktrees = Array.from({ length: WORKTREE_COUNT }, (_, index) => ({ + id: `repo-${index % 15}::/workspace/${index}`, + sortOrder: WORKTREE_COUNT - index, + lastActivityAt: index % 19, + isArchived: false + })) + const tabs = Array.from({ length: TAB_COUNT }, (_, index) => ({ + worktreeId: worktrees[index % worktrees.length].id, + live: index % 3 === 0 + })) + return { worktrees, tabs } +} + +function runSidebarProjection(worktrees, tabs) { + const liveTabsByWorktree = new Map() + for (const tab of tabs) { + if (tab.live) { + liveTabsByWorktree.set(tab.worktreeId, (liveTabsByWorktree.get(tab.worktreeId) ?? 0) + 1) + } + } + const sorted = [...worktrees].sort( + (left, right) => + (liveTabsByWorktree.get(right.id) ?? 0) - (liveTabsByWorktree.get(left.id) ?? 0) || + right.lastActivityAt - left.lastActivityAt || + right.sortOrder - left.sortOrder + ) + return sorted.map((worktree) => worktree.id) +} + +function runBaseline() { + const fixture = makeFixture() + const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree])) + const requestedIds = fixture.worktrees.map((worktree) => worktree.id) + let catalog = fixture.worktrees + let publications = 0 + let persistenceWrites = 0 + for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) { + const now = 1_000_000 + refresh + requestedIds.forEach((worktreeId, index) => { + metaById.set(worktreeId, { + ...metaById.get(worktreeId), + sortOrder: now - index * 1000 + }) + persistenceWrites += 1 + }) + const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId))) + const reconciled = reuseEqualCatalogRows(catalog, incoming) + if (reconciled !== catalog) { + catalog = reconciled + publications += 1 + runSidebarProjection(catalog, fixture.tabs) + } + } + return { publications, persistenceWrites } +} + +function runCandidate() { + const fixture = makeFixture() + const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree])) + const requestedIds = fixture.worktrees.map((worktree) => worktree.id) + let catalog = fixture.worktrees + let publications = 0 + let persistenceWrites = 0 + for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) { + const updates = planWorktreeSortOrderUpdates( + requestedIds, + (worktreeId) => metaById.get(worktreeId), + 1_000_000 + refresh + ) + for (const update of updates) { + metaById.set(update.worktreeId, { + ...metaById.get(update.worktreeId), + sortOrder: update.sortOrder + }) + } + persistenceWrites += updates.length + const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId))) + const reconciled = reuseEqualCatalogRows(catalog, incoming) + if (reconciled !== catalog) { + catalog = reconciled + publications += 1 + runSidebarProjection(catalog, fixture.tabs) + } + } + return { publications, persistenceWrites } +} + +function measureBatch(run) { + let counters + const startedAt = performance.now() + for (let batch = 0; batch < BATCHES_PER_ROUND; batch += 1) { + counters = run() + } + return { elapsedMs: (performance.now() - startedAt) / BATCHES_PER_ROUND, counters } +} + +function measureComparison() { + runBaseline() + runCandidate() + const samples = Array.from({ length: ROUNDS }, (_, round) => { + let baseline + let candidate + if (round % 2 === 0) { + baseline = measureBatch(runBaseline) + candidate = measureBatch(runCandidate) + } else { + candidate = measureBatch(runCandidate) + baseline = measureBatch(runBaseline) + } + return { baseline, candidate, ratio: candidate.elapsedMs / baseline.elapsedMs } + }).sort((left, right) => left.ratio - right.ratio) + return samples[Math.floor(samples.length / 2)] +} + +const { baseline, candidate } = measureComparison() +if (baseline.counters.publications !== REFRESH_COUNT || candidate.counters.publications !== 0) { + throw new Error('Benchmark fixture no longer reproduces the refresh feedback loop') +} + +console.log( + `Worktree refresh churn: ${WORKTREE_COUNT} worktrees, ${TAB_COUNT} tabs, ${REFRESH_COUNT} refreshes` +) +console.log( + `Before: ${baseline.elapsedMs.toFixed(3)} ms, ${baseline.counters.publications} catalog publications, ${baseline.counters.persistenceWrites} rank writes` +) +console.log( + `After: ${candidate.elapsedMs.toFixed(3)} ms, ${candidate.counters.publications} catalog publications, ${candidate.counters.persistenceWrites} rank writes` +) +console.log( + `Reduction: ${((1 - candidate.elapsedMs / baseline.elapsedMs) * 100).toFixed(1)}% CPU, 100% feedback publications` +) diff --git a/config/scripts/wsl-git-shell-benchmark.mjs b/config/scripts/wsl-git-shell-benchmark.mjs index 00ea47ded0a..d29aaea2824 100644 --- a/config/scripts/wsl-git-shell-benchmark.mjs +++ b/config/scripts/wsl-git-shell-benchmark.mjs @@ -175,7 +175,7 @@ async function main() { wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)]) ) const markerOffset = result.stdout.indexOf(outputMarker) - if (markerOffset < 0) { + if (markerOffset === -1) { throw new Error('Login shell did not emit the Git output marker') } result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker)) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 14295cb475a..572e9dcd0f9 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,6 +12,8 @@ "../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/agent-hooks/managed-hook-script-refresh.ts", + "../src/main/agent-hooks/runtime-home-hook-command.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", @@ -74,6 +76,7 @@ "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", + "../src/main/startup/windows-shell-path-ownership.ts", // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this // project's serve spec; the module has no imports, so listing it pulls in nothing else. "../src/main/startup/serve-mode-argv.ts", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 735e1b2a0af..d23bee128c9 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -16,7 +16,8 @@ export default defineConfig({ test: { environment: 'node', // Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's. - execArgv: ['--no-experimental-webstorage'], + // Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds. + execArgv: ['--no-experimental-webstorage', '--expose-gc'], // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], include: [ diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index d795f74039a..a76d981dd2f 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -67,9 +67,9 @@ A common point of drift. Use these conventions for any list-style row (worktrees - **Idle:** transparent background. - **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`). -- **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically. +- **Keyboard-selected (cmdk highlight):** do **not** rely on flat `bg-accent` alone on light popover/dialog surfaces — `--accent` (#f5f5f5) is nearly identical to `--background` (#fff), so the cursor vanishes. Use the jump-palette recipe in `main.css` (`.jump-palette-item[data-selected='true']`): `color-mix` foreground into background (~12%) plus an inset ring. Expose the mix as `--jump-palette-selection-surface` when nested cutouts (status pips) must match. The `data-selected` attribute is set by `cmdk` automatically. - **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, _plus_ a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight. -- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app. +- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. Mix from existing tokens (`foreground`/`background`/`accent`) so light/dark stay aligned. ### Color mixing @@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you' Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern: -- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) +- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) - Use `cn()` for class merging. Pass user `className` last so callers can override. - Use `class-variance-authority` (CVA) for variants when there are multiple. @@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork: | Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered | | Single choice from a known list | `Select` | Custom listbox | | Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) | -| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one | +| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one | | Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner | | Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) | diff --git a/docs/ai-vault-process-isolation-plan.md b/docs/ai-vault-process-isolation-plan.md new file mode 100644 index 00000000000..f9be048a12a --- /dev/null +++ b/docs/ai-vault-process-isolation-plan.md @@ -0,0 +1,596 @@ +# AI Vault process isolation architecture and implementation plan + +Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete. + +Last updated: 2026-08-09. + +## Decision summary + +AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary. + +The target has three rules: + +1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process. +2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data. +3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer. + +This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified. + +The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal. + +## Goals + +- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host. +- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active. +- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time. +- The service can be restarted without restarting the app, runtime, relay, or terminals. +- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible. +- Mixed-version remote peers continue to work. +- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services. + +## Non-goals + +- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity. +- Rewriting the scanner or changing session discovery semantics during the process migration. +- Moving xterm into another renderer. +- Adding a process per window, worktree, repository, or SSH request. +- Removing the old-relay compatibility fallback in the same change. +- Making pagination a public wire dependency before mixed-version fallback behavior exists. + +## Current architecture and coupling + +| Path | Current execution | Remaining coupling | +| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main | +| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host | +| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process | +| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay | +| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm | + +The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API. + +The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign. + +## Feature-parity contract + +The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics. + +| Capability | Required behavior after migration | Owner | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters | +| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today | +| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection | +| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router | +| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them | +| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation | +| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing | +| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate | +| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate | +| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result | +| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration | +| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success | +| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling | +| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports | + +Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow. + +No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list. + +## Target topology + +```text +Desktop / runtime host + + Integrated renderer + ├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon + └─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process + │ + └─ host merge + compatibility routing + +SSH execution host + + Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar + └────────────────── PTY handler/event loop +``` + +The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar. + +## Process ownership + +- Desktop: one Vault service per Electron main process, shared by every window and workspace. +- Orca runtime: one Vault service per runtime host process, shared by all connected clients. +- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests. +- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration. +- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop. + +## Service responsibilities + +The service owns: + +- Source discovery and filesystem metadata reads. +- Transcript streaming and parsing for all supported agents. +- OpenCode SQLite reads and its nested worker lifecycle. +- In-memory and persisted session parse caches. +- The bounded title index and transcript title resolution. +- On-demand Claude/OMP subagent listing and full first-user-prompt extraction. +- Scan parse concurrency and cooperative cancellation. +- Result construction, deduplication, sorting, and internal paging. +- Per-request metrics that contain counts and timings, not session content. + +The router owns: + +- Electron IPC, runtime RPC, or relay method registration. +- Authentication and connection ownership already enforced by that transport. +- Public argument validation and execution-host selection. +- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration. +- Multi-host result merging. +- Request coalescing and mapping caller cancellation to the service request. +- Validation of service responses before publishing them across a public boundary. +- Compatibility fallback selection. +- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation. + +The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication. + +## Internal process protocol + +Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol. + +Parent to service: + +```ts +type ServiceRequest = + | { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions } + | { type: 'request'; id: number; operation: 'scan'; options: ScanOptions } + | { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] } + | { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest } + | { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest } + | { type: 'invalidate'; paths: string[]; generation: number } + | { type: 'cancel'; id: number } + | { type: 'shutdown' } +``` + +Service to parent: + +```ts +type ServiceResponse = + | { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities } + | { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics } + | { type: 'result'; id: number; operation: 'titles'; value: TitlesResult } + | { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult } + | { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult } + | { type: 'invalidated'; generation: number } + | { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean } + | { type: 'fatal'; code: ServiceErrorCode; message: string } +``` + +Protocol rules: + +- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch. +- Use monotonically increasing safe-integer request IDs scoped to the child lifetime. +- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth. +- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes. +- Coalesce equivalent scans. Replace an older queued background scan with the newest one. +- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does. +- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy. +- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck. +- Continue returning the last good renderer result when a refresh fails. +- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state. +- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented. + +The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers. + +## Lifecycle and supervision + +### Startup + +- Start lazily on the first scan or title request. +- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader. +- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows. +- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready. +- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child. + +### Steady state + +- Reuse the process and caches across calls. +- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency. +- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal. +- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive. +- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper. + +### Faults and restart + +- Reject the active request on exit, disconnect, malformed protocol, or timeout. +- Retry queued work in a new process only when it is safe and has not been cancelled. +- Back off at 250 ms, 1 second, then 5 seconds. +- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt. +- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely. + +## Resource and quality-of-service policy + +The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness. + +- CPU: below-normal process priority, scan concurrency eight, one scan at a time. +- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry. +- Queue: 16 calls, coalescing, background replacement, explicit priority. +- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior. +- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve. +- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending. +- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap. + +Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation. + +## Renderer publication + +`ai-vault-session-refresh.ts` should stop applying a completed result immediately. + +Add `ai-vault-session-publication-gate.ts` with this behavior: + +1. Cache the validated result immediately so another caller can reuse it. +2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`. +3. Otherwise retain only the newest pending result and wait for quiet. +4. Publish after 1 second even if input continues, using a transition and one bounded page. +5. Cancel a pending publication when the scope, host, or component request token changes. + +Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel. + +A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization. + +## Desktop and runtime implementation + +Create these concrete modules: + +- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown. +- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy. +- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions. +- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution. +- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes. +- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy. + +Reuse the scanner and cache modules without moving their business logic. Replace imports in: + +- `src/main/ai-vault/cached-session-list.ts` +- `src/main/ai-vault/session-title-resolver.ts` +- `src/main/ipc/ai-vault-subagent-list.ts` +- `src/main/ai-vault/session-first-user-prompt-read.ts` +- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete +- `src/main/ipc/ai-vault.ts` +- `src/main/runtime/rpc/methods/ai-vault.ts` + +Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period. + +Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration. + +## SSH relay sidecar implementation + +Create: + +- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader. +- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter. +- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports. + +Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process. + +`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness. + +If the sidecar cannot start or crashes: + +- Return a normal `AiVaultListResult` containing a host issue and no new sessions. +- Do not scan inline in the relay. +- Keep all PTYs, reconnect state, and other relay methods available. +- Allow the next manual refresh to cross the restart circuit breaker once. + +Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals. + +## Remote compatibility + +The first implementation adds no public method, stream opcode, required field, or changed result meaning. + +- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar. +- New desktop with current relay: calls the same methods and receives the same result shape. +- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback. +- Old desktop with new relay: unknown internal sidecar details never cross the wire. + +Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability. + +The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision. + +## Folder workspaces, WSL, and paths + +- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata. +- Continue using `path.join` and the existing host-platform path adapters. +- Keep Windows batch and shell selection unrelated to terminal-shell preference. +- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows. +- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths. +- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript. +- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected. + +## Security boundary + +The service is a performance and reliability boundary, not a privilege boundary. + +- It has the same OS user and must access the same agent stores. +- Do not expose its IPC channel to renderer code or the network. +- The parent passes validated options; the service validates again before filesystem access. +- Do not log prompts, titles, transcript paths, environment values, or raw service messages. +- Preserve symlink and allowed-root checks for title reads. +- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further. + +## Packaging + +Desktop packaging changes: + +- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`. +- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`. +- Reuse the existing unpacked entry-path pattern used by other forked processes. +- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down. + +Relay packaging changes: + +- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture. +- Include it in `.version` content hashing and deploy completeness assertions. +- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together. +- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier. + +## Observability + +Add spans and counters for: + +- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit. +- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls. +- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count. +- Parent/child serialization duration and serialized bytes. +- Child RSS and heap-used snapshots at request completion. +- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count. +- Relay sidecar failures separately from PTY and relay-loop health. + +Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs. + +## Verification matrix + +The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable. + +| Layer | Required coverage | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts | +| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ | +| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush | +| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown | +| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP | +| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior | +| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions | +| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state | +| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency | +| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata | + +The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals. + +## Failure behavior + +| Failure | User-visible Vault behavior | Terminal behavior | +| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- | +| Service fails to start | Keep last result and show a retryable host issue | Unchanged | +| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged | +| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged | +| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged | +| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged | +| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available | +| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged | +| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains | + +## Benchmark methodology + +The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home. + +Each iteration: + +1. Keeps the Vault panel mounted in both arms. +2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home. +3. Alternates arm order to reduce warm-up bias. +4. Runs paced raw-mode terminal echo typing as the control. +5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment. +6. Verifies the newest seeded title is visible, proving the full scan and UI path completed. + +The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key. + +Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory. + +## Current measurements + +Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work. + +Pre-implementation parity baseline: + +- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts. +- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests. +- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes. + +### Representative pass + +Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data. + +| Metric | Control | Refresh active | +| ------------------------------ | -------------------: | ------------------: | +| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms | +| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms | +| Worst timer drift | 14.2 ms | 24.5 ms | +| Worst frame gap | 9.4 ms | 24.9 ms | +| Renderer long tasks over 50 ms | 0 | 0 | +| Missing echoes | 0 / 300 | 0 / 300 | +| Refresh duration p50 / max | — | 103.5 / 204.2 ms | + +There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration. + +### Stress pass + +Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data. + +| Metric | Control | Refresh active | +| ------------------------------ | ------------------: | -------------------: | +| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms | +| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms | +| Worst timer drift | 9.3 ms | 43.6 ms | +| Worst frame gap | 9.4 ms | 50.0 ms | +| Renderer long tasks over 50 ms | 0 | 0 | +| Missing echoes | 0 / 240 | 0 / 240 | +| Refresh duration p50 / max | — | 276.3 / 453.5 ms | + +The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer. + +### Measurement limits + +- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior. +- The benchmark covers the local service path, not the currently unisolated SSH relay path. +- Raw shell echo is a stable latency probe, not a full-screen TUI workload. +- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost. + +## Post-implementation A/B + +Run the same build with the rollout switch off and on, alternating launch order: + +```bash +ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control +ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment +``` + +Run at least five representative and five stress passes on: + +- macOS Apple Silicon. +- Windows x64 with a folder workspace and a WSL-backed source present. +- Ubuntu 20.04-compatible x64 packaging/runtime. +- A 4-core or smaller machine or constrained CI runner. + +Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy. + +## Implemented result + +The implementation now matches the target topology: + +- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback. +- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry. +- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue. +- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes. +- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second. +- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker. + +Local validation on 2026-08-09: + +- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully. +- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane. +- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone. +- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled. + +Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence. + +| Metric | Worker control | Worker refresh | Process control | Process refresh | +| --------------------- | -------------: | -------------: | --------------: | --------------: | +| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms | +| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms | +| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms | +| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms | +| Long tasks over 50 ms | 0 | 0 | 0 | 0 | +| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 | +| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms | + +The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness. + +Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable. + +## Acceptance criteria + +Correctness: + +- Zero missing terminal echoes. +- The latest seeded Vault session is visibly published in every treatment arm. +- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner. +- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics. + +Performance: + +- Treatment parse p95 is no more than 10 ms above its paired control. +- Treatment render p95 is no more than 15 ms above its paired control. +- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms. +- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run. +- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes. + +Isolation: + +- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon. +- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods. +- The service respects queue, timeout, idle, and heap bounds. +- Packaged app and relay artifacts contain and launch the correct entry for every supported platform. + +## Phased implementation plan + +### Phase 0 — measurement harness (completed in this change) + +- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes. +- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`. +- Capture representative and stress baselines. + +Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes. + +### Phase 1 — desktop and runtime service behind a switch + +- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor. +- Reuse current scanner/cache implementations. +- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled. +- Keep the worker path as the disabled-switch fallback. +- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution. +- Add E2E fault injection that kills the service during active terminal typing. + +Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets. + +### Phase 2 — make the desktop/runtime process the default + +- Enable the process by default in development, E2E, and canary builds. +- Collect process RSS, restart, timeout, and latency telemetry without content. +- Validate Windows, Linux, WSL, and constrained-runner results. +- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle. + +Exit: no unexplained crash or timeout increase and all acceptance gates green. + +### Phase 3 — SSH relay sidecar + +- Add the relay sidecar entry and supervisor. +- Remove scanner/parser/cache imports from the relay handler. +- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test. +- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests. +- Make inline relay scanning impossible; sidecar failure returns a host issue. + +Exit: remote typing and fault gates green; current public relay methods unchanged. + +### Phase 4 — renderer quiet publication + +- Add the shared quiet-publication gate and React transition. +- Retain only the newest pending result per scope/host. +- Add supersession, starvation timeout, unmount, and rapid-host-switch tests. +- Re-run the Electron benchmark with large result counts and active filtering/grouping. + +Exit: no Vault-attributable renderer long task over 50 ms in the representative gate. + +### Phase 5 — hardening and cleanup + +- Remove the worker implementation after one stable release if the kill switch was unused. +- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry. +- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate. +- Document operational recovery and service diagnostics. + +Exit: kill switch and dead worker code removed only after evidence supports it. + +## Rollback + +- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data. +- Renderer: disable quiet publication and apply the validated complete result directly. +- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails. +- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion. + +## Confidence and remaining questions + +Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation. + +The remaining implementation questions are deliberately narrow: + +1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on. +2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry. +3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets. +4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop. diff --git a/docs/assets/caffeinate-agent-menu-after.png b/docs/assets/caffeinate-agent-menu-after.png new file mode 100644 index 00000000000..393fbd5c0b1 Binary files /dev/null and b/docs/assets/caffeinate-agent-menu-after.png differ diff --git a/docs/assets/caffeinate-agent-menu-before.png b/docs/assets/caffeinate-agent-menu-before.png new file mode 100644 index 00000000000..7c12d9ca581 Binary files /dev/null and b/docs/assets/caffeinate-agent-menu-before.png differ diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 7414e4cea7a..610bdb8b92e 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 17m + + downloads: 19m @@ -15,7 +15,7 @@ downloads downloads - 17m - 17m + 19m + 19m diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 0a1ca5390ac..c36cb0c6834 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) --- diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index f11b25acca7..2563a146465 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -40,7 +40,7 @@ Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez. -[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -235,7 +235,7 @@ yay -S stably-orca-bin Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone. - **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android :** [Télécharger l'APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) +- **Android :** [Télécharger l'APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index b5783322291..6b5ee367608 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 4ee6beaeedc..d02a025cb5f 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -179,12 +179,14 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   OpenCode logo OpenCode   + MiMo Code logo MiMo Code   Amp logo Amp   OpenClaude logo OpenClaude   Antigravity logo Antigravity   Pi logo Pi   oh-my-pi logo oh-my-pi   Hermes Agent logo Hermes Agent   + Devin logo Devin   Goose logo Goose   Auggie logo Auggie   Autohand Code logo Autohand Code   @@ -211,6 +213,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 - **[onOrca.dev에서 다운로드](https://onorca.dev/download)** - 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest) +- headless Linux 서버에서 `orca serve`를 실행하시나요? [Headless Linux 서버 가이드](../reference/headless-linux-server.md)를 확인하세요. _또는 패키지 매니저로 설치:_ @@ -226,8 +229,8 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. -- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) +- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA) +- **Android:** [APK 0.0.42 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) --- @@ -235,6 +238,10 @@ yay -S stably-orca-bin - **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요. - **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요. +- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요. + + Orca 커뮤니티 WeChat 그룹 7 QR 코드 + - **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요. - **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요. - **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요. @@ -249,6 +256,14 @@ yay -S stably-orca-bin Orca 기여자 +

+ stablyai/orca GitHub Star 히스토리 차트 +

+ +## 서명된 빌드 + +Windows 코드 서명은 [SignPath.io](https://signpath.io)가 후원·제공하며, 인증서는 [SignPath Foundation](https://signpath.org)에서 제공합니다. + ## 라이선스 Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다. diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index d4017883d3e..a55d1aed1c8 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Baixar APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) +- **Android:** [Baixar APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 818b689ef2e..2b14d7987b2 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) --- diff --git a/docs/reference/renderer-agent-status-performance.md b/docs/reference/renderer-agent-status-performance.md new file mode 100644 index 00000000000..8ed43d868ce --- /dev/null +++ b/docs/reference/renderer-agent-status-performance.md @@ -0,0 +1,328 @@ +# Renderer agent-status performance + +## Status + +This design is adopted for the renderer's high-frequency agent-status path. It +keeps the existing event semantics while bounding the amount of synchronous +store fanout performed for one IPC burst. + +It lands in slices. This document and the `bench:idle-cpu` harness land first so +the store changes can be reviewed against a baseline someone else measured. Until +the store slice lands, the `setAgentStatuses` / `transactAgentStatuses` actions +and the agent-status write workload described below are not yet on `main`; the +harness measures scale, listener census, and raw publication fanout only. + +## Context + +Orca can display a large expanded worktree lineage inside one virtualized list +row. Virtualizing the root row does not virtualize its descendants, so a +100-worktree lineage can mount 100 `WorktreeCard` instances at once. + +Agent-status IPC events are bursty. The renderer already groups live events into +a 33 ms window, but the original flush applied every queued event with a +separate Zustand write. Zustand synchronously visits every listener for every +publication. The resulting work therefore grew with both the number of status +events and the number of mounted subscriptions: + +```text +burst work ~= status events x store listeners x selector work +``` + +A production trace captured the renderer repeatedly entering +`flushLiveAgentStatusBurst -> applyAgentStatus -> setAgentStatus -> setState` +through `Set.forEach`. A deterministic 100-worktree fixture reproduces the +structural multiplier; see "Baseline on `main`" below for the currently measured +listener count and publication cost. + +The production app later recovered substantially when all configured remote +hosts were removed. Host removal can stop relay/reconnect traffic, remove +mounted remote worktrees, or both, depending on host type and removal options. +That observation identifies remote presence as the production trigger but does +not by itself distinguish traffic volume from mounted-listener fanout. + +A read-only reconnect audit ruled out systematic double status emission from a +full PTY replay: replay bytes bypass OSC status parsing. Reconnect still causes +a full terminal-buffer repaint for every attached remote pane, which is a +separate source of renderer work and remains a follow-up investigation. + +## Goals + +- Keep a large expanded lineage responsive during dense agent-status traffic. +- Preserve every ordered status transition, including repeated updates for one + pane inside the same burst. +- Publish agent-status state once for a deferred live burst. +- Preserve selector identity and child render isolation. +- Make the regression reproducible without relying on a user's production data. + +## Non-goals + +- Changing the client/server status payload or remote protocol. +- Deduplicating status events by pane. +- Changing agent freshness, retention, history, title, completion, or provider + session behavior. +- Changing remote reconnect, PTY replay, or terminal repaint behavior. +- Redesigning lineage presentation or collapsing worktrees automatically. + +## Design + +### Bound mounted subscription fanout + +Sidebar components select cohesive state bundles with shallow equality instead +of registering one listener per field. Derived arrays and maps retain their +existing shallow identity behavior so unrelated store writes do not rerender a +card. Full agent-list mode keeps its child-level subscription boundary; compact +mode passes the already selected rows to avoid selecting the same inputs twice. + +The deterministic 100-worktree fixture pins the resulting listener budget: + +| Surface | Listener budget | +| ------------------------------ | --------------: | +| Worktree card state and caches | 2 | +| Agent-row inputs | 1 | +| Worktree activity status | 1 | +| Closed context menu | 1 | + +Unmount tests require the listener count to return to its prior baseline. In the +bundled prototype, the fixture without seeded agents fell from 8,518 listeners +to 1,218; with 100 visible agent rows the candidate mounted 1,618. Compare +against the census in "Baseline on `main`", which the harness reports directly. + +### Share working-spinner phase without per-element animation queries + +Working rows keep the existing compositor-driven CSS animation and shared +visual phase. Each mount derives one negative animation delay from the document +timeline instead of querying `getAnimations()` and mutating the animation start +time. This removes per-row Web Animations setup from dense status transitions +without adding a JavaScript animation clock. + +### Fold a burst in event order + +The store exposes the single-update action and two batch forms: + +- `setAgentStatus(paneKey, payload, ...)` retains the positional single-update + API for the immediate live path. +- `setAgentStatuses(updates)` applies a prebuilt ordered list, while + `transactAgentStatuses(operation)` lets IPC derive each update against the + exact staged state before the single commit. + +Both entry points reuse the same single-update state transition. The batch +reducer passes each resulting state into the next update, so a sequence such as +`working -> waiting -> done` retains the same history and timestamps as three +sequential calls. Updates are never keyed or deduplicated before the fold. + +The live IPC queue is spliced before it is processed. This preserves the +existing reentrancy guarantee: a synchronous subscriber can enqueue another +event without causing the current queue to be drained recursively. The first +event outside an active burst remains immediate; events accumulated within the +33 ms window are applied as one ordered transaction. Startup snapshots and +bounded pending-hydration retries use the same transaction path instead of +publishing once per restored pane. + +Each transaction builds pane-routing ownership once with the same first-match +semantics as the standalone resolver. Split-layout leaf membership is indexed +once per layout root, so a large snapshot performs linear tab and leaf work +instead of rescanning every mounted worktree for every pane. + +### Run effects after the transaction + +Generated-title work that requires committed state is deferred until after the +transaction. Accepted updates also request freshness scheduling; the outer batch +coalesces those requests and schedules the shared freshness timer once after its +single commit. Generated-title requests are folded in event order and published +together, including first-write and forced-replacement semantics. Resolved tab +titles are projected while the transaction folds, then final title changes are +published together. Completion-triggered review refreshes remain deferred +microtasks. + +Bulk title application preserves event order and duplicate-tab behavior while +indexing owners once, cloning each changed owner array once, and replacing each +top-level map once. This keeps the post-commit title phase linear in mounted +tabs plus changed titles. + +This separation is important: invoking store actions from inside a Zustand +updater would re-enter the store, while running an effect before the commit +would let it observe stale state. + +## Semantic invariants + +Sequential and batched application must agree on: + +- live and retained agent maps; +- state history, `updatedAt`, and `stateStartedAt`; +- agent identity, model, prompt, tools, assistant messages, and subagents; +- orchestration and provider-session continuity; +- sleeping-session and launch-config recovery records; +- retired/closed-pane rejection and inherited-status suppression; +- retention cleanup and live-map eviction; +- `agentStatusEpoch` and `sortEpoch`; +- automation completion observation across intermediate transitions; +- generated-title inputs, freshness scheduling, and completion refreshes. + +Equivalence tests use fixed timestamps and include repeated same-pane +transitions. A publication-count test subscribes to the real store and requires +one notification for a non-empty batch and none for an empty batch. + +## Benchmark contract + +The benchmark launches an E2E-mode Electron build with the store exposed only +for instrumentation. It creates 100 worktrees in one expanded lineage, verifies +100 mounted cards, captures the store listener census, and then applies seeded +ordered agent-status traffic through the real store action. + +The benchmark measures the synchronous store action, not the live IPC leading +edge or post-commit notification path. A real-store snapshot test covers the +end-to-end budget for 100 panes with auto-generated titles enabled: one status, +one bulk generated-title, and one bulk resolved-title publication. Disabling +generated titles removes that middle publication, independent of pane count. + +The artifact records only fixed diagnostic fields needed for comparison: + +- requested and completed batches and updates; +- store action calls and observed publications; +- elapsed time, throughput, and scheduling drift; +- final-state verification; +- renderer mean, p95, and maximum CPU; +- renderer timer drift and long tasks; +- mounted-card and listener counts. + +Raw process inventories, temporary paths, pane identifiers, and DOM text are +diagnostic-only and must not be embedded in the shareable report. + +Run baseline and candidate on the same machine and OS with the same Electron +build mode. CPU samples from macOS and Linux are comparable within that +constraint; Windows process CPU collection currently cannot support this +comparison. + +## Harness + +`pnpm run bench:idle-cpu` drives `config/scripts/run-idle-cpu-benchmark.mjs`, +which composes four modules: + +| Module | Responsibility | +| ------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `idle-cpu-renderer-scale-fixture.mjs` | Seeds the lineage, agent rows, and sidebar view state; takes the mounted-card and listener census | +| `idle-cpu-renderer-timing-probe.mjs` | In-page timer drift and long-task probe; runs the no-op publication workload | +| `idle-cpu-process-sampling.mjs` | Classifies the Electron process tree and samples per-role CPU/RSS | +| `idle-cpu-synthetic-spinners.mjs` | Measurement-only visible spinners | + +The sampling window extends past `--sample-ms` until the workload settles, and +fails the run rather than reporting a truncated window if the workload overruns +the guard. That is why a 2,000-publication run reports a measured window longer +than the requested one. + +`--zustand-publications` publishes an empty partial through the real store, so +each publication costs exactly one full subscriber visit and nothing else. It +isolates the `listeners x selector work` half of the burst-cost model from +agent-status payload work, and it is store-API independent — it measures the +same thing before and after the batching slice. + +The agent-status write workload (`--agent-status-batches`, +`--agent-status-write-mode`) is not in this harness yet. It depends on +`setAgentStatuses`, so it lands with the store slice. + +## Baseline on `main` + +Measured on `main` at `077f5a11cd4` (macOS, arm64, 16 CPUs), Electron built with +`electron-vite --mode e2e`, headless, 100 worktrees at lineage depth 99 with 100 +seeded agent rows, 10 s warmup and a 30 s sampling window. + +Fixture scale is confirmed by the census rather than assumed: 100 store +worktrees, 100 mounted cards, 100 mounted agent rows, and **9,279 store +listeners**. That listener count is the multiplier the design targets. + +2,000 no-op store publications at a 1 ms cadence, three repetitions. The +listener census was 9,279 in every run. + +| Measure | Median | Runs | +| ------------------------ | ----------: | ------------------------------ | +| Wall time to complete | 12,325.7 ms | 13,148.6 / 12,325.7 / 11,870.5 | +| p50 scheduling drift | 5,150.3 ms | 5,432.3 / 5,150.3 / 4,945.2 | +| p95 scheduling drift | 9,802.9 ms | 10,570.4 / 9,802.9 / 9,390.0 | +| Renderer mean CPU | 18.25% | 20.25 / 18.25 / 15.88 | +| Renderer p95 CPU | 32.59% | 40.07 / 32.59 / 31.28 | +| Renderer timer drift p95 | 7.0 ms | 7.3 / 5.3 / 7.0 | + +2,000 publications requested over 2 s take about 12 s, so the renderer sustains +roughly 160 publications per second at this scale. Each publication is +individually short - the long-task observer recorded zero entries in all three +runs - so the cost surfaces as scheduling drift and sustained CPU rather than as +discrete long tasks. Compare drift and CPU here, not long-task counts. + +The idle control at the same scale with `--zustand-publications 0` reports 6.63% +renderer mean CPU, 17.11% p95, and 1.6 ms p95 timer drift. Roughly 11.6 points +of mean renderer CPU are therefore attributable to publication fanout rather than +to the mounted fixture itself. 200 spinner animations run in both cases, so the +control also bounds the animation cost out of the comparison. + +Reproduce with: + +```bash +pnpm run bench:idle-cpu -- --worktrees 100 --lineage-depth 99 \ + --agents-per-worktree 1 --warmup-ms 10000 --sample-ms 30000 \ + --zustand-publications 2000 --zustand-publication-interval-ms 1 \ + --output /tmp/idle-cpu-baseline.json +``` + +## Results + +Three repetitions used 100 mounted worktrees, lineage depth 99, 100 seeded +agent rows, and verified final state. Medians from the regenerated evidence set +are: + +| Single 2,000-update burst | Sequential | Batched | +| ------------------------- | ---------: | ---------: | +| Status-state publications | 2,000 | 1 | +| Store action time | 3,692.0 ms | 188.7 ms | +| Update throughput | 541.7/s | 10,598.8/s | +| Renderer mean CPU | 36.2% | 2.9% | +| Renderer p95 CPU | 107.3% | 8.2% | +| p95 long task | 4,653 ms | 216 ms | + +The direct store transaction performs 99.95% fewer status-state publications, +spends 94.9% less time in the store action, and processes updates 19.6 times +faster. Renderer mean CPU falls 92.0%, renderer p95 CPU falls 92.4%, and the p95 +long task falls 95.4%. + +The 60-burst × 32-update case at 33 ms is a sustained saturation stress, not a +real-time production SLO. Publications fall from 1,920 to 60 and median store +action time falls from 2,791.9 ms to 323.3 ms. Median completion time falls from +5,298.2 ms to 2,710.6 ms, p95 scheduling drift falls from 3,073.0 ms to 664.9 +ms, and long-task count falls from 57 to 1. Renderer p95 CPU remains saturated +and noisy in this cadence, so it is not used as the discriminating measure. + +The 20-pane artificial OpenCode regression passes with 12.4 ms median key echo, +25.2 ms worst key echo, 19.4 ms maximum timer drift, and zero dropped renderer +backlogs. + +These figures come from the bundled prototype and are restated here as the +target. They are re-measured with the harness when the store slice lands. + +## Acceptance criteria + +- The 100-worktree fixture stays at or below the pinned listener budgets. +- A deferred transaction performs one status-state publication while preserving + ordered final state, including live-map eviction at the 500-row cap. +- A 100-pane startup snapshot performs one status and one bulk resolved-title + publication with generated titles disabled; enabling generated titles adds at + most one ordered bulk publication while preserving final statuses and titles. +- Sequential-versus-batch equivalence tests pass across same-pane transitions + and side-effect-bearing updates. +- Renderer CPU tails and scheduling drift improve in repeated candidate runs. +- The 20-pane artificial terminal test reports no dropped output backlog and no + material typing-latency regression. +- Web typecheck, focused unit tests, lint, max-lines ratchet, and E2E build pass. + +## Compatibility + +This is renderer-local. It adds no RPC field, stream opcode, persisted data, Git +command, or provider-specific contract. Native, WSL, SSH, relay, folder +workspace, and git-worktree status events enter the same renderer action. Mixed +client/server versions therefore need no capability negotiation. + +## Failure containment + +The first live event remains immediate. Startup replay and bounded pending +retries fold synchronously without waiting for the 33 ms live-burst window, but +publish their accepted updates together. Empty batches are no-ops. If an update +is stale or targets retired authority, the reducer skips only that update and +continues folding later events in order. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ff8360a138d..7db7df7ca69 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -220,6 +220,9 @@ export const electronViteConfig: UserConfig = { 'session-scanner-worker-entry': resolve( 'src/main/ai-vault/session-scanner-worker-entry.ts' ), + 'session-scanner-service-entry': resolve( + 'src/main/ai-vault/session-scanner-service-entry.ts' + ), // Why: libuv spawns processes inline on the calling loop, so the port // scan's probe commands run on a worker thread instead of the UI one. 'port-scan-command-worker-entry': resolve( diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 51dd82305f1..877a6d7e123 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -93,12 +93,6 @@ "max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["app/terminal-settings.tsx"], - "rules": { - "max-lines": ["error", { "max": 514, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["scripts/mock-server.ts"], "rules": { diff --git a/mobile/app.json b/mobile/app.json index e1749d34b7d..34cc8de3be5 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.42", + "version": "0.0.43", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 11 + "versionCode": 12 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 755d6605501..cf0f1dd7edb 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -106,7 +106,7 @@ import type { TerminalModes, TerminalWebViewHandle } from '../../../../src/terminal/terminal-webview-contract' -import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges' +import { isTerminalOscLinkRanges } from '../../../../../src/shared/terminal-osc-link-ranges' import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift' import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit' import { @@ -154,7 +154,7 @@ import { ActionSheetModal } from '../../../../src/components/ActionSheetModal' import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon' import { TextInputModal } from '../../../../src/components/TextInputModal' import { ConfirmModal } from '../../../../src/components/ConfirmModal' -import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor' +import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader' import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments' import { CustomKeyModal, @@ -267,7 +267,6 @@ import { TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND, updateTerminalCwdFromStreamEvent } from '../../../../src/session/mobile-session-route-helpers' -import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout' import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab' import { @@ -275,7 +274,7 @@ import { dismissMobileSessionCreateWarningState, reconcileMobileSessionCreateWarningState } from '../../../../src/session/mobile-session-create-warning-state' -import { colors, spacing } from '../../../../src/theme/mobile-theme' +import { colors } from '../../../../src/theme/mobile-theme' import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton' import { styles } from '../../../../src/session/mobile-session-styles' import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types' @@ -302,132 +301,6 @@ import type { const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 -function MarkdownReader({ - documentId, - doc, - onRefresh, - onChange, - onSave, - onCopy, - onDiscard, - keyboardLift -}: { - documentId: string - doc: MarkdownDocState | undefined - onRefresh: () => void - onChange: (content: string) => void - onSave: () => void - onCopy: () => void - onDiscard: () => void - keyboardLift: number -}) { - // Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset. - const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0) - const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset) - if (!doc || doc.status === 'loading') { - return ( - - - - ) - } - if (doc.status === 'error') { - return ( - - {doc.message} - - - Retry - - - ) - } - - const statusText = doc.saveError - ? doc.saveError - : doc.readOnlyReason - ? 'Read only' - : doc.stale - ? 'Changed on desktop' - : null - const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable - const showCopy = doc.saveError || !doc.editable - const showSave = doc.isDirty || doc.saving - const showFloatingActions = statusText || showRefresh || showCopy || showSave - - return ( - - - {showFloatingActions ? ( - - {statusText ? ( - - {statusText} - - ) : null} - - {showCopy ? ( - - Copy - - ) : null} - {showRefresh ? ( - - - Refresh - - ) : null} - {doc.isDirty ? ( - - Discard - - ) : null} - {showSave ? ( - - {doc.saving ? ( - - ) : ( - Save - )} - - ) : null} - - - ) : null} - - ) -} - function DiffLineRow({ line, title, @@ -4636,7 +4509,7 @@ export default function SessionScreen() { ) : activeMarkdownTab ? ( - void readMarkdownTab(activeMarkdownTab)} diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 677ded5aa78..553490b4748 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -115,13 +115,15 @@ import { import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { triggerMediumImpact } from '../../../src/platform/haptics' import { - type GitHubProjectSortDirection, - type GitHubProjectTable as SharedGitHubProjectTable, groupRows, isIterationCurrent, sortRows, type ProjectGroup -} from '../../../src/tasks/mobile-github-project-group-sort' +} from '../../../../src/shared/github-project-group-sort' +import type { + GitHubProjectSortDirection, + GitHubProjectTable as SharedGitHubProjectTable +} from '../../../../src/shared/github-project-types' import { CROSS_REPO_DISPLAY_LIMIT, isGitHubWorkItemsSshRemoteRequiredError, diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index e55819aa22a..860f906e77b 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { View, Text, StyleSheet, Pressable, Switch } from 'react-native' +import { View, Text, Pressable, Switch } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureHandlerRootView } from 'react-native-gesture-handler' import Animated, { @@ -9,7 +9,7 @@ import Animated, { } from 'react-native-reanimated' import { useRouter } from 'expo-router' import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { colors, spacing } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' @@ -17,6 +17,7 @@ import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state' +import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles' import { loadTerminalAutocompleteEnabled, loadTerminalTextScale, @@ -380,92 +381,3 @@ export default function TerminalSettingsScreen() { ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.lg, - paddingTop: 0 - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.sm, - marginBottom: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - scrollContent: { - paddingBottom: spacing.xl - }, - groupHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - groupDescription: { - fontSize: typography.bodySize - 1, - color: colors.textSecondary, - lineHeight: 20, - paddingHorizontal: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden' - }, - sectionTopGap: { - marginTop: spacing.sm - }, - inputGroupGap: { - marginTop: spacing.xl - }, - emptyText: { - fontSize: typography.bodySize, - color: colors.textSecondary, - padding: spacing.md - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowContent: { - flex: 1 - }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowSublabel: { - fontSize: typography.bodySize - 2, - color: colors.textSecondary, - marginTop: 2 - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - } -}) diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile index 45819c55ffd..204c14672b6 100644 --- a/mobile/fastlane/Fastfile +++ b/mobile/fastlane/Fastfile @@ -2,8 +2,9 @@ # # Builds the prebuilt iOS workspace, signs it with the distribution identity # imported into the CI keychain plus an explicit App Store provisioning profile -# fetched via the App Store Connect API key, then uploads the .ipa to -# TestFlight. All Apple credentials come from CI env vars +# fetched via the App Store Connect API key, uploads the .ipa, then distributes +# the processed build to external TestFlight testers. Credentials come from CI +# env vars # (see .github/workflows/mobile-ios-release.yml) so nothing secret lives in the # repo. # @@ -28,6 +29,9 @@ WORKSPACE = File.join(MOBILE_ROOT, "ios", "Orca.xcworkspace") BUILD_OUTPUT_DIRECTORY = File.join(MOBILE_ROOT, "build") SCHEME = "Orca" BUNDLE_ID = "com.stably.orca.mobile" +TESTFLIGHT_GROUPS = ["peeps"].freeze +DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze +TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS = 20 * 60 # App Store version states in which the version "train" is terminally closed to # new TestFlight build uploads (altool rejects with 90186 "train ... is @@ -72,6 +76,11 @@ def current_mobile_version(config) config.fetch("expo").fetch("version") end +def testflight_changelog + changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip + changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog +end + # True when either state field reports a terminally closed train. `respond_to?` # guards the newer field, which older spaceship versions do not define. def closed_state?(app_store_version) @@ -172,7 +181,7 @@ platform :ios do end desc "Build, sign, and upload Orca Mobile to App Store Connect / TestFlight" - lane :release do + lane :build_and_upload do api_key = app_store_connect_api_key_from_env team_id = ENV.fetch("APPLE_TEAM_ID") @@ -214,13 +223,59 @@ platform :ios do upload_to_testflight( api_key: api_key, - # Why: ASC processing wait hung for hours (0.0.42 builds 1–2) with no Ready - # build and no email. Exit after upload acceptance. Do not pass changelog — - # Pilot only fully skips waiting when changelog is nil (otherwise it still - # polls until the build appears). External distribute (e.g. peeps) from ASC - # or a follow-up lane once Ready. + # Distribution runs separately so a processing timeout can be retried + # against this exact build without uploading another binary. skip_waiting_for_build_processing: true, distribute_external: false, ) end + + desc "Wait for and distribute an uploaded build to external TestFlight testers" + lane :distribute_testflight do |options| + version = options[:version].to_s.strip + build_number = options[:build_number].to_s.strip + UI.user_error!("version is required") if version.empty? + UI.user_error!("build_number is required") if build_number.empty? + + api_key = app_store_connect_api_key_from_env + app = Spaceship::ConnectAPI::App.find(BUNDLE_ID) + UI.user_error!("Could not find App Store Connect app #{BUNDLE_ID}") unless app + + available_groups = app.get_beta_groups.map(&:name) + missing_groups = TESTFLIGHT_GROUPS - available_groups + unless missing_groups.empty? + UI.user_error!( + "Missing TestFlight group(s): #{missing_groups.join(', ')}. " \ + "Create them in App Store Connect or update TESTFLIGHT_GROUPS.", + ) + end + + upload_to_testflight( + api_key: api_key, + app_identifier: BUNDLE_ID, + # distribute_only means there is no local .ipa to sniff, so fastlane would + # otherwise fall through to an interactive platform prompt and crash in CI. + app_platform: "ios", + app_version: version, + build_number: build_number, + distribute_only: true, + skip_waiting_for_build_processing: false, + wait_processing_timeout_duration: TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS, + distribute_external: true, + groups: TESTFLIGHT_GROUPS, + notify_external_testers: true, + changelog: testflight_changelog, + ) + end + + desc "Build, upload, and distribute Orca Mobile to external TestFlight testers" + lane :release do + build_and_upload + + config = load_mobile_app_config + distribute_testflight( + version: current_mobile_version(config), + build_number: config.fetch("expo").fetch("ios").fetch("buildNumber"), + ) + end end diff --git a/mobile/src/accounts-route-reset-credit.test.ts b/mobile/src/accounts-route-reset-credit.test.ts index 76be8071930..2de1e96881d 100644 --- a/mobile/src/accounts-route-reset-credit.test.ts +++ b/mobile/src/accounts-route-reset-credit.test.ts @@ -157,28 +157,12 @@ const RESET_SNAPSHOT = { } } as const -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - async function renderAccountsRoute(): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(AccountsScreen)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(AccountsScreen)) + await Promise.resolve() + }) if (!renderer) { throw new Error('Accounts route did not render') } @@ -229,7 +213,6 @@ describe('accounts route Codex reset credit', () => { let storedValues: Map beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true resetCodexResetAttemptJournalForTests() storedValues = new Map() dependencies.alert.mockReset() diff --git a/mobile/src/components/CodexResetCreditAction.test.ts b/mobile/src/components/CodexResetCreditAction.test.ts index 25e39591701..0c1a6ea57b4 100644 --- a/mobile/src/components/CodexResetCreditAction.test.ts +++ b/mobile/src/components/CodexResetCreditAction.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { CodexResetCreditAction } from './CodexResetCreditAction' vi.mock('react-native', () => ({ @@ -19,35 +19,19 @@ const summary = { expiryLabel: 'Expires in 5d' } -function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() -} - function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer { let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - act(() => { - renderer = create( - createElement(CodexResetCreditAction, { - summary, - scopeLabel: 'dev@example.com on the host', - busy, - disabled, - onPress: vi.fn() - }) - ) - }) - } finally { - restore() - } + act(() => { + renderer = create( + createElement(CodexResetCreditAction, { + summary, + scopeLabel: 'dev@example.com on the host', + busy, + disabled, + onPress: vi.fn() + }) + ) + }) if (!renderer) { throw new Error('Reset action did not render') } @@ -55,10 +39,6 @@ function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer { } describe('CodexResetCreditAction', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - it('exposes a 44pt touch target and enabled accessibility state', () => { const renderer = renderAction(false, false) const button = renderer.root.findByType('Pressable') diff --git a/mobile/src/components/HostProtocolGate.test.ts b/mobile/src/components/HostProtocolGate.test.ts index 94cae6d9170..44a2265ccb3 100644 --- a/mobile/src/components/HostProtocolGate.test.ts +++ b/mobile/src/components/HostProtocolGate.test.ts @@ -78,7 +78,6 @@ describe('HostProtocolGate', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true nativeTestState.openUrl.mockClear() nativeTestState.platform.OS = 'ios' probeMounts.count = 0 diff --git a/mobile/src/components/MobileHomeQuickActions.test.ts b/mobile/src/components/MobileHomeQuickActions.test.ts index ba2e0ba750f..e71e1d8ff83 100644 --- a/mobile/src/components/MobileHomeQuickActions.test.ts +++ b/mobile/src/components/MobileHomeQuickActions.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { HostProfile } from '../transport/types' import { MobileHomeQuickActions } from './MobileHomeQuickActions' @@ -37,10 +37,6 @@ function host(id: string, name: string, endpoint: string): HostProfile { describe('MobileHomeQuickActions', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/MobileHostCard.test.ts b/mobile/src/components/MobileHostCard.test.ts index f7c494cfdbf..f4033e67112 100644 --- a/mobile/src/components/MobileHostCard.test.ts +++ b/mobile/src/components/MobileHostCard.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileHostCard } from './MobileHostCard' vi.mock('react-native', () => ({ @@ -30,10 +30,6 @@ function suppressRendererDeprecation() { describe('MobileHostCard', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx index 66e978a14ce..a921571555c 100644 --- a/mobile/src/components/MobileHostCard.test.tsx +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ConnectionVerdict } from '../transport/connection-health' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types' @@ -39,10 +39,6 @@ const loaded: HostWorktreeInfo = { describe('MobileHostCard', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts index a8e381865d8..962538a8842 100644 --- a/mobile/src/components/MobileMarkdown.file-links.test.ts +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -38,7 +38,6 @@ describe('MobileMarkdown file links', () => { const onOpenFile = vi.fn() beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true onOpenFile.mockClear() openURL.mockClear() }) diff --git a/mobile/src/components/MobilePrBasePicker.tsx b/mobile/src/components/MobilePrBasePicker.tsx deleted file mode 100644 index be9f92d65fc..00000000000 --- a/mobile/src/components/MobilePrBasePicker.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' -import { Check, ChevronDown } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import type { RpcClient } from '../transport/rpc-client' -import { searchBaseRefs } from '../source-control/mobile-base-ref-search' - -type Props = { - client: RpcClient | null - worktreeId: string - value: string - onChange: (ref: string) => void - editable?: boolean -} - -// Base-branch field for the create-PR composer: a free-text input that also searches -// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's -// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still -// be entered. -export function MobilePrBasePicker({ - client, - worktreeId, - value, - onChange, - editable = true -}: Props) { - const [results, setResults] = useState([]) - const [focused, setFocused] = useState(false) - const timer = useRef | null>(null) - // Guards: drop results after unmount, and ignore an earlier search whose response - // arrives after a later one (out-of-order network) so stale matches can't clobber. - const mounted = useRef(true) - const seq = useRef(0) - - useEffect(() => { - return () => { - mounted.current = false - if (timer.current) { - clearTimeout(timer.current) - } - } - }, []) - - const queryRefs = useCallback( - (query: string) => { - if (timer.current) { - clearTimeout(timer.current) - } - if (!client || query.trim().length === 0) { - // Advance the generation so an earlier in-flight search can't land and - // repopulate results after the input was cleared. - seq.current += 1 - setResults([]) - return - } - timer.current = setTimeout(() => { - const requestSeq = ++seq.current - void searchBaseRefs(client, worktreeId, query.trim()) - .then((refs) => { - if (!mounted.current || requestSeq !== seq.current) { - return - } - setResults(refs.filter((r) => r !== query).slice(0, 6)) - }) - // Why: a rejected ref search must not escape as an unhandled rejection; - // drop to an empty result set (free text stays valid to submit). - .catch(() => { - if (mounted.current && requestSeq === seq.current) { - setResults([]) - } - }) - }, 200) - }, - [client, worktreeId] - ) - - return ( - - - { - onChange(text) - queryRefs(text) - }} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - placeholder="main" - placeholderTextColor={colors.textMuted} - autoCapitalize="none" - autoCorrect={false} - editable={editable} - /> - - - {focused && results.length > 0 ? ( - - {results.map((ref) => ( - [styles.resultRow, pressed && styles.resultRowPressed]} - onPress={() => { - onChange(ref) - setResults([]) - }} - > - - {ref} - - {ref === value ? ( - - ) : null} - - ))} - - ) : null} - - ) -} - -const styles = StyleSheet.create({ - inputShell: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs - }, - inputShellDisabled: { - opacity: 0.6 - }, - input: { - flex: 1, - minWidth: 0, - padding: 0, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontFamily: typography.monoFamily - }, - results: { - marginTop: spacing.xs, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - backgroundColor: colors.bgPanel, - overflow: 'hidden' - }, - resultRow: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - paddingHorizontal: spacing.md, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle - }, - resultRowPressed: { backgroundColor: colors.bgRaised }, - resultText: { - flex: 1, - minWidth: 0, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontFamily: typography.monoFamily - } -}) diff --git a/mobile/src/components/MobilePrComposeSheet.tsx b/mobile/src/components/MobilePrComposeSheet.tsx deleted file mode 100644 index 124f9192256..00000000000 --- a/mobile/src/components/MobilePrComposeSheet.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Linking } from 'react-native' -import { BottomDrawer } from './BottomDrawer' -import { MobilePrComposeForm, type PrComposePrefill } from './pr-sidebar/MobilePrComposeForm' -import type { RpcClient } from '../transport/rpc-client' - -type Props = { - visible: boolean - client: RpcClient | null - worktreeId: string - prefill: PrComposePrefill - // Head branch — enables the base≠head guard and the "from " hint. - head?: string | null - onClose: () => void - onCreated: (url: string, warning?: string) => void -} - -// BottomDrawer wrapper around the inline compose form, for full-screen roots -// (source-control modals). The PR sidebar empty-state renders MobilePrComposeForm -// inline instead, since a BottomDrawer overlay nested in a ScrollView clips it. -export function MobilePrComposeSheet({ - visible, - client, - worktreeId, - prefill, - head, - onClose, - onCreated -}: Props) { - return ( - - {/* Why: key on visible + prefill fields so reopening (or a new prefill) - remounts the form with fresh initial values; the previous sheet reset - its fields via an effect on the same signals. */} - - - ) -} - -export function openMobilePrUrl(url: string): void { - // Why: Linking.openURL rejects when iOS/Android can't open the URL (no app, - // bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught - // (in promise) Error: Unable to open URL…" over the PR screen. - void Linking.openURL(url).catch(() => {}) -} diff --git a/mobile/src/components/MobileRichMarkdownEditor.test.tsx b/mobile/src/components/MobileRichMarkdownEditor.test.tsx new file mode 100644 index 00000000000..20b17098d14 --- /dev/null +++ b/mobile/src/components/MobileRichMarkdownEditor.test.tsx @@ -0,0 +1,84 @@ +import { createElement, createRef, forwardRef, useImperativeHandle } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MobileRichMarkdownEditor, + type MobileRichMarkdownEditorHandle +} from './MobileRichMarkdownEditor' + +const mocks = vi.hoisted(() => ({ + dismissKeyboard: vi.fn(), + injectJavaScript: vi.fn() +})) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + Keyboard: { dismiss: mocks.dismissKeyboard }, + Linking: { openURL: vi.fn() }, + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + View: 'View' + } +}) + +vi.mock('react-native-webview', () => { + const WebView = forwardRef((props: Record, ref) => { + useImperativeHandle(ref, () => ({ injectJavaScript: mocks.injectJavaScript })) + return createElement('WebView', props) + }) + return { WebView, default: WebView } +}) + +vi.mock('lucide-react-native', () => ({ + Bold: 'Bold', + ChevronDown: 'ChevronDown', + Code2: 'Code2', + FileCode2: 'FileCode2', + Heading1: 'Heading1', + Heading2: 'Heading2', + Heading3: 'Heading3', + ImageIcon: 'ImageIcon', + Italic: 'Italic', + Keyboard: 'Keyboard', + Link: 'Link', + List: 'List', + ListOrdered: 'ListOrdered', + ListTodo: 'ListTodo', + Pilcrow: 'Pilcrow', + Quote: 'Quote', + Strikethrough: 'Strikethrough' +})) + +describe('MobileRichMarkdownEditor', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.clearAllMocks() + }) + + it('exposes WebView keyboard dismissal to its native parent', () => { + const editorRef = createRef() + act(() => { + renderer = create( + createElement(MobileRichMarkdownEditor, { + ref: editorRef, + content: '', + editable: true, + onChange: vi.fn() + }) + ) + }) + + act(() => editorRef.current?.dismissKeyboard()) + + expect(mocks.injectJavaScript).toHaveBeenCalledWith( + expect.stringContaining('window.__orcaRichMarkdown.dismissKeyboard()') + ) + expect(mocks.dismissKeyboard).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx index c9e0b44e082..8fbcaa149e7 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.tsx @@ -1,5 +1,15 @@ -import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react' -import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native' +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + type ComponentType, + type ForwardedRef +} from 'react' +import { Keyboard, Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native' import { Bold, Code2, @@ -77,6 +87,10 @@ type Props = { onKeyboardInsetChange?: (bottom: number) => void } +export type MobileRichMarkdownEditorHandle = { + dismissKeyboard: () => void +} + type EditorWebViewMessage = | { type: 'ready' } | { type: 'change'; markdown: string; generation: number } @@ -107,12 +121,10 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [ { command: 'codeBlock', label: 'Code block', icon: FileCode2 } ] -function MobileRichMarkdownEditorInner({ - content, - editable, - onChange, - onKeyboardInsetChange -}: Props) { +function MobileRichMarkdownEditorInner( + { content, editable, onChange, onKeyboardInsetChange }: Props, + ref: ForwardedRef +) { const webViewRef = useRef(null) const readyRef = useRef(false) const documentGenerationRef = useRef(0) @@ -227,6 +239,15 @@ function MobileRichMarkdownEditorInner({ [inject] ) + const dismissKeyboard = useCallback(() => { + // Why: the caret lives in the WebView, so the injected blur is what closes the keyboard; + // Keyboard.dismiss only clears a native TextInput that stole focus first. + inject('window.__orcaRichMarkdown && window.__orcaRichMarkdown.dismissKeyboard();') + Keyboard.dismiss() + }, [inject]) + + useImperativeHandle(ref, () => ({ dismissKeyboard }), [dismissKeyboard]) + return ( @@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({ ) } -export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner) +export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner)) const styles = StyleSheet.create({ container: { diff --git a/mobile/src/components/MobileSearchField.tsx b/mobile/src/components/MobileSearchField.tsx index 2dfd6bf26e4..cbeab92c208 100644 --- a/mobile/src/components/MobileSearchField.tsx +++ b/mobile/src/components/MobileSearchField.tsx @@ -149,8 +149,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - // Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious - // control, matching TextInputModal / MobilePrBasePicker input shells. + // Keep search visually distinct from surrounding panels. backgroundColor: colors.bgRaised, borderWidth: 1, borderColor: colors.borderSubtle, diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 3fb76dd3122..4d8e95bec89 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -58,7 +58,6 @@ describe('NewWorktreeModal repo list', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true setCachedRepos('host-1', repos) }) diff --git a/mobile/src/components/PickerModal.accessibility.test.ts b/mobile/src/components/PickerModal.accessibility.test.ts index 4508f227554..46610a6e631 100644 --- a/mobile/src/components/PickerModal.accessibility.test.ts +++ b/mobile/src/components/PickerModal.accessibility.test.ts @@ -24,7 +24,6 @@ describe('PickerModal accessibility', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true 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])) diff --git a/mobile/src/components/WorktreeAgentList.test.tsx b/mobile/src/components/WorktreeAgentList.test.tsx index 036f5d35996..aef5161cb4c 100644 --- a/mobile/src/components/WorktreeAgentList.test.tsx +++ b/mobile/src/components/WorktreeAgentList.test.tsx @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { WorktreeAgentList } from './WorktreeAgentList' @@ -39,10 +39,6 @@ function agent(paneKey: string, parentPaneKey: string | null = null): RuntimeWor describe('WorktreeAgentList', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts index 9fe0aa11b02..d0825412636 100644 --- a/mobile/src/components/WorktreeListRow.test.ts +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -127,7 +127,6 @@ describe('memoized worktree rows', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true agentSpinnerRender.mockClear() agentStateDotRender.mockClear() }) diff --git a/mobile/src/components/bottom-drawer-close-lifecycle.test.ts b/mobile/src/components/bottom-drawer-close-lifecycle.test.ts index b4362412db7..94eb8a8495c 100644 --- a/mobile/src/components/bottom-drawer-close-lifecycle.test.ts +++ b/mobile/src/components/bottom-drawer-close-lifecycle.test.ts @@ -51,14 +51,12 @@ function mountedDrawer(renderer: ReactTestRenderer) { describe('BottomDrawer close lifecycle', () => { beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true const originalConsoleError = console.error vi.spyOn(console, 'error').mockImplementation((...args) => { const message = args[0] if ( typeof message === 'string' && - (message.includes('react-test-renderer is deprecated') || - message.includes('The current testing environment is not configured to support act')) + message.includes('The current testing environment is not configured to support act') ) { return } diff --git a/mobile/src/components/mobile-agent-icon-gradient.test.ts b/mobile/src/components/mobile-agent-icon-gradient.test.ts index 8e2781edfb2..c2221b62771 100644 --- a/mobile/src/components/mobile-agent-icon-gradient.test.ts +++ b/mobile/src/components/mobile-agent-icon-gradient.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileAgentIcon } from './MobileAgentIcon' vi.mock('react-native', () => ({ @@ -35,10 +35,6 @@ vi.mock('./AgentIcons', () => ({ describe('MobileAgentIcon OMP gradient', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/mobile-markdown-preview-html.ts b/mobile/src/components/mobile-markdown-preview-html.ts index 8b2d247f8f2..8bb5fca0934 100644 --- a/mobile/src/components/mobile-markdown-preview-html.ts +++ b/mobile/src/components/mobile-markdown-preview-html.ts @@ -97,7 +97,7 @@ function attrValue(tag: string, name: string): string { const valueStart = cursor if (quote) { const valueEnd = tag.indexOf(quote, cursor) - if (valueEnd < 0) { + if (valueEnd === -1) { return '' } cursor = valueEnd + 1 diff --git a/mobile/src/components/mobile-markdown-preview-tag-stripper.ts b/mobile/src/components/mobile-markdown-preview-tag-stripper.ts index 90e3c3cc3cc..6eeca2aa612 100644 --- a/mobile/src/components/mobile-markdown-preview-tag-stripper.ts +++ b/mobile/src/components/mobile-markdown-preview-tag-stripper.ts @@ -191,7 +191,7 @@ function closingMarkupTagNames(value: string): Set { let cursor = 0 while (cursor < value.length) { const start = value.indexOf('<', cursor) - if (start < 0) { + if (start === -1) { return names } const end = findMobileMarkdownMarkupTagEnd(value, start + 1) @@ -228,7 +228,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string { const closingTagNames = closingMarkupTagNames(value) while (cursor < value.length) { const start = value.indexOf('<', cursor) - if (start < 0) { + if (start === -1) { return output + value.slice(cursor) } output += value.slice(cursor, start) @@ -261,7 +261,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string { !/[-:]/.test(name) && !knownMarkupTagNames.has(lowerName) && !closingTagNames.has(lowerName) - if (isNestedGeneric && nestedStart >= 0) { + if (isNestedGeneric && nestedStart !== -1) { output += value.slice(start, nestedStart) cursor = nestedStart continue diff --git a/mobile/src/components/mobile-pr-url.ts b/mobile/src/components/mobile-pr-url.ts new file mode 100644 index 00000000000..3f642d5ccf6 --- /dev/null +++ b/mobile/src/components/mobile-pr-url.ts @@ -0,0 +1,6 @@ +import { Linking } from 'react-native' + +export function openMobilePrUrl(url: string): void { + // Prevent unhandled rejections when no app accepts the URL. + void Linking.openURL(url).catch(() => {}) +} diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.test.ts b/mobile/src/components/mobile-rich-markdown-editor-html.test.ts index f21674478b1..b8321c7a2f2 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.test.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.test.ts @@ -11,9 +11,7 @@ function editorScript(): string { return script ?? '' } -function extractFunctionSource(script: string, name: string): string { - const start = script.indexOf(`function ${name}`) - expect(start).toBeGreaterThanOrEqual(0) +function extractBracedSource(script: string, start: number, label: string): string { const bodyStart = script.indexOf('{', start) let depth = 0 for (let index = bodyStart; index < script.length; index += 1) { @@ -28,7 +26,19 @@ function extractFunctionSource(script: string, name: string): string { } } } - throw new Error(`Could not extract ${name}`) + throw new Error(`Could not extract ${label}`) +} + +function extractFunctionSource(script: string, name: string): string { + const start = script.indexOf(`function ${name}`) + expect(start).toBeGreaterThanOrEqual(0) + return extractBracedSource(script, start, name) +} + +function extractEditorListenerSource(script: string, type: string): string { + const start = script.indexOf(`editor.addEventListener('${type}'`) + expect(start).toBeGreaterThanOrEqual(0) + return `${extractBracedSource(script, start, `${type} listener`)});` } function runtimeMarkdownToHtml(markdown: string, editable: boolean): string { @@ -65,6 +75,134 @@ function runtimeListMarkdown(): (list: unknown) => string { return new Function(sources)() as (list: unknown) => string } +type FakeRange = { + commonAncestorContainer: unknown + cloneRange: () => FakeRange + selectNodeContents: (node: unknown) => void + collapse: (toStart: boolean) => void +} + +function createFakeRange(container: unknown): FakeRange { + const range: FakeRange = { + commonAncestorContainer: container, + cloneRange: () => createFakeRange(range.commonAncestorContainer), + selectNodeContents: (node) => { + range.commonAncestorContainer = node + }, + collapse: () => {} + } + return range +} + +type FakeClickEvent = { + clientX: number + clientY: number + target: { closest: (selector: string) => unknown } + preventDefault: () => void +} + +// Drives the editor's real selection and click handling against a stub DOM whose blur +// drops the selection, the way WebKit does. +function createSelectionRuntime(caretContainer: string | null) { + const liveNodes = new Set(caretContainer ? [caretContainer] : []) + const listeners = new Map void>() + let focused = caretContainer != null + let ranges: FakeRange[] = caretContainer ? [createFakeRange(caretContainer)] : [] + let caretAtPoint: string | null = null + + const editor = { + contains: (node: unknown) => typeof node === 'string' && liveNodes.has(node), + focus: () => { + focused = true + fakeDocument.activeElement = editor + }, + blur: () => { + focused = false + fakeDocument.activeElement = null + ranges = [] + }, + addEventListener: (type: string, handler: (event: FakeClickEvent) => void) => { + listeners.set(type, handler) + } + } + const fakeDocument: { + activeElement: unknown + createRange: () => FakeRange + caretRangeFromPoint: () => FakeRange | null + } = { + activeElement: focused ? editor : null, + createRange: () => createFakeRange('detached'), + caretRangeFromPoint: () => (caretAtPoint ? createFakeRange(caretAtPoint) : null) + } + const fakeWindow = { + getSelection: () => ({ + get rangeCount() { + return ranges.length + }, + getRangeAt: (index: number) => ranges[index], + removeAllRanges: () => { + ranges = [] + }, + addRange: (range: FakeRange) => { + ranges = [range] + } + }) + } + + const script = editorScript() + const sources = [ + 'var editor = arguments[0];', + 'var document = arguments[1];', + 'var window = arguments[2];', + 'var editable = true;', + 'var savedSelectionRange = null;', + 'var selectionDroppedOnBlur = false;', + 'function post() {}', + 'function emitChange() {}', + extractFunctionSource(script, 'focusEditor'), + extractFunctionSource(script, 'rememberSelection'), + extractFunctionSource(script, 'applySelectionRange'), + extractFunctionSource(script, 'caretRangeAtPoint'), + extractFunctionSource(script, 'dismissKeyboard'), + extractFunctionSource(script, 'restoreSelectionOrEnd'), + extractEditorListenerSource(script, 'click'), + 'return { dismissKeyboard: dismissKeyboard, restoreSelectionOrEnd: restoreSelectionOrEnd };' + ].join('\n') + const api = new Function(sources)(editor, fakeDocument, fakeWindow) as { + dismissKeyboard: () => void + restoreSelectionOrEnd: () => void + } + + return { + dismissKeyboard: api.dismissKeyboard, + restoreSelectionOrEnd: api.restoreSelectionOrEnd, + tapAt: (container: string, options?: { uneditableAncestor?: string }) => { + liveNodes.add(container) + caretAtPoint = container + listeners.get('click')?.({ + clientX: 12, + clientY: 34, + target: { + closest: (selector: string) => + selector === '[contenteditable="false"]' ? (options?.uneditableAncestor ?? null) : null + }, + preventDefault: () => {} + }) + }, + detachEditorContent: () => liveNodes.clear(), + selectedContainer: () => { + if (ranges.length === 0) { + return null + } + const container = ranges[0].commonAncestorContainer + return container === editor ? 'editor-end' : (container as string) + }, + get focused() { + return focused + } + } +} + describe('mobile rich markdown editor HTML', () => { it('builds parseable WebView JavaScript', () => { const script = editorScript() @@ -233,4 +371,64 @@ describe('mobile rich markdown editor HTML', () => { expect(emitChange).not.toContain('window.setTimeout') expect(emitChange).toContain('generation: pendingGeneration') }) + + it('exposes a keyboard dismissal command that blurs WebView focus', () => { + const script = editorScript() + const dismissKeyboard = extractFunctionSource(script, 'dismissKeyboard') + + expect(dismissKeyboard).toContain('rememberSelection()') + expect(dismissKeyboard).toContain('document.activeElement.blur()') + expect(dismissKeyboard).toContain('editor.blur()') + expect(script).toContain('dismissKeyboard: dismissKeyboard') + }) + + it('reclaims editor focus at the tapped caret after keyboard dismissal', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + runtime.tapAt('paragraph-7') + + expect(runtime.focused).toBe(true) + expect(runtime.selectedContainer()).toBe('paragraph-7') + }) + + it('leaves task-list label taps to the checkbox instead of refocusing the editor', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + runtime.tapAt('paragraph-7', { uneditableAncestor: 'task-label' }) + + expect(runtime.focused).toBe(false) + expect(runtime.selectedContainer()).toBe(null) + }) + + it('restores the pre-dismissal caret so commands do not insert at the document end', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + expect(runtime.selectedContainer()).toBe(null) + + runtime.restoreSelectionOrEnd() + + expect(runtime.selectedContainer()).toBe('paragraph-3') + expect(runtime.focused).toBe(true) + }) + + it('falls back to the document end when no caret was ever placed', () => { + const runtime = createSelectionRuntime(null) + + runtime.restoreSelectionOrEnd() + + expect(runtime.selectedContainer()).toBe('editor-end') + }) + + it('drops a remembered caret whose nodes left the document', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + runtime.detachEditorContent() + runtime.restoreSelectionOrEnd() + + expect(runtime.selectedContainer()).toBe('editor-end') + }) }) diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index 32fc12b59b2..b7ff1649075 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -1,5 +1,7 @@ import { colors } from '../theme/mobile-theme' +import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script' import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' +import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script' export function escapeInjectedJavaScriptString(value: string): string { return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') @@ -559,6 +561,9 @@ export function buildMobileRichMarkdownEditorHtml(): string { window.clearTimeout(inputTimer); documentGeneration = Number(generation) || 0; suppressInput = true; + // Why: replacing innerHTML detaches the remembered caret's nodes. + savedSelectionRange = null; + selectionDroppedOnBlur = false; lastMarkdown = String(markdown || ''); editor.innerHTML = markdownToHtml(lastMarkdown); syncTaskCheckboxesDisabled(); @@ -571,39 +576,8 @@ export function buildMobileRichMarkdownEditorHtml(): string { syncTaskCheckboxesDisabled(); } - function focusEditor() { - editor.focus(); - } - - function restoreSelectionOrEnd() { - focusEditor(); - var selection = window.getSelection(); - if (!selection || selection.rangeCount > 0) return; - var range = document.createRange(); - range.selectNodeContents(editor); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } - - function wrapSelection(tagName) { - restoreSelectionOrEnd(); - var selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return; - var range = selection.getRangeAt(0); - if (range.collapsed) return; - var wrapper = document.createElement(tagName); - try { - range.surroundContents(wrapper); - } catch (_error) { - wrapper.appendChild(range.extractContents()); - range.insertNode(wrapper); - } - selection.removeAllRanges(); - selection.selectAllChildren(wrapper); - emitChange(); - } - +${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT} +${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT} function runCommand(command) { if (!editable || editor.getAttribute('contenteditable') !== 'true') return; restoreSelectionOrEnd(); @@ -632,6 +606,7 @@ export function buildMobileRichMarkdownEditorHtml(): string { } editor.addEventListener('input', function () { + selectionDroppedOnBlur = false; if (editable) emitChange(); }); editor.addEventListener('change', function (event) { @@ -654,7 +629,19 @@ export function buildMobileRichMarkdownEditorHtml(): string { return; } var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]'); - if (!input) return; + if (!input) { + if (!editable) return; + // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard. + var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]'); + if (uneditable && uneditable !== editor) return; + selectionDroppedOnBlur = false; + if (document.activeElement === editor) return; + // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped. + var caret = caretRangeAtPoint(event.clientX, event.clientY); + focusEditor(); + if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret); + return; + } if (!editable) { event.preventDefault(); return; @@ -670,7 +657,7 @@ export function buildMobileRichMarkdownEditorHtml(): string { } }); - window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown }; + window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard }; ${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT} post({ type: 'ready' }); })(); diff --git a/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts b/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts new file mode 100644 index 00000000000..a4520ce4261 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts @@ -0,0 +1,13 @@ +// Composed into the editor document after MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT, whose +// rememberSelection/selectionDroppedOnBlur this depends on. +export const MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT = ` + function dismissKeyboard() { + // Why: WebKit discards the DOM selection on blur, so capture the caret before it goes. + rememberSelection(); + selectionDroppedOnBlur = true; + if (document.activeElement && document.activeElement.blur) { + document.activeElement.blur(); + } + editor.blur(); + } +` diff --git a/mobile/src/components/mobile-rich-markdown-selection-script.ts b/mobile/src/components/mobile-rich-markdown-selection-script.ts new file mode 100644 index 00000000000..c649bd09ed8 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-selection-script.ts @@ -0,0 +1,71 @@ +// Caret and selection management for the WebView editor. Split out so the editor +// document script stays inside its line budget. +export const MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT = ` + var savedSelectionRange = null; + var selectionDroppedOnBlur = false; + + function focusEditor() { + editor.focus(); + } + + function rememberSelection() { + var selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + var range = selection.getRangeAt(0); + if (editor.contains(range.commonAncestorContainer)) savedSelectionRange = range.cloneRange(); + } + + function applySelectionRange(range) { + var selection = window.getSelection(); + if (!selection) return; + selection.removeAllRanges(); + selection.addRange(range); + savedSelectionRange = range.cloneRange(); + } + + function caretRangeAtPoint(x, y) { + if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y); + if (!document.caretPositionFromPoint) return null; + var position = document.caretPositionFromPoint(x, y); + if (!position) return null; + var range = document.createRange(); + range.setStart(position.offsetNode, position.offset); + range.collapse(true); + return range; + } + + function restoreSelectionOrEnd() { + focusEditor(); + var selection = window.getSelection(); + if (!selection) return; + // Why: the blur dropped the live selection, so commands would otherwise insert at the document end. + if (selectionDroppedOnBlur && savedSelectionRange && editor.contains(savedSelectionRange.commonAncestorContainer)) { + selectionDroppedOnBlur = false; + applySelectionRange(savedSelectionRange); + return; + } + if (selection.rangeCount > 0) return; + var range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + applySelectionRange(range); + } + + function wrapSelection(tagName) { + restoreSelectionOrEnd(); + var selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + var range = selection.getRangeAt(0); + if (range.collapsed) return; + var wrapper = document.createElement(tagName); + try { + range.surroundContents(wrapper); + } catch (_error) { + wrapper.appendChild(range.extractContents()); + range.insertNode(wrapper); + } + selection.removeAllRanges(); + selection.selectAllChildren(wrapper); + emitChange(); + } +` diff --git a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx b/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx deleted file mode 100644 index 618e399d1a0..00000000000 --- a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { useCallback, useState } from 'react' -import { ActivityIndicator, Pressable, Switch, Text, TextInput, View } from 'react-native' -import { - ArrowRight, - GitMerge, - GitPullRequestArrow, - Sparkles, - TriangleAlert, - X -} from 'lucide-react-native' -import { colors } from '../../theme/mobile-theme' -import type { RpcClient } from '../../transport/rpc-client' -import type { RpcSuccess } from '../../transport/types' -import { triggerError, triggerSuccess } from '../../platform/haptics' -import { - createMobilePr, - getMobilePrCreateSuccessWarning, - shouldPushBeforeMobilePrCreate, - type MobilePrPrefill -} from '../../source-control/mobile-pr-create' -import { hostedReviewCopy } from '../../source-control/hosted-review-copy' -import { - getPrComposeDisabledReason, - isBaseHeadDistinct -} from '../../source-control/pr-compose-validation' -import { MobilePrBasePicker } from '../MobilePrBasePicker' -import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles' - -export type PrComposePrefill = MobilePrPrefill - -type Props = { - client: RpcClient | null - worktreeId: string - prefill: PrComposePrefill - // Head branch — enables the base≠head guard and the "from " hint. - head?: string | null - onCancel: () => void - onCreated: (url: string, warning?: string) => void -} - -// PR compose form body: title/body/base/draft with AI prefill (git.generate -// PullRequestFields), submitting via createMobilePr. Renders a plain View so it -// can sit inline inside the PR sidebar's existing ScrollView (a BottomDrawer -// overlay trapped in a ScrollView clips the form). The BottomDrawer wrapper -// MobilePrComposeSheet reuses this body at full-screen roots. -export function MobilePrComposeForm({ - client, - worktreeId, - prefill, - head, - onCancel, - onCreated -}: Props) { - const copy = hostedReviewCopy(prefill.provider) - const ReviewIcon = prefill.provider === 'gitlab' ? GitMerge : GitPullRequestArrow - const [title, setTitle] = useState(prefill.title) - const [body, setBody] = useState(prefill.body) - const [base, setBase] = useState(prefill.base) - const [draft, setDraft] = useState(false) - const [generating, setGenerating] = useState(false) - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState(null) - const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill) - - const generate = useCallback(async () => { - if (!client || generating) { - return - } - setGenerating(true) - setError(null) - try { - const response = await client.sendRequest('git.generatePullRequestFields', { - worktree: `id:${worktreeId}`, - base, - title, - body, - draft - }) - if (!response.ok) { - setError(response.error?.message || 'Failed to generate PR fields') - return - } - const result = (response as RpcSuccess).result as { - success?: boolean - fields?: { base: string; title: string; body: string; draft: boolean } - error?: string - } - if (result.success && result.fields) { - setBase(result.fields.base || base) - setTitle(result.fields.title || title) - setBody(result.fields.body || body) - setDraft(result.fields.draft) - } else if (result.error) { - setError(result.error) - } - } catch (err) { - triggerError() - setError(err instanceof Error ? err.message : 'Failed to generate PR fields') - } finally { - setGenerating(false) - } - }, [base, body, client, draft, generating, title, worktreeId]) - - const headRef = head ?? '' - const baseConflict = base.trim().length > 0 && !isBaseHeadDistinct(base, headRef) - const submitDisabledReason = getPrComposeDisabledReason({ - title, - base, - head: headRef, - generating, - reviewLabel: copy.reviewLabel - }) - const canSubmit = submitDisabledReason === null - const fieldsLocked = submitting || generating - - const submit = useCallback(async () => { - if (!client || submitting || !canSubmit) { - return - } - setSubmitting(true) - setError(null) - try { - const outcome = await createMobilePr(client, worktreeId, { - provider: prefill.provider, - base, - // Send the same head the submit guard validated against, so the PR opens - // from the validated branch instead of a host-inferred one. - ...(head ? { head } : {}), - title, - body, - draft, - pushBeforeCreate - }) - if (outcome.ok) { - triggerSuccess() - const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider) - if (warning) { - setError(warning) - } - onCreated(outcome.url, warning) - } else { - triggerError() - setError(outcome.error) - } - } finally { - setSubmitting(false) - } - }, [ - base, - body, - canSubmit, - client, - copy.titleLabel, - draft, - head, - onCreated, - prefill.provider, - pushBeforeCreate, - submitting, - title, - worktreeId - ]) - - return ( - - - - - New {copy.reviewLabel} - - - [styles.genButton, pressed && styles.genButtonPressed]} - disabled={generating || submitting} - onPress={() => void generate()} - accessibilityRole="button" - accessibilityLabel={`Generate ${copy.reviewLabel} details with AI`} - > - {generating ? ( - - ) : ( - - )} - {generating ? 'Generating…' : 'Generate'} - - - - - - - - {head ? ( - - - {head} - - - - {base || 'base'} - - - ) : null} - - - - - - - {generating ? ( - - - Generating title and description… - - ) : null} - - - Base - - - - - - - Create as draft - - - {error || submitDisabledReason ? ( - - - {error ?? submitDisabledReason} - - ) : null} - [ - styles.submit, - (submitting || !canSubmit) && styles.submitDisabled, - pressed && styles.submitPressed - ]} - disabled={submitting || !canSubmit} - onPress={() => void submit()} - accessibilityRole="button" - > - {submitting ? ( - - ) : ( - - )} - - {pushBeforeCreate - ? draft - ? `Push & create draft ${copy.shortLabel}` - : `Push & create ${copy.shortLabel}` - : draft - ? `Create draft ${copy.shortLabel}` - : `Create ${copy.shortLabel}`} - - - - ) -} diff --git a/mobile/src/components/pr-sidebar/PRCommentCard.tsx b/mobile/src/components/pr-sidebar/PRCommentCard.tsx index ce1ef33cb96..23d768b7814 100644 --- a/mobile/src/components/pr-sidebar/PRCommentCard.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentCard.tsx @@ -7,7 +7,7 @@ import { canEditComment, isResolvableComment } from '../../session/pr-comment-ac import { ConfirmModal } from '../ConfirmModal' import { CommentMarkdown } from './CommentMarkdown' import { PRCommentComposer } from './PRCommentComposer' -import { formatPrCommentRelativeTime } from './pr-comment-time' +import { formatPrCommentRelativeTime } from '../../../../src/shared/pr-comment-time' import { prCommentsStyles as styles } from './pr-comments-styles' export type PRCommentRepoSlug = { owner: string; repo: string; host?: string } diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts index d174f455c67..08fc02c357b 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts @@ -32,18 +32,6 @@ vi.mock('./pr-comments-styles', () => ({ prCommentsStyles: {} })) vi.mock('./mobile-pr-sidebar-styles', () => ({ mobilePrSidebarStyles: {} })) vi.mock('../../theme/mobile-theme', () => ({ colors: { textSecondary: '#999' } })) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - function comment(id: number): PRComment { return { id, @@ -65,14 +53,9 @@ function detailsWithComments(count: number): GitHubWorkItemDetails { async function renderComments(details: GitHubWorkItemDetails): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(PRCommentsSection, { details, prState: 'open' })) - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(PRCommentsSection, { details, prState: 'open' })) + }) if (!renderer) { throw new Error('PRCommentsSection did not render') } @@ -107,7 +90,6 @@ describe('PRCommentsSection', () => { afterEach(() => { renderer?.unmount() renderer = null - vi.restoreAllMocks() }) it('resets pagination only when the user chooses a different audience filter', async () => { diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx index bb473307619..8fb5de31037 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx @@ -12,12 +12,14 @@ import { CommentMarkdown } from './CommentMarkdown' import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard' import { PRCommentComposer } from './PRCommentComposer' import { - PR_COMMENT_AUDIENCE_FILTERS, filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, type PRCommentAudienceFilter -} from './pr-comment-audience' +} from '../../../../src/shared/pr-comment-audience' +import { + PR_COMMENT_AUDIENCE_FILTERS, + getPRCommentAudienceEmptyLabel +} from './pr-comment-audience-labels' import { getPRCommentGroupCount, getPRCommentGroupId, @@ -25,7 +27,7 @@ import { groupPRComments, isResolvedPRCommentGroup, type PRCommentGroup -} from './pr-comment-groups' +} from '../../../../src/shared/pr-comment-groups' import { prCommentsStyles as styles } from './pr-comments-styles' import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles' diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index 8a399babe78..335108e07ae 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -7,7 +7,7 @@ import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-acti import { prStateBadge } from './pr-checks-presentation' import { statusColor } from './pr-sidebar-status-color' import { canEditPRTitle } from '../../session/pr-title-edit' -import { openMobilePrUrl } from '../MobilePrComposeSheet' +import { openMobilePrUrl } from '../mobile-pr-url' import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles' import { prCommentComposerStyles as composerStyles } from './pr-comment-composer-styles' diff --git a/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx b/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx index ebc724d947d..cffde86ea5b 100644 --- a/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx +++ b/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx @@ -18,7 +18,7 @@ import { runMobileHostedReviewCreateIntent } from '../../source-control/mobile-hosted-review-create-intent-runner' import { fetchWorktreeLinkedPR } from '../../source-control/mobile-pr-link' -import { openMobilePrUrl } from '../MobilePrComposeSheet' +import { openMobilePrUrl } from '../mobile-pr-url' import { MobileLinkPrForm } from './MobileLinkPrForm' import { prCreateEmptyStateStyles as styles } from './pr-create-empty-state-styles' diff --git a/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts b/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts deleted file mode 100644 index 587082d972f..00000000000 --- a/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { StyleSheet } from 'react-native' -import { colors, radii, spacing, typography } from '../../theme/mobile-theme' - -export const mobilePrComposeFormStyles = StyleSheet.create({ - root: { - gap: spacing.sm - }, - headingRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - marginBottom: spacing.xs - }, - headingTitle: { - flex: 1, - minWidth: 0, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - heading: { - color: colors.textPrimary, - fontSize: typography.bodySize, - fontWeight: '700' - }, - headingActions: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - genButton: { - minHeight: 32, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs, - paddingHorizontal: spacing.sm, - borderRadius: radii.button, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - genButtonPressed: { - opacity: 0.7 - }, - genButtonText: { - color: colors.textSecondary, - fontSize: typography.metaSize, - fontWeight: '700' - }, - iconButton: { - minWidth: 32, - minHeight: 32, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - branchFlow: { - minHeight: 28, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - branchToken: { - maxWidth: 116, - color: colors.textPrimary, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily - }, - branchTokenError: { - color: colors.statusRed - }, - fieldStack: { - gap: spacing.sm - }, - titleInput: { - minHeight: 40, - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontWeight: '600' - }, - bodyInput: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - color: colors.textPrimary, - fontSize: typography.bodySize, - // Why: a moderate fixed height avoids over-expanding inside the sidebar scroll. - minHeight: 120, - textAlignVertical: 'top' - }, - baseRow: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - baseLabel: { - color: colors.textSecondary, - fontSize: typography.metaSize, - width: 36 - }, - baseControl: { - flex: 1, - minWidth: 0 - }, - draftRow: { - minHeight: 36, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.button, - backgroundColor: colors.bgPanel, - paddingHorizontal: spacing.sm - }, - draftText: { - color: colors.textPrimary, - fontSize: typography.metaSize, - fontWeight: '700' - }, - notice: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.xs - }, - noticeText: { - flex: 1, - color: colors.textSecondary, - fontSize: typography.metaSize, - lineHeight: 18 - }, - errorText: { - color: colors.statusRed - }, - submit: { - marginTop: spacing.xs, - minHeight: 44, - borderRadius: radii.button, - backgroundColor: colors.textPrimary, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs - }, - submitDisabled: { - opacity: 0.45 - }, - submitPressed: { - opacity: 0.8 - }, - submitText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '700' - } -}) diff --git a/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts b/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts new file mode 100644 index 00000000000..43f0d54289f --- /dev/null +++ b/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts @@ -0,0 +1,18 @@ +import type { PRCommentAudienceFilter } from '../../../../src/shared/pr-comment-audience' + +export const PR_COMMENT_AUDIENCE_FILTERS = [ + { value: 'all', label: 'All' }, + { value: 'human', label: 'Humans' }, + { value: 'bot', label: 'Bots' } +] satisfies { value: PRCommentAudienceFilter; label: string }[] + +export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { + switch (filter) { + case 'bot': + return 'No bot comments.' + case 'human': + return 'No human comments.' + case 'all': + return 'No comments yet.' + } +} diff --git a/mobile/src/components/pr-sidebar/pr-comment-groups.ts b/mobile/src/components/pr-sidebar/pr-comment-groups.ts deleted file mode 100644 index 78bff853478..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-groups.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { PRComment } from '../../../../src/shared/types' - -// Thread grouping for the PR comment timeline, ported from the desktop helper -// (src/renderer/src/lib/pr-comment-groups.ts) minus the DOM class constants. -// Inline review comments sharing a threadId collapse into a root + replies; all -// other comments are standalone. Upstream order is preserved. -export type PRCommentGroup = - | { kind: 'standalone'; comment: PRComment } - | { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] } - -export function groupPRComments(comments: PRComment[]): PRCommentGroup[] { - const threadMap = new Map() - const groupsByFirstComment = new Map() - - for (const comment of comments) { - if (!comment.threadId) { - groupsByFirstComment.set(comment, { kind: 'standalone', comment }) - continue - } - const existing = threadMap.get(comment.threadId) - if (existing) { - existing.replies.push(comment) - continue - } - threadMap.set(comment.threadId, { root: comment, replies: [] }) - } - - const emitted = new Set() - const groups: PRCommentGroup[] = [] - for (const comment of comments) { - if (!comment.threadId) { - const group = groupsByFirstComment.get(comment) - if (group) { - groups.push(group) - } - continue - } - if (emitted.has(comment.threadId)) { - continue - } - emitted.add(comment.threadId) - const thread = threadMap.get(comment.threadId) - if (thread) { - groups.push({ kind: 'thread', threadId: comment.threadId, ...thread }) - } - } - return groups -} - -export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] { - return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment] -} - -export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment { - return group.kind === 'thread' ? group.root : group.comment -} - -export function getPRCommentGroupCount(group: PRCommentGroup): number { - return getPRCommentGroupComments(group).length -} - -export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean { - return getPRCommentGroupRoot(group).isResolved === true -} - -export function getPRCommentGroupId(group: PRCommentGroup): string { - return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` -} diff --git a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts deleted file mode 100644 index e01fb4166dc..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PRComment } from '../../../../src/shared/types' -import { - createBotAuthorOverrideSet, - filterPRCommentsByAudience, - getPRCommentAudienceCounts, - isBotPRComment -} from './pr-comment-audience' -import { groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups' -import { formatPrCommentRelativeTime } from './pr-comment-time' - -function comment(overrides: Partial & { id: number }): PRComment { - return { - author: 'octocat', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - ...overrides - } -} - -describe('pr comment audience', () => { - it('classifies app bots, [bot] suffix, and known automation logins', () => { - expect(isBotPRComment(comment({ id: 1, author: 'alice' }))).toBe(false) - expect(isBotPRComment(comment({ id: 2, author: 'alice', isBot: true }))).toBe(true) - expect(isBotPRComment(comment({ id: 3, author: 'renovate[bot]' }))).toBe(true) - expect(isBotPRComment(comment({ id: 4, author: 'coderabbitai' }))).toBe(true) - }) - - it('counts and filters by audience', () => { - const comments = [ - comment({ id: 1, author: 'alice' }), - comment({ id: 2, author: 'dependabot[bot]' }), - comment({ id: 3, author: 'bob' }) - ] - expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 2, bot: 1 }) - expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.id)).toEqual([2]) - expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3]) - expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3) - }) - - it('classifies desktop-marked bot authors via overrides (desktop parity)', () => { - const overrides = createBotAuthorOverrideSet([' GretelFlux ']) - const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })] - expect(isBotPRComment(comments[1])).toBe(false) - expect(isBotPRComment(comments[1], overrides)).toBe(true) - expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 }) - expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2]) - }) -}) - -describe('pr comment groups', () => { - it('threads comments sharing a threadId as root + replies, preserving order', () => { - const comments = [ - comment({ id: 1, author: 'a' }), - comment({ id: 2, author: 'b', threadId: 't1', isResolved: true }), - comment({ id: 3, author: 'c', threadId: 't1' }) - ] - const groups = groupPRComments(comments) - expect(groups).toHaveLength(2) - expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] }) - expect(groups[1].kind).toBe('thread') - if (groups[1].kind === 'thread') { - expect(groups[1].root.id).toBe(2) - expect(groups[1].replies.map((r) => r.id)).toEqual([3]) - } - expect(isResolvedPRCommentGroup(groups[1])).toBe(true) - }) -}) - -describe('pr comment relative time', () => { - const now = Date.parse('2026-06-16T12:00:00Z') - it('formats buckets and rejects bad input', () => { - expect(formatPrCommentRelativeTime('2026-06-16T11:59:30Z', now)).toBe('just now') - expect(formatPrCommentRelativeTime('2026-06-16T11:30:00Z', now)).toBe('30m ago') - expect(formatPrCommentRelativeTime('2026-06-16T09:00:00Z', now)).toBe('3h ago') - expect(formatPrCommentRelativeTime('2026-06-10T12:00:00Z', now)).toBe('6d ago') - expect(formatPrCommentRelativeTime('not-a-date', now)).toBe('') - }) -}) diff --git a/mobile/src/components/pr-sidebar/pr-comment-time.ts b/mobile/src/components/pr-sidebar/pr-comment-time.ts deleted file mode 100644 index 47d78ea8a2f..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-time.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd/Xmo/Xy" out), -// mirroring the desktop formatRelativeTime so the timeline reads the same. Pure + -// unit-testable; nowMs is passed in (Date.now() is unavailable in some contexts). -export function formatPrCommentRelativeTime(iso: string, nowMs: number): string { - const ts = Date.parse(iso) - if (Number.isNaN(ts)) { - return '' - } - const delta = nowMs - ts - if (delta < 60_000) { - return 'just now' - } - const minutes = Math.floor(delta / 60_000) - if (minutes < 60) { - return `${minutes}m ago` - } - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return `${hours}h ago` - } - const days = Math.floor(hours / 24) - if (days < 30) { - return `${days}d ago` - } - const months = Math.floor(days / 30) - if (months < 12) { - return `${months}mo ago` - } - return `${Math.floor(months / 12)}y ago` -} diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts index 0d003e9f456..ff407ff21f6 100644 --- a/mobile/src/files/MobileFileExplorerPanel.test.ts +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -83,18 +83,6 @@ vi.mock('../transport/client-context', () => ({ }) })) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - function entry(name: string, isDirectory = false): MobileDirEntry { return { name, isDirectory } } @@ -113,21 +101,16 @@ function createMockClient(entriesByPath: Record): Mock async function renderExplorer(): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileFileExplorerPanel, { - hostId: 'host-a', - worktreeId: 'worktree-a', - name: 'Example Worktree', - embedded: true - }) - ) - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create( + createElement(MobileFileExplorerPanel, { + hostId: 'host-a', + worktreeId: 'worktree-a', + name: 'Example Worktree', + embedded: true + }) + ) + }) if (!renderer) { throw new Error('MobileFileExplorerPanel did not render') } diff --git a/mobile/src/files/MobileFileMarkdownPreview.test.ts b/mobile/src/files/MobileFileMarkdownPreview.test.ts index 2afdb0b2ce9..8ac3770eb35 100644 --- a/mobile/src/files/MobileFileMarkdownPreview.test.ts +++ b/mobile/src/files/MobileFileMarkdownPreview.test.ts @@ -33,28 +33,11 @@ vi.mock('./mobile-file-preview-styles', () => ({ type PreviewProps = Parameters[0] -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - async function renderPreview(props: PreviewProps): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(MobileFileMarkdownPreview, props)) - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(MobileFileMarkdownPreview, props)) + }) if (!renderer) { throw new Error('MobileFileMarkdownPreview did not render') } diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index 71f26ca155b..8a3e795ac2d 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -132,7 +132,7 @@ export function getDirectoryCacheState( ): DirectoryState | undefined { // Why: repository paths are arbitrary object keys; inherited keys like // "constructor" must not masquerade as loaded directory state. - return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined + return Object.hasOwn(cache, relativePath) ? cache[relativePath] : undefined } export function joinRelativePath(parentPath: string, name: string): string { diff --git a/mobile/src/hooks/use-active-worktree-scroll.ts b/mobile/src/hooks/use-active-worktree-scroll.ts index 7600f3ceafe..ff0441c85a8 100644 --- a/mobile/src/hooks/use-active-worktree-scroll.ts +++ b/mobile/src/hooks/use-active-worktree-scroll.ts @@ -37,7 +37,7 @@ export function useActiveWorktreeScroll( (worktreeId: string): boolean => { for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { const itemIndex = sections[sectionIndex].data.findIndex((w) => w.worktreeId === worktreeId) - if (itemIndex >= 0) { + if (itemIndex !== -1) { sectionListRef.current?.scrollToLocation({ sectionIndex, itemIndex, diff --git a/mobile/src/hooks/use-now.test.ts b/mobile/src/hooks/use-now.test.ts index c3fe56a980b..14264b513ee 100644 --- a/mobile/src/hooks/use-now.test.ts +++ b/mobile/src/hooks/use-now.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const appState = vi.hoisted(() => ({ current: 'active', @@ -25,7 +25,6 @@ import { useNow } from './use-now' describe('useNow', () => { let renderer: ReactTestRenderer | null = null let latest = 0 - let consoleSpy: MockInstance function Harness({ enabled = true }: { enabled?: boolean }): null { latest = useNow(1_000, enabled) @@ -42,18 +41,10 @@ describe('useNow', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(1_000) - globalThis.IS_REACT_ACT_ENVIRONMENT = true appState.current = 'active' appState.listener = null appState.remove.mockClear() latest = 0 - const original = console.error - consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) act(() => { renderer = create(createElement(Harness)) }) @@ -63,7 +54,6 @@ describe('useNow', () => { act(() => renderer?.unmount()) renderer = null vi.useRealTimers() - consoleSpy.mockRestore() }) it('ticks while active, pauses in the background, and refreshes immediately on resume', () => { diff --git a/mobile/src/host-edit-route-accessibility.test.ts b/mobile/src/host-edit-route-accessibility.test.ts index bb5ddf4faa6..d5fc20521a9 100644 --- a/mobile/src/host-edit-route-accessibility.test.ts +++ b/mobile/src/host-edit-route-accessibility.test.ts @@ -46,29 +46,12 @@ vi.mock('./transport/client-context', () => ({ usePrimeHosts: () => dependencies.primeHosts })) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - async function renderEditHostRoute(): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(EditHostScreen)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(EditHostScreen)) + await Promise.resolve() + }) if (!renderer) { throw new Error('Edit host route did not render') } @@ -77,7 +60,6 @@ async function renderEditHostRoute(): Promise { describe('edit host route accessibility', () => { beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true dependencies.loadHosts.mockReset().mockResolvedValue([ { id: 'host-1', diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts index aa0069a7c69..a0a4561b2b2 100644 --- a/mobile/src/host-edit-save-flow.test.ts +++ b/mobile/src/host-edit-save-flow.test.ts @@ -56,29 +56,12 @@ const HOST_FIXTURE = { lastConnected: 1 } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - async function renderEditHostRoute(): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(EditHostScreen)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(EditHostScreen)) + await Promise.resolve() + }) if (!renderer) { throw new Error('Edit host route did not render') } @@ -135,7 +118,6 @@ function findText(renderer: ReactTestRenderer, match: string): boolean { describe('edit host handleSave', () => { beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true dependencies.hostId = 'host-1' dependencies.back.mockReset() dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) @@ -285,7 +267,6 @@ describe('edit host handleSave', () => { describe('edit host load() error states', () => { beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true dependencies.hostId = 'host-1' dependencies.back.mockReset() dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts index a99052c3ec9..d62e496f0bd 100644 --- a/mobile/src/onboarding/MobileOnboardingPage.test.ts +++ b/mobile/src/onboarding/MobileOnboardingPage.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileOnboardingPage } from './MobileOnboardingPage' vi.mock('react-native', async () => { @@ -24,10 +24,6 @@ vi.mock('lucide-react-native', () => ({ describe('MobileOnboardingPage', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/onboarding/mobile-onboarding-screen.test.ts b/mobile/src/onboarding/mobile-onboarding-screen.test.ts index d571ce28a01..05dcc6b9ad8 100644 --- a/mobile/src/onboarding/mobile-onboarding-screen.test.ts +++ b/mobile/src/onboarding/mobile-onboarding-screen.test.ts @@ -56,7 +56,6 @@ describe('MobileOnboardingScreen', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' } mocks.replace.mockReset() mocks.reducedMotionEnabled = false diff --git a/mobile/src/session/MobileMarkdownReader.test.tsx b/mobile/src/session/MobileMarkdownReader.test.tsx new file mode 100644 index 00000000000..c6ff322acba --- /dev/null +++ b/mobile/src/session/MobileMarkdownReader.test.tsx @@ -0,0 +1,119 @@ +import { createElement, forwardRef, useImperativeHandle } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileMarkdownReader } from './MobileMarkdownReader' +import type { MarkdownDocState } from './mobile-session-route-types' + +const mocks = vi.hoisted(() => ({ + dismissKeyboard: vi.fn(), + reportKeyboardInset: null as ((bottom: number) => void) | null +})) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (choices: Record) => choices.ios }, + Pressable: ({ children, ...props }: { children?: unknown }) => + React.createElement('Pressable', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + Keyboard: 'KeyboardIcon', + RefreshCw: 'RefreshCw' +})) + +vi.mock('../components/MobileRichMarkdownEditor', () => { + const Editor = forwardRef( + (props: { onKeyboardInsetChange?: (bottom: number) => void }, ref: unknown) => { + mocks.reportKeyboardInset = props.onKeyboardInsetChange ?? null + useImperativeHandle(ref as never, () => ({ dismissKeyboard: mocks.dismissKeyboard })) + return createElement('MobileRichMarkdownEditor') + } + ) + return { MobileRichMarkdownEditor: Editor } +}) + +const readyDoc: MarkdownDocState = { + status: 'ready', + content: '# Notes', + localContent: '# Notes', + baseVersion: '1', + isDirty: false, + editable: true +} + +describe('MobileMarkdownReader', () => { + let renderer: ReactTestRenderer | null = null + + function render(keyboardLift: number, doc: MarkdownDocState = readyDoc): ReactTestRenderer { + act(() => { + renderer = create( + createElement(MobileMarkdownReader, { + documentId: 'doc-1', + doc, + keyboardLift, + onRefresh: vi.fn(), + onChange: vi.fn(), + onSave: vi.fn(), + onCopy: vi.fn(), + onDiscard: vi.fn() + }) + ) + }) + return renderer as unknown as ReactTestRenderer + } + + function dismissButtons(instance: ReactTestRenderer) { + return instance.root.findAll( + (node) => + typeof node.type === 'string' && node.props?.accessibilityLabel === 'Dismiss keyboard' + ) + } + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + mocks.reportKeyboardInset = null + vi.clearAllMocks() + }) + + it('offers keyboard dismissal while the keyboard covers the editor', () => { + const instance = render(291) + + const [button] = dismissButtons(instance) + expect(button).toBeDefined() + + act(() => button.props.onPress()) + + expect(mocks.dismissKeyboard).toHaveBeenCalledOnce() + }) + + it('hides the floating row entirely on a clean document with the keyboard closed', () => { + const instance = render(0) + + expect(dismissButtons(instance)).toHaveLength(0) + expect(instance.root.findAllByType('Text')).toHaveLength(0) + }) + + it('keeps document actions available without offering dismissal when the keyboard is closed', () => { + const instance = render(0, { ...readyDoc, isDirty: true }) + + expect(dismissButtons(instance)).toHaveLength(0) + expect(instance.root.findAllByType('Text').length).toBeGreaterThan(0) + }) + + it('treats a WebView-reported inset as an open keyboard when native lift reports none', () => { + const instance = render(0) + expect(dismissButtons(instance)).toHaveLength(0) + + act(() => mocks.reportKeyboardInset?.(291)) + + expect(dismissButtons(instance)).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/MobileMarkdownReader.tsx b/mobile/src/session/MobileMarkdownReader.tsx new file mode 100644 index 00000000000..a84599ded3a --- /dev/null +++ b/mobile/src/session/MobileMarkdownReader.tsx @@ -0,0 +1,172 @@ +import { useRef, useState } from 'react' +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { ChevronDown, Keyboard as KeyboardIcon, RefreshCw } from 'lucide-react-native' +import { + MobileRichMarkdownEditor, + type MobileRichMarkdownEditorHandle +} from '../components/MobileRichMarkdownEditor' +import { colors, spacing } from '../theme/mobile-theme' +import { + resolveMarkdownFloatingActionsBottom, + shouldShowMarkdownFloatingActions +} from './markdown-floating-actions-layout' +import type { MarkdownDocState } from './mobile-session-route-types' +import { styles } from './mobile-session-styles' + +type Props = { + documentId: string + doc: MarkdownDocState | undefined + onRefresh: () => void + onChange: (content: string) => void + onSave: () => void + onCopy: () => void + onDiscard: () => void + keyboardLift: number +} + +export function MobileMarkdownReader({ + documentId, + doc, + onRefresh, + onChange, + onSave, + onCopy, + onDiscard, + keyboardLift +}: Props) { + const editorRef = useRef(null) + // Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset. + const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0) + const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset) + const keyboardOpen = effectiveKeyboardLift > 0 + + if (!doc || doc.status === 'loading') { + return ( + + + + ) + } + if (doc.status === 'error') { + return ( + + {doc.message} + + + Retry + + + ) + } + + const statusText = doc.saveError + ? doc.saveError + : doc.readOnlyReason + ? 'Read only' + : doc.stale + ? 'Changed on desktop' + : null + const showRefresh = Boolean((doc.stale && !doc.isDirty) || !doc.editable) + const showCopy = Boolean(doc.saveError || !doc.editable) + const showSave = Boolean(doc.isDirty || doc.saving) + const showFloatingActions = shouldShowMarkdownFloatingActions({ + keyboardLift: effectiveKeyboardLift, + hasStatus: statusText != null, + showRefresh, + showCopy, + showSave + }) + + return ( + + + {showFloatingActions ? ( + + {statusText ? ( + + {statusText} + + ) : null} + + {keyboardOpen ? ( + editorRef.current?.dismissKeyboard()} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Dismiss keyboard" + accessibilityHint="Hides the software keyboard and keeps the markdown editor open." + > + + + + + + ) : null} + {showCopy ? ( + + Copy + + ) : null} + {showRefresh ? ( + + + Refresh + + ) : null} + {doc.isDirty ? ( + + Discard + + ) : null} + {showSave ? ( + + {doc.saving ? ( + + ) : ( + Save + )} + + ) : null} + + + ) : null} + + ) +} diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index 68258c22820..a5ba0ef7f72 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -1,8 +1,8 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' import { Check } from 'lucide-react-native' +import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' type Props = { prompt: AskPrompt diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 4ee001ca4af..1d1b0288467 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { radii, spacing } from '../theme/mobile-theme' import { MobileNativeChatComposer } from './MobileNativeChatComposer' @@ -43,24 +43,9 @@ vi.mock('../components/BottomDrawer', async () => { } }) -function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() -} - describe('MobileNativeChatComposer', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -71,21 +56,16 @@ describe('MobileNativeChatComposer', () => { onChangeText: () => void, isAttaching = false ) { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: ' hello ', - onChangeText, - onSend, - isAttaching - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' hello ', + onChangeText, + onSend, + isAttaching + }) + ) + }) } function sendButton(): { props: { onPress: () => Promise } } { @@ -130,20 +110,15 @@ describe('MobileNativeChatComposer', () => { it('preserves leading whitespace so prose is not turned into a slash command', async () => { const onSend = vi.fn().mockResolvedValue(true) - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: ' /clear is prose ', - onChangeText: vi.fn(), - onSend - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' /clear is prose ', + onChangeText: vi.fn(), + onSend + }) + ) + }) await act(async () => sendButton().props.onPress()) expect(onSend).toHaveBeenCalledWith(' /clear is prose') }) @@ -181,67 +156,57 @@ describe('MobileNativeChatComposer', () => { invokeAction: vi.fn(), recordCommand: vi.fn() } - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: 'run the tests', - onChangeText: vi.fn(), - onSend, - sessionOptions: { isWorking: false, controller } - }) - ) - }) - const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } => - renderer!.root.find( - (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model' - ) as { props: { accessibilityState: { disabled: boolean } } } - expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) - // Start the send but don't await it — it stays in flight on purpose. - let pressed!: Promise - await act(async () => { - pressed = sendButton().props.onPress() - await Promise.resolve() - }) - expect(onSend).toHaveBeenCalled() - expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true }) - await act(async () => { - releaseSend?.(true) - await pressed - }) - expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'run the tests', + onChangeText: vi.fn(), + onSend, + sessionOptions: { isWorking: false, controller } + }) + ) + }) + const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } => + renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model' + ) as { props: { accessibilityState: { disabled: boolean } } } + expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) + // Start the send but don't await it — it stays in flight on purpose. + let pressed!: Promise + await act(async () => { + pressed = sendButton().props.onPress() + await Promise.resolve() + }) + expect(onSend).toHaveBeenCalled() + expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true }) + await act(async () => { + releaseSend?.(true) + await pressed + }) + expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) }) it('blocks composer submission while a session-option command is pending', async () => { const onSend = vi.fn().mockResolvedValue(true) - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: 'hello', - onChangeText: vi.fn(), - onSend, - sessionOptions: { - isWorking: false, - controller: { - snapshot: [], - pendingId: 'model', - setOption: vi.fn(), - invokeAction: vi.fn(), - recordCommand: vi.fn() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'hello', + onChangeText: vi.fn(), + onSend, + sessionOptions: { + isWorking: false, + controller: { + snapshot: [], + pendingId: 'model', + setOption: vi.fn(), + invokeAction: vi.fn(), + recordCommand: vi.fn() } - }) - ) - }) - } finally { - restore() - } + } + }) + ) + }) expect(sendButton().props).toMatchObject({ disabled: true }) await act(async () => sendButton().props.onPress()) expect(onSend).not.toHaveBeenCalled() @@ -268,21 +233,16 @@ describe('MobileNativeChatComposer', () => { }) it('keeps the text input editable while the send is locked', async () => { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: 'half-typed', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - disabled: true - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'half-typed', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + disabled: true + }) + ) + }) // Revoking `editable` on a focused field resigns first responder on iOS and // yanks the keyboard mid-typing (#10681) — the lock may only gate sending. const input = renderer!.root.find((node) => node.type === 'TextInput') as { @@ -294,25 +254,20 @@ describe('MobileNativeChatComposer', () => { it('renders a removable thumbnail for each pending image attachment', async () => { const onRemoveAttachment = vi.fn() - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - attachments: [ - { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }, - { id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' } - ], - onRemoveAttachment - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + attachments: [ + { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }, + { id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' } + ], + onRemoveAttachment + }) + ) + }) const thumbs = renderer!.root.findAll((node) => node.type === 'Image') as Array<{ props: { source: { uri: string } } }> @@ -327,42 +282,33 @@ describe('MobileNativeChatComposer', () => { it('enables send with an attached image even when the text is empty', async () => { const onSend = vi.fn().mockResolvedValue(true) - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '', - onChangeText: vi.fn(), - onSend, - attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }] - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend, + attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }] + }) + ) + }) expect(sendButton().props).toMatchObject({ disabled: false }) await act(async () => sendButton().props.onPress()) expect(onSend).toHaveBeenCalledWith('') }) it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '/c', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - agent: 'claude' - }) - ) - }) - } finally { - restore() - } + const onChangeText = vi.fn() + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/c', + onChangeText, + onSend: vi.fn().mockResolvedValue(true), + agent: 'claude' + }) + ) + }) const input = () => renderer!.root.find((node) => node.type === 'TextInput') as { props: { @@ -380,6 +326,7 @@ describe('MobileNativeChatComposer', () => { (node) => node.type === 'Pressable' && !node.props.accessibilityLabel )[0] as { props: { onPress: () => void } } await act(async () => firstSuggestion.props.onPress()) + expect(onChangeText).toHaveBeenCalledWith('/clear ') // `/clear ` is 7 chars — the caret jumps just past the inserted command + space. expect(input().props.selection).toEqual({ start: 7, end: 7 }) // The next native selection event releases control so manual placement still works. @@ -390,21 +337,16 @@ describe('MobileNativeChatComposer', () => { }) it('serves the active agent’s shared command catalog with descriptions', async () => { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '/', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - agent: 'codex' - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + agent: 'codex' + }) + ) + }) const input = renderer!.root.find((node) => node.type === 'TextInput') as { props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void } } @@ -428,45 +370,40 @@ describe('MobileNativeChatComposer', () => { (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate' ) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } } - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - onMicPress, - dictationMode: 'hold', - onMicPressIn, - onMicPressOut - }) - ) - }) - // Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert. - expect(mic().props.onPress).toBeUndefined() - expect(mic().props.onPressIn).toBe(onMicPressIn) - expect(mic().props.onPressOut).toBe(onMicPressOut) + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'hold', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert. + expect(mic().props.onPress).toBeUndefined() + expect(mic().props.onPressIn).toBe(onMicPressIn) + expect(mic().props.onPressOut).toBe(onMicPressOut) - await act(async () => { - renderer!.update( - createElement(MobileNativeChatComposer, { - value: '', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - onMicPress, - dictationMode: 'toggle', - onMicPressIn, - onMicPressOut - }) - ) - }) - // Toggle mode: tap drives dictation, press-in/out inert. - expect(mic().props.onPress).toBe(onMicPress) - expect(mic().props.onPressIn).toBeUndefined() - expect(mic().props.onPressOut).toBeUndefined() - } finally { - restore() - } + await act(async () => { + renderer!.update( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'toggle', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Toggle mode: tap drives dictation, press-in/out inert. + expect(mic().props.onPress).toBe(onMicPress) + expect(mic().props.onPressIn).toBeUndefined() + expect(mic().props.onPressOut).toBeUndefined() }) }) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 9d33db069ff..10b96cc3d20 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -1,8 +1,8 @@ import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary' vi.mock('react-native', async () => { const React = await import('react') @@ -38,9 +38,6 @@ function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage { describe('MobileNativeChatMessage', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -50,20 +47,9 @@ describe('MobileNativeChatMessage', () => { message: NativeChatMessage, props: { toolsExpanded?: boolean } = {} ): ReactTestRenderer { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message, ...props })) }) - try { - act(() => { - renderer = create(createElement(MobileNativeChatMessage, { message, ...props })) - }) - } finally { - spy.mockRestore() - } return renderer! } diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index d9c9f6100ca..0a676061e5b 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -2,25 +2,22 @@ import { memo, useEffect, useRef, useState } from 'react' import { Image, Pressable, Text, View } from 'react-native' import * as Clipboard from 'expo-clipboard' import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native' -import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' -import { MobileMarkdown } from '../components/MobileMarkdown' -import { colors } from '../theme/mobile-theme' -import { - isImageRefBlock, - isTextBlock, - pairToolBlocks, - splitNativeChatBlocks, - type ToolPair -} from './mobile-native-chat-blocks' -import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff' -import { isRenderableImageUri } from './mobile-native-chat-image-preview' -import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' -import { nativeChatMessageText } from './mobile-native-chat-message-text' +import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff' +import type { NativeChatDiffLine as DiffLine } from '../../../src/shared/native-chat-diff' +import { pairToolBlocks, splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' +import type { NativeChatToolPair as ToolPair } from '../../../src/shared/native-chat-tool-fold' import { createToolInputDisplay, summarizeToolRun, truncateToolDetail -} from './mobile-native-chat-tool-summary' +} from '../../../src/shared/native-chat-tool-summary' +import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' +import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileMarkdown } from '../components/MobileMarkdown' +import { colors } from '../theme/mobile-theme' +import { isRenderableImageUri } from './mobile-native-chat-image-preview' +import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { nativeChatMessageText } from './mobile-native-chat-message-text' const MAX_VISIBLE_TOOL_PAIRS = 6 const MAX_TOOL_RUN_DIFF_ROWS = 240 @@ -279,7 +276,6 @@ function AgentControls({ function MobileNativeChatMessageImpl({ message, - queued, toolsExpanded = false, fontScale = 1, messageIndex, @@ -287,7 +283,6 @@ function MobileNativeChatMessageImpl({ onOpenFile }: { message: NativeChatMessage - queued?: boolean toolsExpanded?: boolean /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number @@ -331,27 +326,24 @@ function MobileNativeChatMessageImpl({ // Copy + scroll-to-top, shown inline with the first tool call (or after the // prose when there are no tools). - const controls = - isAgent && !queued ? ( - onScrollToMessage(messageIndex) - : undefined - } - /> - ) : null + const controls = isAgent ? ( + onScrollToMessage(messageIndex) + : undefined + } + /> + ) : null return ( - {isUser && queued ? Queued : null} diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index c8e62c146cb..b43a7c79f3b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' @@ -16,17 +16,6 @@ function assistantTurn(id: string, text: string): NativeChatMessage { return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } } -function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() -} - /** One render of the route: chat visible or not, the transcript it currently * holds, and the agent-status stream behind it. */ type Tick = { @@ -69,24 +58,15 @@ function overlayElement(tick: Tick): ReturnType { describe('MobileNativeChatOverlay streaming gate', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null }) async function render(tick: Tick): Promise { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(overlayElement(tick)) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(overlayElement(tick)) + }) } async function update(tick: Tick): Promise { diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index 8e0223f19ec..b39188b381f 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileNativeChatPermission } from './MobileNativeChatPermission' vi.mock('react-native', () => ({ @@ -15,10 +15,6 @@ vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' })) describe('MobileNativeChatPermission', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -28,25 +24,14 @@ describe('MobileNativeChatPermission', () => { let resolveResponse: (accepted: boolean) => void = () => {} const response = new Promise((resolve) => (resolveResponse = resolve)) const onRespond = vi.fn(() => response) - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + onRespond + }) + ) }) - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatPermission, { - permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, - onRespond - }) - ) - }) - } finally { - consoleSpy.mockRestore() - } const button = renderer.root.findByType('Pressable') act(() => { diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts index f5af434fb92..4430c60115a 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts @@ -149,7 +149,6 @@ describe('MobileNativeChatSessionOptionPickers', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true setOption.mockReset() setOption.mockResolvedValue(true) invokeAction.mockReset() diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 21a5a7c4aa1..1021671b837 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatView } from './MobileNativeChatView' @@ -71,17 +71,7 @@ type Overrides = { onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null onSend?: (text: string) => Promise -} - -function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() + pending?: Parameters[0]['pending'] } function assistantTurn(id: string, text: string): NativeChatMessage { @@ -105,24 +95,15 @@ function chatViewElement(overrides: Overrides): ReturnType describe('MobileNativeChatView', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null }) async function render(overrides: Overrides = {}): Promise { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(chatViewElement(overrides)) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(chatViewElement(overrides)) + }) } async function update(overrides: Overrides = {}): Promise { @@ -137,6 +118,13 @@ describe('MobileNativeChatView', () => { return (list.props.data as { id: string }[]).map((row) => row.id) } + function renderedRow(id: string): ReturnType { + const list = renderer!.root.find((node) => node.type === 'FlatList') + const data = list.props.data as NativeChatMessage[] + const index = data.findIndex((row) => row.id === id) + return list.props.renderItem({ item: data[index], index }) + } + function banners(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } @@ -207,6 +195,15 @@ describe('MobileNativeChatView', () => { expect(listIds()).toEqual(['a1', 'streaming']) }) + it('renders an accepted optimistic image send without a queued state', async () => { + await render({ + pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }] + }) + + expect(listIds()).toEqual(['pending-1']) + expect(renderedRow('pending-1').props).not.toHaveProperty('queued') + }) + it('keeps a visible lock through a subscribed-end lease blip', async () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 478d36267a7..190001417a3 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -11,6 +11,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' @@ -26,7 +27,6 @@ import { MobileNativeChatComposer } from './MobileNativeChatComposer' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { MobileNativeChatMessage } from './MobileNativeChatMessage' import { MobileNativeChatAsk } from './MobileNativeChatAsk' -import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' import { MobileNativeChatPermission } from './MobileNativeChatPermission' import type { MobileChatPermission } from './mobile-native-chat-permission' import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' @@ -58,8 +58,7 @@ type Props = { loadingEarlier?: boolean onLoadEarlier?: () => void onSend: (text: string) => Promise - /** Optimistic queued sends (owned by the route so they survive view switches). */ - /** Optimistic user echoes, including any ridden-along image preview URIs. */ + /** Accepted user echoes awaiting transcript replacement, including image previews. */ pending: MobileNativeChatPendingItem[] /** Local photo URIs retained when the authoritative transcript replaces an * optimistic image bubble. */ @@ -176,9 +175,8 @@ export function MobileNativeChatView({ [] ) - const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending]) // `data` is the list source: folded transcript + synthetic streaming bubble + - // route-owned optimistic queued messages. Memoize on the same deps so the + // route-owned accepted echoes. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. const { data } = useMemo( () => @@ -248,7 +246,6 @@ export function MobileNativeChatView({ ({ item, index }: { item: NativeChatMessage; index: number }) => ( ), - [pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile] + [toolsExpanded, fontScale, onScrollToMessage, onOpenFile] ) const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) diff --git a/mobile/src/session/QuickCommandRow.tsx b/mobile/src/session/QuickCommandRow.tsx new file mode 100644 index 00000000000..401305b298c --- /dev/null +++ b/mobile/src/session/QuickCommandRow.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from 'react' +import { View, Text, Pressable, StyleSheet } from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { MobileAgentIcon } from '../components/MobileAgentIcon' +import type { TerminalQuickCommand } from '../../../src/shared/types' +import { + getQuickCommandDisplayPreview, + getTerminalQuickCommandBody, + isAgentQuickCommand +} from '../terminal/quick-commands' + +type QuickCommandRowProps = { + command: TerminalQuickCommand + first: boolean + onLaunch: (command: TerminalQuickCommand) => void + onEdit: (command: TerminalQuickCommand) => void + onDelete: (command: TerminalQuickCommand) => void + disabled: boolean +} + +type CopyFeedback = { + body: string + status: 'copied' | 'failed' +} + +export function QuickCommandRow({ + command, + first, + onLaunch, + onEdit, + onDelete, + disabled +}: QuickCommandRowProps) { + const isAgent = isAgentQuickCommand(command) + const body = getTerminalQuickCommandBody(command) + const canCopy = body.trim().length > 0 + // Why: key feedback to the copied body so a prop change drops stale labels + // without setState-in-effect (react-doctor no-adjust-state-on-prop-change). + const [feedback, setFeedback] = useState(null) + const copyResetTimerRef = useRef | null>(null) + const mountedRef = useRef(true) + const copyStatus: 'idle' | 'copied' | 'failed' = + feedback != null && feedback.body === body ? feedback.status : 'idle' + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current) + } + } + }, []) + + // Drop any pending reset timer when the body changes; display status is already idle. + useEffect(() => { + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current) + copyResetTimerRef.current = null + } + }, [body]) + + const handleCopy = async (): Promise => { + if (!canCopy || disabled) { + return + } + try { + await Clipboard.setStringAsync(body) + if (!mountedRef.current) { + return + } + setFeedback({ body, status: 'copied' }) + } catch { + if (!mountedRef.current) { + return + } + setFeedback({ body, status: 'failed' }) + } + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current) + } + copyResetTimerRef.current = setTimeout(() => { + copyResetTimerRef.current = null + if (mountedRef.current) { + setFeedback(null) + } + }, 1500) + } + + const copyDisabled = disabled || !canCopy + const copyLabel = + copyStatus === 'copied' + ? 'Copied' + : copyStatus === 'failed' + ? "Couldn't copy" + : canCopy + ? `Copy ${command.label}` + : 'Nothing to copy' + const copyIconColor = + copyStatus === 'copied' + ? colors.statusGreen + : copyStatus === 'failed' + ? colors.statusRed + : colors.textSecondary + + return ( + + [styles.rowMain, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onLaunch(command)} + accessibilityRole="button" + accessibilityLabel={`Run ${command.label}`} + > + + {isAgent ? ( + + ) : ( + + )} + + + + {command.label} + + + {getQuickCommandDisplayPreview(command)} + + + + [ + styles.rowAction, + // Why: row already dims when `disabled`; only dim again for empty body. + !canCopy && styles.disabled, + pressed && !copyDisabled && styles.pressed + ]} + disabled={copyDisabled} + onPress={() => void handleCopy()} + accessibilityRole="button" + accessibilityLabel={copyLabel} + accessibilityState={{ disabled: copyDisabled }} + > + {copyStatus === 'copied' ? ( + + ) : ( + + )} + + [styles.rowAction, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onEdit(command)} + accessibilityLabel={`Edit ${command.label}`} + > + + + [styles.rowAction, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onDelete(command)} + accessibilityLabel={`Delete ${command.label}`} + > + + + + ) +} + +const styles = StyleSheet.create({ + pressed: { backgroundColor: colors.bgRaised }, + disabled: { opacity: 0.45 }, + row: { flexDirection: 'row', alignItems: 'center' }, + rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle }, + rowMain: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.md, + paddingLeft: spacing.md, + minWidth: 0 + }, + rowIcon: { + width: 26, + height: 26, + borderRadius: 6, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + rowText: { flex: 1, minWidth: 0 }, + rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, + rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 }, + mono: { fontFamily: typography.monoFamily }, + rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' } +}) diff --git a/mobile/src/session/QuickCommandsList.test.ts b/mobile/src/session/QuickCommandsList.test.ts index eb2e2c787c0..37e41990a08 100644 --- a/mobile/src/session/QuickCommandsList.test.ts +++ b/mobile/src/session/QuickCommandsList.test.ts @@ -17,6 +17,7 @@ vi.mock('react-native', () => ({ vi.mock('lucide-react-native', () => ({ Check: 'Check', + Copy: 'Copy', Pencil: 'Pencil', Play: 'Play', Plus: 'Plus', @@ -24,14 +25,14 @@ vi.mock('lucide-react-native', () => ({ Trash2: 'Trash2' })) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) + vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) describe('QuickCommandsList search', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) + beforeEach(() => {}) afterEach(() => { act(() => renderer?.unmount()) diff --git a/mobile/src/session/QuickCommandsList.tsx b/mobile/src/session/QuickCommandsList.tsx index 2c76884d335..e8781163691 100644 --- a/mobile/src/session/QuickCommandsList.tsx +++ b/mobile/src/session/QuickCommandsList.tsx @@ -1,14 +1,11 @@ import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native' -import { Pencil, Plus, Search, Trash2, Check, Play } from 'lucide-react-native' -import { colors, spacing, typography } from '../theme/mobile-theme' +import { Check, Plus, Search } from 'lucide-react-native' +import { colors, spacing } from '../theme/mobile-theme' import { MobileAgentIcon } from '../components/MobileAgentIcon' import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' -import { - getQuickCommandDisplayPreview, - isAgentQuickCommand, - supportsTerminalAgentQuickCommand -} from '../terminal/quick-commands' +import { supportsTerminalAgentQuickCommand } from '../terminal/quick-commands' +import { QuickCommandRow } from './QuickCommandRow' export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) => supportsTerminalAgentQuickCommand(entry.id) @@ -162,67 +159,6 @@ function QuickCommandGroup({ ) } -function QuickCommandRow({ - command, - first, - onLaunch, - onEdit, - onDelete, - disabled -}: { - command: TerminalQuickCommand - first: boolean - onLaunch: (command: TerminalQuickCommand) => void - onEdit: (command: TerminalQuickCommand) => void - onDelete: (command: TerminalQuickCommand) => void - disabled: boolean -}) { - const isAgent = isAgentQuickCommand(command) - return ( - - [styles.rowMain, pressed && !disabled && styles.pressed]} - disabled={disabled} - onPress={() => onLaunch(command)} - accessibilityRole="button" - accessibilityLabel={`Run ${command.label}`} - > - - {isAgent ? ( - - ) : ( - - )} - - - - {command.label} - - - {getQuickCommandDisplayPreview(command)} - - - - [styles.rowAction, pressed && !disabled && styles.pressed]} - disabled={disabled} - onPress={() => onEdit(command)} - accessibilityLabel={`Edit ${command.label}`} - > - - - [styles.rowAction, pressed && !disabled && styles.pressed]} - disabled={disabled} - onPress={() => onDelete(command)} - accessibilityLabel={`Delete ${command.label}`} - > - - - - ) -} - export function QuickCommandAgentPicker({ selected, onSelect @@ -292,15 +228,6 @@ const styles = StyleSheet.create({ group: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' }, row: { flexDirection: 'row', alignItems: 'center' }, rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle }, - rowMain: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - paddingVertical: spacing.md, - paddingLeft: spacing.md, - minWidth: 0 - }, rowIcon: { width: 26, height: 26, @@ -309,11 +236,6 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center' }, - rowText: { flex: 1, minWidth: 0 }, - rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, - rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 }, - mono: { fontFamily: typography.monoFamily }, - rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }, agentLabel: { flex: 1, fontSize: 14, color: colors.textPrimary }, addRow: { flexDirection: 'row', diff --git a/mobile/src/session/QuickCommandsSheet.test.ts b/mobile/src/session/QuickCommandsSheet.test.ts index 654882cd2ad..9951742b64f 100644 --- a/mobile/src/session/QuickCommandsSheet.test.ts +++ b/mobile/src/session/QuickCommandsSheet.test.ts @@ -1,6 +1,6 @@ import { createElement, type ReactNode } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../src/shared/types' import type { RpcClient } from '../transport/rpc-client' import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands' @@ -65,26 +65,15 @@ function deferred() { describe('QuickCommandsSheet', () => { let renderer: ReactTestRenderer | null = null - let consoleSpy: MockInstance - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true mocks.alert.mockReset() mocks.commands = [] mocks.persist.mockReset() - const originalConsoleError = console.error - consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) }) afterEach(() => { act(() => renderer?.unmount()) renderer = null - consoleSpy.mockRestore() }) it('keeps the sheet open when a launch is rejected', async () => { diff --git a/mobile/src/session/markdown-floating-actions-layout.test.ts b/mobile/src/session/markdown-floating-actions-layout.test.ts index 021b8de958c..a4e139acc2f 100644 --- a/mobile/src/session/markdown-floating-actions-layout.test.ts +++ b/mobile/src/session/markdown-floating-actions-layout.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-actions-layout' +import { + resolveMarkdownFloatingActionsBottom, + shouldShowMarkdownFloatingActions +} from './markdown-floating-actions-layout' describe('resolveMarkdownFloatingActionsBottom', () => { it('keeps markdown actions at their resting bottom when the keyboard is closed', () => { @@ -22,3 +25,28 @@ describe('resolveMarkdownFloatingActionsBottom', () => { ).toBe(303) }) }) + +describe('shouldShowMarkdownFloatingActions', () => { + const idle = { + keyboardLift: 0, + hasStatus: false, + showRefresh: false, + showCopy: false, + showSave: false + } + + it('shows the floating row for keyboard dismissal without document actions', () => { + expect(shouldShowMarkdownFloatingActions({ ...idle, keyboardLift: 291 })).toBe(true) + }) + + it('hides the floating row on a clean document with the keyboard closed', () => { + expect(shouldShowMarkdownFloatingActions(idle)).toBe(false) + }) + + it.each(['hasStatus', 'showRefresh', 'showCopy', 'showSave'] as const)( + 'shows the floating row for %s alone', + (field) => { + expect(shouldShowMarkdownFloatingActions({ ...idle, [field]: true })).toBe(true) + } + ) +}) diff --git a/mobile/src/session/markdown-floating-actions-layout.ts b/mobile/src/session/markdown-floating-actions-layout.ts index 06aa5b9223f..bc506a88a9d 100644 --- a/mobile/src/session/markdown-floating-actions-layout.ts +++ b/mobile/src/session/markdown-floating-actions-layout.ts @@ -9,3 +9,19 @@ export function resolveMarkdownFloatingActionsBottom({ }): number { return keyboardLift > 0 ? keyboardLift + liftedClearance : restingBottom } + +export function shouldShowMarkdownFloatingActions({ + keyboardLift, + hasStatus, + showRefresh, + showCopy, + showSave +}: { + keyboardLift: number + hasStatus: boolean + showRefresh: boolean + showCopy: boolean + showSave: boolean +}): boolean { + return keyboardLift > 0 || hasStatus || showRefresh || showCopy || showSave +} diff --git a/mobile/src/session/mobile-diff-comments.ts b/mobile/src/session/mobile-diff-comments.ts index b204cba19c5..7fc7140fd42 100644 --- a/mobile/src/session/mobile-diff-comments.ts +++ b/mobile/src/session/mobile-diff-comments.ts @@ -1,4 +1,7 @@ import type { DiffComment, DiffReviewScope } from '../../../src/shared/types' +import { formatDiffComment, formatDiffComments } from '../../../src/shared/diff-comments-format' + +export { formatDiffComment, formatDiffComments } export type CreateMobileDiffCommentInput = { worktreeId: string @@ -16,44 +19,10 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function isMarkdownComment(comment: Pick): boolean { - return comment.source === 'markdown' -} - function normalizeScope(value: unknown): DiffReviewScope | undefined { return value === 'unstaged' || value === 'staged' || value === 'branch' ? value : undefined } -// Why: mobile Vitest/Metro run from the mobile package and cannot transform -// runtime imports from root src/shared. Keep this byte-for-byte compatible with -// the desktop shared formatter contract. -export function formatDiffComment(c: DiffComment): string { - const escaped = c.body - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\r/g, '\\r') - .replace(/\n/g, '\\n') - const locationLabel = - c.lineNumber === 0 - ? 'Scope: file' - : c.startLine !== undefined && c.startLine !== c.lineNumber - ? `Lines: ${c.startLine}-${c.lineNumber}` - : `Line: ${c.lineNumber}` - if (!isMarkdownComment(c)) { - return [`File: ${c.filePath}`, locationLabel, `User comment: "${escaped}"`].join('\n') - } - return [ - `File: ${c.filePath}`, - 'Source: markdown', - locationLabel, - `User comment: "${escaped}"` - ].join('\n') -} - -export function formatDiffComments(comments: readonly DiffComment[]): string { - return comments.map(formatDiffComment).join('\n\n') -} - export function formatMobileDiffReviewPrompt(comments: readonly DiffComment[]): string { return [ 'You are reviewing the current worktree. Address the following mobile review notes.', diff --git a/mobile/src/session/mobile-diff-review-screen-model.ts b/mobile/src/session/mobile-diff-review-screen-model.ts index 3dcaed35716..f8f1ceedf99 100644 --- a/mobile/src/session/mobile-diff-review-screen-model.ts +++ b/mobile/src/session/mobile-diff-review-screen-model.ts @@ -100,7 +100,7 @@ export function nextReviewIndexAfterMarkReviewed({ const wrappedIndex = filteredQueue.findIndex( (item) => item.key !== currentItemKey && !item.isReviewed ) - const targetIndex = nextIndex >= 0 ? nextIndex : wrappedIndex >= 0 ? wrappedIndex : null + const targetIndex = nextIndex !== -1 ? nextIndex : wrappedIndex !== -1 ? wrappedIndex : null if (targetIndex === null) { return null } diff --git a/mobile/src/session/mobile-diff-review-state.ts b/mobile/src/session/mobile-diff-review-state.ts index a9bab23471b..a9680a66df5 100644 --- a/mobile/src/session/mobile-diff-review-state.ts +++ b/mobile/src/session/mobile-diff-review-state.ts @@ -104,31 +104,6 @@ export function mergeMobileDiffReviewState( } } -export function markMobileDiffReviewFileOpened( - state: MobileDiffReviewState, - descriptor: MobileDiffReviewFileDescriptor, - now: number -): MobileDiffReviewState { - const previous = state.files[descriptor.key] - return { - ...state, - updatedAt: now, - files: { - ...state.files, - [descriptor.key]: { - key: descriptor.key, - filePath: descriptor.filePath, - oldPath: descriptor.oldPath, - scope: descriptor.scope, - reviewedAt: previous?.reviewedAt, - reviewDiffIdentity: previous?.reviewDiffIdentity, - lastOpenedAt: now, - lastSeenDiffIdentity: descriptor.diffIdentity - } - } - } -} - export function markMobileDiffReviewFileReviewed( state: MobileDiffReviewState, descriptor: MobileDiffReviewFileDescriptor, diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts index 6654a0e8689..47d56d68901 100644 --- a/mobile/src/session/mobile-native-chat-ask.test.ts +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildAskAnswerKeys, extractPendingAsk, formatAskAnswer, parseAskFromStatus -} from './mobile-native-chat-ask' +} from '../../../src/shared/native-chat-ask' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage { return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' } diff --git a/mobile/src/session/mobile-native-chat-ask.ts b/mobile/src/session/mobile-native-chat-ask.ts deleted file mode 100644 index 50766b25ee4..00000000000 --- a/mobile/src/session/mobile-native-chat-ask.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift. -export * from '../../../src/shared/native-chat-ask' diff --git a/mobile/src/session/mobile-native-chat-blocks.test.ts b/mobile/src/session/mobile-native-chat-blocks.test.ts index 57351e4a20c..27b47576bfc 100644 --- a/mobile/src/session/mobile-native-chat-blocks.test.ts +++ b/mobile/src/session/mobile-native-chat-blocks.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages, pairToolBlocks, splitNativeChatBlocks -} from './mobile-native-chat-blocks' +} from '../../../src/shared/native-chat-tool-fold' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' function msg( role: NativeChatMessage['role'], diff --git a/mobile/src/session/mobile-native-chat-blocks.ts b/mobile/src/session/mobile-native-chat-blocks.ts deleted file mode 100644 index 1cf293a79fd..00000000000 --- a/mobile/src/session/mobile-native-chat-blocks.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - isImageRefBlock, - isTextBlock, - NATIVE_CHAT_SOURCE_PRIORITY -} from '../../../src/shared/native-chat-types' -export { - foldToolMessages, - pairToolBlocks, - splitNativeChatBlocks, - type NativeChatToolPair as ToolPair -} from '../../../src/shared/native-chat-tool-fold' diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index a817b47b3ee..1a086783dbb 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -1,7 +1,11 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { + AskAnswerSelection, + AskPrompt, + parseAskFromStatus +} from '../../../src/shared/native-chat-ask' import type { detectAgentPermission } from './mobile-native-chat-permission' import type { parseAgentQuestion } from './mobile-native-chat-question' -import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts index 69277dd09ae..a19bcf54b67 100644 --- a/mobile/src/session/mobile-native-chat-diff.test.ts +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff' +import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff' describe('diffFromToolCall', () => { it('builds del/add lines from an Edit tool call', () => { @@ -36,8 +36,6 @@ describe('diffFromText', () => { expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() }) - // Parity guard: the mobile module re-exports the shared parser, so the -- / ++ - // content collision must stay fixed on both platforms. it('counts a removed -- comment line despite the --- prefix collision', () => { const lines = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---sql comment\n+new') expect(lines).toEqual([ diff --git a/mobile/src/session/mobile-native-chat-diff.ts b/mobile/src/session/mobile-native-chat-diff.ts deleted file mode 100644 index 4ace06d37d0..00000000000 --- a/mobile/src/session/mobile-native-chat-diff.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - diffFromText, - diffFromToolCall, - type NativeChatDiffLine as DiffLine, - type NativeChatDiffLineKind as DiffLineKind -} from '../../../src/shared/native-chat-diff' diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 360fd22587b..a6f599b7458 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -87,7 +87,7 @@ export function mergeLandedImagePreviewEchoes( const entries = Object.entries(previous[sessionKey] ?? {}) for (const preview of landed) { const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) - if (existingIndex >= 0) { + if (existingIndex !== -1) { entries.splice(existingIndex, 1) } entries.push([preview.messageId, preview.images]) diff --git a/mobile/src/session/mobile-native-chat-merge.test.ts b/mobile/src/session/mobile-native-chat-merge.test.ts index 61312670512..c8de9d3ff1e 100644 --- a/mobile/src/session/mobile-native-chat-merge.test.ts +++ b/mobile/src/session/mobile-native-chat-merge.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { mergeNativeChatMessages } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { mergeNativeChatMessages } from './mobile-native-chat-merge' function msg(id: string, overrides: Partial = {}): NativeChatMessage { return { diff --git a/mobile/src/session/mobile-native-chat-merge.ts b/mobile/src/session/mobile-native-chat-merge.ts deleted file mode 100644 index 6b6ffe1781e..00000000000 --- a/mobile/src/session/mobile-native-chat-merge.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Keep the established mobile import path while executing the shared desktop/mobile merger. -export { - applyAppend, - boundNativeChatWindow, - createNativeChatMerger, - mergeNativeChatMessages, - replaceList, - type NativeChatMerger -} from '../../../src/shared/native-chat-merge' diff --git a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts index 2a034fd33f3..d21d2bc00ba 100644 --- a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts +++ b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts @@ -6,7 +6,7 @@ import { createNativeChatMerger, mergeNativeChatMessages, replaceList -} from './mobile-native-chat-merge' +} from '../../../src/shared/native-chat-merge' function msg(id: string, overrides: Partial = {}): NativeChatMessage { return { diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index df1bad92815..ad7cf4b4009 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -49,15 +49,6 @@ export const styles = StyleSheet.create({ reasoning: { opacity: 0.7 }, - queued: { - opacity: 0.55 - }, - queuedTag: { - color: colors.textMuted, - fontSize: 11, - fontWeight: '600', - marginBottom: 2 - }, toolRun: { marginTop: spacing.xs }, diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts index 26aeea7680c..227488a56df 100644 --- a/mobile/src/session/mobile-native-chat-message-text.ts +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -1,5 +1,4 @@ -import type { NativeChatBlock } from '../../../src/shared/native-chat-types' -import { isTextBlock } from './mobile-native-chat-blocks' +import { isTextBlock, type NativeChatBlock } from '../../../src/shared/native-chat-types' /** Concatenate a message's text blocks into a single copyable string. Tool * calls/results and image refs are skipped — Copy is for the agent's prose. */ diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts index 6977b764e72..0369a609135 100644 --- a/mobile/src/session/mobile-native-chat-noise.test.ts +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise' function msg( role: NativeChatMessage['role'], diff --git a/mobile/src/session/mobile-native-chat-noise.ts b/mobile/src/session/mobile-native-chat-noise.ts deleted file mode 100644 index 1b15a7e414a..00000000000 --- a/mobile/src/session/mobile-native-chat-noise.ts +++ /dev/null @@ -1 +0,0 @@ -export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts index 253f8c8b85a..f74289d97ab 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.test.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -67,7 +67,6 @@ describe('useMobileNativeChatPermissionSend', () => { let respond: ((text: string) => Promise) | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() resetMobileNativeChatTerminalWritesForTests() }) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index b3d4ba1c0fd..73a45db6962 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,10 +3,10 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' +import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' +import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' -import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' -import { stripNoiseMessages } from './mobile-native-chat-noise' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' /** The centered empty-state copy for a chat with no messages, mirroring the @@ -51,7 +51,7 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat /** Assemble the list data the chat renders: the folded transcript, then a * synthetic bubble for the streaming text the gate let through, then the - * route-owned optimistic "queued" messages at the tail. */ + * route-owned accepted optimistic messages at the tail. */ export function buildMobileNativeChatTransientData({ folded, streaming, diff --git a/mobile/src/session/mobile-native-chat-send-classification.ts b/mobile/src/session/mobile-native-chat-send-classification.ts index 18568c05789..2a34efabdd2 100644 --- a/mobile/src/session/mobile-native-chat-send-classification.ts +++ b/mobile/src/session/mobile-native-chat-send-classification.ts @@ -2,7 +2,7 @@ // (src/renderer/src/components/native-chat/NativeChatComposer.tsx): slash/skill // sends are TUI control actions, not chat turns — they never echo as a user // bubble, because the transcript will never contain a matching user turn and -// the optimistic echo would sit at "Queued" forever. +// the optimistic echo would never reconcile. import { getNativeChatAgentProfile, diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ccdacfd7b3e..cc70e2e3b0f 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' @@ -7,10 +7,13 @@ import { openMobileNativeChatSendBudget, clearMobileNativeChatInput, sendMobileNativeChatMessage, - sendMobileNativeChatMessageWithOutcome + sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome } from './mobile-native-chat-send' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' +afterEach(() => vi.useRealTimers()) + function clientWithResponse(response: unknown): RpcClient { return { sendRequest: vi.fn().mockResolvedValue(response) @@ -290,6 +293,65 @@ describe('sendMobileNativeChatMessage', () => { }) }) +describe('typeMobileNativeChatCommandWithOutcome', () => { + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model' + }) + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe('accepted') + expect( + vi.mocked(client.sendRequest).mock.calls.map((call) => { + const params = call[1] as { text: string; enter: boolean } + return { text: params.text, enter: params.enter } + }) + ).toEqual( + ['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({ + text, + enter: false + })) + ) + }) + + it('retires a parked launch draft only with the final typed Enter', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model', + resolvedLaunchDraft: { text: 'seed', createdAt: 7 } + }) + await vi.runAllTimersAsync() + await result + + const params = vi.mocked(client.sendRequest).mock.calls.map((call) => call[1]) as Array<{ + text: string + resolvedLaunchDraft?: { text: string; createdAt: number } + }> + expect(params.slice(0, -1).every((entry) => entry.resolvedLaunchDraft === undefined)).toBe(true) + expect(params.at(-1)).toMatchObject({ + text: '\r', + resolvedLaunchDraft: { text: 'seed', createdAt: 7 } + }) + }) +}) + describe('clearMobileNativeChatInput', () => { const accepted = { id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ce432c3efb9..28f91759168 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' type MobileTerminalClient = { id: string @@ -93,6 +94,35 @@ export async function sendMobileNativeChatMessage( return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' } +export async function typeMobileNativeChatCommandWithOutcome(args: { + client: RpcClient + terminal: string + command: string + resolvedLaunchDraft?: { text: string; createdAt: number } + mobileClient?: MobileTerminalClient + deadline?: number +}): Promise { + let writeIndex = 0 + return typeAgentTuiCommand({ + command: args.command, + write: (key) => { + const isSubmit = writeIndex === args.command.length + 1 + writeIndex += 1 + return sendMobileNativeChatMessageWithOutcome({ + client: args.client, + terminal: args.terminal, + text: key, + enter: false, + ...(isSubmit && args.resolvedLaunchDraft + ? { resolvedLaunchDraft: args.resolvedLaunchDraft } + : {}), + ...(args.mobileClient ? { mobileClient: args.mobileClient } : {}), + ...(args.deadline === undefined ? {} : { deadline: args.deadline }) + }) + } + }) +} + /** * Clear the agent's input line as its OWN write, before any body. * diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts index b4ffe542266..1f00bc5ec15 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' function message(id: string): NativeChatMessage { diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts index bef3dc5ef27..a3cef3d5464 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -1,5 +1,6 @@ +import { applyAppend, replaceList } from '../../../src/shared/native-chat-merge' +import type { NativeChatMerger } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge' export type MobileNativeChatStreamFrame = { type?: string diff --git a/mobile/src/session/mobile-native-chat-tool-summary.test.ts b/mobile/src/session/mobile-native-chat-tool-summary.test.ts index 8e65ccf1958..f7a976b92b8 100644 --- a/mobile/src/session/mobile-native-chat-tool-summary.test.ts +++ b/mobile/src/session/mobile-native-chat-tool-summary.test.ts @@ -4,7 +4,7 @@ import { describeToolInput, summarizeToolInput, toolFilePath -} from './mobile-native-chat-tool-summary' +} from '../../../src/shared/native-chat-tool-summary' describe('summarizeToolInput', () => { it('passes short strings through, collapsing whitespace', () => { @@ -59,7 +59,7 @@ describe('toolFilePath', () => { }) describe('describeToolInput', () => { - it('is re-exported and labels rows with the path or primary argument', () => { + it('labels rows with the path or primary argument', () => { expect(describeToolInput({ file_path: 'src/a.ts', offset: 3 })).toBe('src/a.ts') expect(describeToolInput('{"cmd":"git status"}')).toBe('git status') }) diff --git a/mobile/src/session/mobile-native-chat-tool-summary.ts b/mobile/src/session/mobile-native-chat-tool-summary.ts deleted file mode 100644 index 0ee6576d767..00000000000 --- a/mobile/src/session/mobile-native-chat-tool-summary.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { - briefToolArg, - countToolCalls, - createToolInputDisplay, - describeToolInput, - formatToolInput, - isStructuredToolInput, - MAX_TOOL_DETAIL_LENGTH, - summarizeToolInput, - summarizeToolRun, - toolFilePath, - truncateToolDetail -} from '../../../src/shared/native-chat-tool-summary' diff --git a/mobile/src/session/mobile-session-review-comment-styles.ts b/mobile/src/session/mobile-session-review-comment-styles.ts index 8d8b2d11ccd..a3b80615446 100644 --- a/mobile/src/session/mobile-session-review-comment-styles.ts +++ b/mobile/src/session/mobile-session-review-comment-styles.ts @@ -159,6 +159,11 @@ export const mobileSessionReviewCommentStyles = StyleSheet.create({ paddingHorizontal: spacing.md, paddingVertical: spacing.xs }, + markdownKeyboardDismissButton: { + width: 34, + justifyContent: 'center', + paddingHorizontal: 0 + }, markdownSaveButton: { backgroundColor: colors.bgRaised }, diff --git a/mobile/src/session/use-initial-session-terminal-autocreate.test.ts b/mobile/src/session/use-initial-session-terminal-autocreate.test.ts index 46a7bb859fa..5e4c8d425a4 100644 --- a/mobile/src/session/use-initial-session-terminal-autocreate.test.ts +++ b/mobile/src/session/use-initial-session-terminal-autocreate.test.ts @@ -52,7 +52,6 @@ describe('useInitialSessionTerminalAutoCreate', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true stateRef = { current: { autoCreatedForWorktree: null, sawSessionTabs: false } } diff --git a/mobile/src/session/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts index 45e64205d1d..ad15bda3cb7 100644 --- a/mobile/src/session/use-live-worktree-name.test.ts +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -13,18 +13,6 @@ vi.mock('expo-router', async () => { } }) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - describe('useLiveWorktreeName request volume', () => { let renderer: ReactTestRenderer | null = null let eventListener: ((payload: unknown) => void) | null = null @@ -54,15 +42,10 @@ describe('useLiveWorktreeName request volume', () => { return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(Harness)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) } async function emitEvent(payload: unknown): Promise { @@ -75,7 +58,6 @@ describe('useLiveWorktreeName request volume', () => { beforeEach(() => { vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true eventListener = null sendRequest.mockClear().mockResolvedValue({ id: 'worktree-show', @@ -179,15 +161,10 @@ describe('useLiveWorktreeName request volume', () => { return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(FloatingHarness)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(FloatingHarness)) + await Promise.resolve() + }) await act(async () => { await vi.advanceTimersByTimeAsync(30_000) }) @@ -214,7 +191,6 @@ describe('useLiveWorktreeName request volume', () => { return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create( @@ -241,7 +217,6 @@ describe('useLiveWorktreeName request volume', () => { ) }) } finally { - restoreConsoleError() act(() => renderer?.unmount()) } @@ -263,15 +238,10 @@ describe('useLiveWorktreeName request volume', () => { return null } const mount = async (): Promise => { - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(VerdictHarness)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(VerdictHarness)) + await Promise.resolve() + }) } await mount() diff --git a/mobile/src/session/use-missing-worktree-bounce.test.ts b/mobile/src/session/use-missing-worktree-bounce.test.ts index 209486bc8d1..df1bccc5678 100644 --- a/mobile/src/session/use-missing-worktree-bounce.test.ts +++ b/mobile/src/session/use-missing-worktree-bounce.test.ts @@ -27,7 +27,6 @@ describe('useMissingWorktreeBounce', () => { const bounce = vi.fn() beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true bounce.mockReset() renderer = null }) diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts index a69d66accdd..9b11ad70c6d 100644 --- a/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts @@ -7,16 +7,7 @@ type Gate = (targetHandle: string) => Promise describe('useMobileAttachmentInputLeaseGate', () => { let renderer: ReactTestRenderer | null = null - let errorSpy: ReturnType | null = null - beforeEach(() => { - const original = console.error - errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...(args as Parameters)) - }) vi.useFakeTimers() }) @@ -24,7 +15,6 @@ describe('useMobileAttachmentInputLeaseGate', () => { act(() => renderer?.unmount()) renderer = null vi.useRealTimers() - errorSpy?.mockRestore() }) function renderGate(args: { diff --git a/mobile/src/session/use-mobile-default-session-view-preference.test.ts b/mobile/src/session/use-mobile-default-session-view-preference.test.ts index eb2624efd36..084164b6a40 100644 --- a/mobile/src/session/use-mobile-default-session-view-preference.test.ts +++ b/mobile/src/session/use-mobile-default-session-view-preference.test.ts @@ -30,7 +30,6 @@ describe('useMobileDefaultSessionViewPreference', () => { let preference: MobileDefaultSessionViewPreference | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal') vi.mocked(saveDefaultSessionView).mockReset().mockResolvedValue(undefined) }) diff --git a/mobile/src/session/use-mobile-diff-review-controller.test.ts b/mobile/src/session/use-mobile-diff-review-controller.test.ts index 7c0a80455bc..5056b3cb546 100644 --- a/mobile/src/session/use-mobile-diff-review-controller.test.ts +++ b/mobile/src/session/use-mobile-diff-review-controller.test.ts @@ -63,7 +63,6 @@ describe('useMobileDiffReviewController', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true loadSnapshot.mockReset() }) diff --git a/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts b/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts index 3245c228254..4abb7443188 100644 --- a/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts +++ b/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts @@ -57,7 +57,6 @@ describe('useMobileDiffReviewDiffLoading', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true loadDiff.mockReset() }) diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts index f7594fd6e57..1d06190948c 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts @@ -52,7 +52,6 @@ describe('useMobileDiffReviewSendActions', () => { let saveCommentsAndReviewState: ReturnType beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() setActionError = vi.fn() setSendSheet = vi.fn() @@ -81,20 +80,9 @@ describe('useMobileDiffReviewSendActions', () => { async function mount(client: RpcClient): Promise { mountedClient = client - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness)) }) - try { - await act(async () => { - renderer = create(createElement(Harness)) - }) - } finally { - consoleSpy.mockRestore() - } } it('heals a marked terminal BEFORE submitting the notes', async () => { diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts index 6f48b2a771d..0cfa617f269 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -19,7 +19,6 @@ describe('useMobileFileTapHandlers', () => { let handlers: Handlers | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true push.mockClear() }) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index b296ea4851b..82db799deed 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -1,11 +1,11 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AskPrompt } from '../../../src/shared/native-chat-ask' import type { AgentType } from '../../../src/shared/native-chat-types' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' -import type { AskPrompt } from './mobile-native-chat-ask' import { isMobileNativeChatInputStale, markMobileNativeChatInputStale, @@ -54,7 +54,6 @@ describe('useMobileNativeChatAnswerSend', () => { beforeEach(() => { onAccepted = vi.fn() vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() resetMobileNativeChatTerminalWritesForTests() }) @@ -93,20 +92,9 @@ describe('useMobileNativeChatAnswerSend', () => { mountedClient = client mountedOnSendError = onSendError mountedAgent = agent - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { enabled: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { enabled: true })) - }) - } finally { - consoleSpy.mockRestore() - } } async function setEnabled(enabled: boolean): Promise { diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index b893f7ae071..40925931d3c 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -1,6 +1,4 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' -import type { RpcClient } from '../transport/rpc-client' -import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' import { buildAskAnswerKeys, buildCodexAskAnswerKeys, @@ -8,7 +6,9 @@ import { hasAskAnswer, type AskAnswerSelection, type AskPrompt -} from './mobile-native-chat-ask' +} from '../../../src/shared/native-chat-ask' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' import { openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts index 460ffca54cd..49d69ab4c3b 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -1,7 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AskPrompt } from './mobile-native-chat-ask' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AskPrompt } from '../../../src/shared/native-chat-ask' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' describe('useMobileNativeChatAskDismiss', () => { @@ -10,7 +10,6 @@ describe('useMobileNativeChatAskDismiss', () => { let renders = 0 beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true renders = 0 }) @@ -45,20 +44,9 @@ describe('useMobileNativeChatAskDismiss', () => { } async function mount(props: Parameters[0]): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, props)) }) - try { - await act(async () => { - renderer = create(createElement(Harness, props)) - }) - } finally { - consoleSpy.mockRestore() - } } async function update(props: Parameters[0]): Promise { diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts index 35d6cf1c7c8..beb46b45d95 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { nativeChatAskDismissKey, type AskPrompt } from './mobile-native-chat-ask' +import { nativeChatAskDismissKey, type AskPrompt } from '../../../src/shared/native-chat-ask' type AskDismissal = { sessionKey: string | null; askKey: string } type DetectedAsk = { sessionKey: string | null; askKey: string | null } diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a9cd1201639..c04b560798d 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -127,24 +127,12 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() resetMobileNativeChatStaleInputForTests() captureSendOrigin.mockReturnValue(ORIGIN) - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness)) }) - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - spy.mockRestore() - } }) afterEach(() => { act(() => renderer?.unmount()) @@ -364,24 +352,12 @@ describe('useMobileNativeChatController launch-draft wiring', () => { } function render(tab: unknown): void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness, { tab })) }) - try { - act(() => { - renderer = create(createElement(Harness, { tab })) - }) - } finally { - spy.mockRestore() - } } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true draftsArgs.length = 0 viewMode.isTabChatView = () => true sessionState.messages = [] @@ -487,25 +463,13 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true viewMode.isTabChatView = () => true setTranscript('ready') promptsState.ask = PROMPT promptsState.detectedAsk = PROMPT - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness)) }) - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - spy.mockRestore() - } }) afterEach(() => { @@ -757,22 +721,10 @@ describe('useMobileNativeChatController streaming scope', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true viewMode.isTabChatView = () => true - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness)) }) - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - spy.mockRestore() - } }) afterEach(() => { diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 91c84814c41..665d3ac1f2f 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' @@ -23,10 +23,6 @@ describe('useMobileNativeChatDrafts launch draft', () => { let renderer: ReactTestRenderer | null = null let state: DraftState | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -65,20 +61,9 @@ describe('useMobileNativeChatDrafts launch draft', () => { } async function mount(tabId: string): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { tabId })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { tabId })) - }) - } finally { - consoleSpy.mockRestore() - } } it('prefills the composer from a host launch draft exactly once', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index 154ef3fb870..db8e76696d0 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' @@ -30,10 +30,6 @@ describe('useMobileNativeChatDrafts', () => { let renderer: ReactTestRenderer | null = null let state: DraftState | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -69,20 +65,9 @@ describe('useMobileNativeChatDrafts', () => { } async function mount(tabId: string): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { tabId })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { tabId })) - }) - } finally { - consoleSpy.mockRestore() - } } async function switchTo(tabId: string): Promise { diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts index e0500019844..93db87c8b32 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.test.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -15,17 +15,6 @@ function rpcSuccess(files: string[]): Awaited void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() -} - describe('useMobileNativeChatFileSearch', () => { let renderer: ReactTestRenderer | null = null let state: SearchState | null = null @@ -35,19 +24,13 @@ describe('useMobileNativeChatFileSearch', () => { state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' }) return null } - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(Harness)) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(Harness)) + }) } beforeEach(() => { vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true state = null }) diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index a57e1c7520c..cb8522bae7a 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -87,7 +87,6 @@ describe('useMobileNativeChatImageAttachments', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true pick.mockReset() // Stale markers and write locks live at module scope (they outlive the // screen), so they also outlive a test. @@ -101,20 +100,9 @@ describe('useMobileNativeChatImageAttachments', () => { }) function mount(args: HookArgs): void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness, { args })) }) - try { - act(() => { - renderer = create(createElement(Harness, { args })) - }) - } finally { - spy.mockRestore() - } } function update(args: HookArgs): void { diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts index c7e60170f0a..7491f74500f 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' type Lease = ReturnType @@ -9,10 +9,6 @@ describe('useMobileNativeChatInputLease', () => { let renderer: ReactTestRenderer | null = null let lease: Lease | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -25,65 +21,43 @@ describe('useMobileNativeChatInputLease', () => { } it('unlocks only after acknowledgement and clears on disconnect', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { connected: true })) - }) - expect(lease?.ready).toBe(false) - expect(lease?.lockReason).toBe('waiting') - act(() => lease?.markReady('terminal')) - expect(lease?.ready).toBe(true) - expect(lease?.lockReason).toBeNull() + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('waiting') + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + expect(lease?.lockReason).toBeNull() - act(() => lease?.clear()) - expect(lease?.ready).toBe(false) - act(() => lease?.markReady('terminal')) - expect(lease?.ready).toBe(true) + act(() => lease?.clear()) + expect(lease?.ready).toBe(false) + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) - await act(async () => renderer?.update(createElement(Harness, { connected: false }))) - expect(lease?.ready).toBe(false) - expect(lease?.lockReason).toBe('disconnected') - } finally { - consoleSpy.mockRestore() - } + await act(async () => renderer?.update(createElement(Harness, { connected: false }))) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('disconnected') }) it('reports whether a clear actually dropped a lease', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { connected: true })) - }) - // The route reads this to tell a real teardown from one React never sees. - expect(lease?.clear('terminal')).toBe(false) - expect(lease?.clear()).toBe(false) + // The route reads this to tell a real teardown from one React never sees. + expect(lease?.clear('terminal')).toBe(false) + expect(lease?.clear()).toBe(false) - act(() => lease?.markReady('terminal')) - let dropped: boolean | undefined - act(() => { - dropped = lease?.clear('terminal') - }) - expect(dropped).toBe(true) - expect(lease?.ready).toBe(false) - expect(lease?.clear('terminal')).toBe(false) + act(() => lease?.markReady('terminal')) + let dropped: boolean | undefined + act(() => { + dropped = lease?.clear('terminal') + }) + expect(dropped).toBe(true) + expect(lease?.ready).toBe(false) + expect(lease?.clear('terminal')).toBe(false) - act(() => lease?.markReady('other')) - expect(lease?.clear()).toBe(true) - } finally { - consoleSpy.mockRestore() - } + act(() => lease?.markReady('other')) + expect(lease?.clear()).toBe(true) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 0ceda5714da..dd67cf46da8 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendWithOutcome = vi.fn() const clearInputWrite = vi.fn() +const typeCommandWithOutcome = vi.fn() vi.mock('./mobile-native-chat-send', () => ({ sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args), clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args), openMobileNativeChatSendBudget: () => Date.now() + 15_000, MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, @@ -80,11 +82,12 @@ describe('useMobileNativeChatMessageSend', () => { (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true sendWithOutcome.mockReset() sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + typeCommandWithOutcome.mockReset() + typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() @@ -216,24 +219,46 @@ describe('useMobileNativeChatMessageSend', () => { expect(onCommandSend).toHaveBeenCalledWith('/clear') }) - it('never creates an optimistic echo for an unknown slash token', async () => { - // `/model` is not in Claude's autocomplete catalog, but the session-option - // recorder still recognizes it without claiming a generic command ran. - mount(() => null) - await act(async () => { - await api!.send('/model sonnet') - }) - expect(acceptSend).not.toHaveBeenCalled() - expect(onCommandSend).toHaveBeenCalledWith('/model sonnet') - }) + it.each(['claude', 'openclaude'] as const)( + 'keeps %s composer slash sends pasted', + async (agent) => { + // `/model` is not in Claude's autocomplete catalog, but the session-option + // recorder still recognizes it without claiming a generic command ran. + mount(() => null, agent) + await act(async () => { + await api!.send('/model sonnet') + }) + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).toHaveBeenCalledWith('/model sonnet') + expect(sendWithOutcome).toHaveBeenCalledOnce() + expect(typeCommandWithOutcome).not.toHaveBeenCalled() + } + ) - it('classifies per agent: /model is a catalog command for Codex', async () => { + it('types Codex composer slash sends instead of pasting them', async () => { mount(() => null, 'codex') await act(async () => { await api!.send('/model') }) expect(acceptSend).not.toHaveBeenCalled() expect(onCommandSend).toHaveBeenCalledWith('/model') + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + + it('keeps Codex skill sends pasted', async () => { + mount(() => null, 'codex') + await act(async () => { + await api!.send('$ref-oss') + }) + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).toHaveBeenCalledWith('$ref-oss') + expect(sendWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ text: '$ref-oss', terminal: 'term' }) + ) + expect(typeCommandWithOutcome).not.toHaveBeenCalled() }) it('holds only chat sends for transcript confirmation on a lost ack', async () => { @@ -249,16 +274,44 @@ describe('useMobileNativeChatMessageSend', () => { expect(holdUnconfirmedSend).toHaveBeenCalledTimes(1) }) - it('dispatchCommand surfaces the outcome without echo or composer sync', async () => { - mount(() => ({ text: DRAFT, createdAt: 1 })) + it.each(['claude', 'openclaude'] as const)( + 'keeps %s session-option commands pasted', + async (agent) => { + mount(() => ({ text: DRAFT, createdAt: 1 }), agent) + let outcome: string | undefined + await act(async () => { + outcome = await api!.dispatchCommand('/model sonnet', { delivery: 'type' }) + }) + expect(outcome).toBe('accepted') + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).not.toHaveBeenCalled() + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + } + ) + + it('routes typed picker commands around the pasted composer-text send', async () => { + mount(() => null, 'codex') let outcome: string | undefined await act(async () => { - outcome = await api!.dispatchCommand('/model sonnet') + outcome = await api!.dispatchCommand('/model', { delivery: 'type' }) }) expect(outcome).toBe('accepted') - expect(acceptSend).not.toHaveBeenCalled() - expect(onCommandSend).not.toHaveBeenCalled() - expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + + it('types Codex session-option commands without caller delivery metadata', async () => { + mount(() => null, 'codex') + await act(async () => { + await api!.dispatchCommand('/model') + }) + + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() }) it('binds classification to the agent that started the send', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 1d59e25431c..17f34b86d27 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -4,8 +4,11 @@ import { clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog' +import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' import { @@ -32,7 +35,10 @@ export type MobileNativeChatMessageSend = { answerQuestion: (text: string) => Promise /** Session-option command dispatch (e.g. `/model sonnet`) — never touches the * composer draft; callers need the outcome to track dispatched state. */ - dispatchCommand: (text: string) => Promise + dispatchCommand: ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise } /** The native-chat send seam: one write path shared by composer sends, image @@ -150,40 +156,42 @@ export function useMobileNativeChatMessageSend(args: { return 'rejected' } } - const outcome = await sendMobileNativeChatMessageWithOutcome({ - client, - terminal: handle, - text, - // Why: pre-clear only when nothing was deliberately pasted first. The heal - // above fires only for terminals a mobile image paste marked, so a desktop - // launch-draft prefill parked on the input line would otherwise glue onto - // this message. An image send already led its own paste with Ctrl+U, and a - // second one here would wipe the image it just pasted (desktop's image path - // likewise clears once, before the paste, and never again). - // - // Also skipped once the dedicated clear above ran: the line is already - // empty, and a Ctrl+U written immediately before body text in the SAME - // write reaches the agent as a literal control character rather than a - // keypress (observed live as a stray \x15 heading the received message). - clearInputFirst: !images?.length && !seededLaunchDraft, - ...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number' - ? { - resolvedLaunchDraft: { - text: seededLaunchDraft.text, - createdAt: seededLaunchDraft.createdAt - } - } - : {}), - deadline, - ...(deviceTokenRef.current - ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } - : {}) - }) + const classification = classifyMobileNativeChatSend(agent, text) + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + const resolvedLaunchDraft = + syncComposer && typeof seededLaunchDraft?.createdAt === 'number' + ? { text: seededLaunchDraft.text, createdAt: seededLaunchDraft.createdAt } + : undefined + const outcome = + agent === 'codex' && + classification !== 'chat' && + isSlashCommandDraft(text) && + !images?.length + ? await typeMobileNativeChatCommandWithOutcome({ + client, + terminal: handle, + command: text, + ...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}), + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + : await sendMobileNativeChatMessageWithOutcome({ + client, + terminal: handle, + text, + // Why: an image send already cleared before its paste; a second + // clear here would wipe the image before submission. + clearInputFirst: !images?.length && !seededLaunchDraft, + ...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}), + deadline, + ...(mobileClient ? { mobileClient } : {}) + }) // Why (desktop parity): a slash/skill send dispatches into the agent's own // TUI, not the conversation — the transcript never echoes it as a user - // turn, so an optimistic bubble would sit at "Queued" forever and the + // turn, so an optimistic bubble would never reconcile and the // unconfirmed hold could never observe a landing. - const classification = classifyMobileNativeChatSend(agent, text) if (outcome === 'unknown') { if (classification === 'chat') { // Why: an ack-lost send usually WAS delivered (issue seen on cellular @@ -269,12 +277,41 @@ export function useMobileNativeChatMessageSend(args: { // spaces a send's body and its Enter ~500ms apart — so without this lock an // apply lands between them and is submitted as part of the user's prompt. const dispatchCommand = useCallback( - async (text: string): Promise => { + async ( + text: string, + _options?: { delivery?: CatalogCommandDelivery } + ): Promise => { const terminal = handleRef.current if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { return 'rejected' } try { + if (agentRef.current === 'codex') { + if (!client || !terminal || !enabled) { + return 'rejected' + } + const deadline = openMobileNativeChatSendBudget() + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + if ( + !(await healMobileNativeChatStaleInput({ + client, + terminal, + deviceToken: deviceTokenRef.current, + deadline + })) + ) { + return 'rejected' + } + return typeMobileNativeChatCommandWithOutcome({ + client, + terminal, + command: text, + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + } return await sendMessage(text, undefined, false, false) } finally { if (terminal) { @@ -282,7 +319,7 @@ export function useMobileNativeChatMessageSend(args: { } } }, - [handleRef, sendMessage] + [client, deviceTokenRef, enabled, handleRef, sendMessage] ) return { send, sendWithOutcome, answerQuestion, dispatchCommand } diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 61b831dafd2..35acecbe844 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { parseAskFromStatus, resolveNativeChatAsk } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask' import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts index 41d08c3c32c..b7e2c5daab3 100644 --- a/mobile/src/session/use-mobile-native-chat-readability.test.ts +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -10,7 +10,6 @@ describe('useMobileNativeChatReadability', () => { let readable = false beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true readable = false }) @@ -34,21 +33,10 @@ describe('useMobileNativeChatReadability', () => { readable = useMobileNativeChatReadability(client, worktreeId) return null } - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() }) - try { - await act(async () => { - renderer = create(createElement(Harness)) - await Promise.resolve() - }) - } finally { - consoleSpy.mockRestore() - } return sendRequest } @@ -89,32 +77,21 @@ describe('useMobileNativeChatReadability', () => { readable = useMobileNativeChatReadability(client, worktreeId) return null } - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' })) + await Promise.resolve() }) - try { - await act(async () => { - renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' })) - await Promise.resolve() - }) - expect(readable).toBe(true) + expect(readable).toBe(true) - act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' }))) - expect(readable).toBe(false) - await act(async () => { - resolveNext({ - ok: true, - result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] } - }) - await Promise.resolve() + act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' }))) + expect(readable).toBe(false) + await act(async () => { + resolveNext({ + ok: true, + result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] } }) - expect(readable).toBe(false) - } finally { - consoleSpy.mockRestore() - } + await Promise.resolve() + }) + expect(readable).toBe(false) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-send-error.test.ts b/mobile/src/session/use-mobile-native-chat-send-error.test.ts index a22eee3a0a4..49ca07198d0 100644 --- a/mobile/src/session/use-mobile-native-chat-send-error.test.ts +++ b/mobile/src/session/use-mobile-native-chat-send-error.test.ts @@ -31,31 +31,13 @@ describe('useMobileNativeChatSendError', () => { return apiRef.current } - /** react-test-renderer logs a deprecation notice on every render; keep real errors. */ - function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() - } - async function render(scopeKey: string | null = 'terminal-1'): Promise { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(Harness, { scopeKey })) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(Harness, { scopeKey })) + }) } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true apiRef.current = null showToast.mockClear() vi.useFakeTimers() @@ -117,26 +99,16 @@ describe('useMobileNativeChatSendError', () => { await act(async () => api().show('a')) expect(api().message).toBe('a') - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) - }) - } finally { - restore() - } + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) expect(api().message).toBeNull() }) it('falls back to the toast when the banner is not mounted', async () => { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(Harness, { scopeKey: 'terminal-1', bannerMounted: false })) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(Harness, { scopeKey: 'terminal-1', bannerMounted: false })) + }) // A deferred failure landing after the user left chat must still be seen. await act(async () => api().show('Delivery unconfirmed')) @@ -149,14 +121,9 @@ describe('useMobileNativeChatSendError', () => { // Captured while tab A was live; a 20s unconfirmed send resolves much later. const showFromTabA = api().show - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) - }) - } finally { - restore() - } + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) await act(async () => showFromTabA('Message not sent')) // The banner belongs to terminal-2 now, so A's failure must not paint there. @@ -168,14 +135,9 @@ describe('useMobileNativeChatSendError', () => { await render('terminal-1') const clearFromTabA = api().clear - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) - }) - } finally { - restore() - } + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) await act(async () => api().show('b')) // An accepted card action from tab A resolving late must not retire B's warning. await act(async () => clearFromTabA()) diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index 246c3021948..a587f3c5747 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => { let renderer: ReactTestRenderer | null = null let api: MobileNativeChatSessionOptionsController | null = null let hookArgs: HookArgs - const dispatchCommand = vi.fn<(command: string) => Promise>() + const dispatchCommand = vi.fn() const onAgentPicker = vi.fn() function Probe(): null { @@ -44,7 +44,6 @@ describe('useMobileNativeChatSessionOptions', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true clearMobileSessionOptionRecordsForTests() dispatchCommand.mockReset() dispatchCommand.mockResolvedValue('accepted') @@ -98,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => { expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) }) - it('applies Codex model changes through the native command', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mount({ agent: 'codex' }) - expect(api!.snapshot[0]?.action).toBeUndefined() + expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' }) await act(async () => { - await api!.setOption('model', 'gpt-5.5') + await api!.invokeAction('model') }) - expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5') - expect(onAgentPicker).not.toHaveBeenCalled() + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() + }) + + it('types the Codex effort picker command', async () => { + mount({ agent: 'codex', reportedModel: 'gpt-5.5' }) + await act(async () => { + await api!.invokeAction('effort') + }) + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() }) it('seeds the current model from a hook-reported provider model', () => { diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 09779756631..66a929aeb56 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { getAgentSessionOptionCatalog, type AgentSessionOptionCatalog, + type CatalogCommandDelivery, type CatalogModel } from '../../../src/shared/agent-session-option-catalog' import type { @@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: { scopeKey: string | null /** Provider model from live agent status, when the hook reported one. */ reportedModel: string | null - dispatchCommand: (command: string) => Promise + dispatchCommand: ( + command: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise /** A model change that must happen in the agent's own TUI picker was * dispatched — bring the terminal view forward. */ onAgentPicker?: () => void @@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: { ?.options.find((option) => option.id === id)?.apply const midSession = apply?.midSession if (midSession?.kind === 'agent-picker') { - const outcome = await dispatchCommand(midSession.command) + const outcome = midSession.delivery + ? await dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : await dispatchCommand(midSession.command) if (outcome === 'rejected') { return false } diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 8c685d00202..2ff97a3370c 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -24,7 +24,6 @@ describe('useMobileNativeChatSession', () => { let emit: (frame: unknown) => void = () => {} beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true state = null }) @@ -45,20 +44,9 @@ describe('useMobileNativeChatSession', () => { } async function mount(client: RpcClient): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { client })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { client })) - }) - } finally { - consoleSpy.mockRestore() - } } it('drops an older-page response captured before transcript replacement', async () => { @@ -121,7 +109,10 @@ describe('useMobileNativeChatSession', () => { await Promise.resolve() }) - expect(state?.messages).toEqual([]) + // The retained window keeps rendering while the source is gone; what must + // never land is the page that resolved after it disappeared. + expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-page') + expect(state?.messages).toHaveLength(40) expect(state?.status).toBe('idle') expect(state?.loadingEarlier).toBe(false) }) @@ -445,7 +436,6 @@ describe('useMobileNativeChatSession transcriptLoading', () => { }[] = [] beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true renders.length = 0 }) @@ -482,20 +472,9 @@ describe('useMobileNativeChatSession transcriptLoading', () => { } async function mountAt(client: RpcClient | null, sessionId: string | null): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { client, sessionId })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { client, sessionId })) - }) - } finally { - consoleSpy.mockRestore() - } } it('reports loading on the very first render, before the subscription effect runs', async () => { @@ -581,6 +560,30 @@ describe('useMobileNativeChatSession transcriptLoading', () => { }) }) + it('keeps the retained transcript rendered when the stream reports an error', async () => { + // A transient read failure must not blank a conversation the user is looking + // at; the last settled list for this identity stays until a read supersedes it. + let emitFrame: (frame: unknown) => void = () => {} + const client = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + emitFrame = onData + onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + return () => {} + }) + } as unknown as RpcClient + await mountAt(client, 'session-a') + expect(renders.at(-1)).toMatchObject({ status: 'ready', ids: ['a-1'] }) + + renders.length = 0 + await act(async () => emitFrame({ type: 'error', message: 'stream broke' })) + + expect(renders.at(-1)).toMatchObject({ + status: 'error', + transcriptLoading: false, + ids: ['a-1'] + }) + }) + it('never holds a cached list across a host/workspace source change', async () => { const firstClient = { subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 6305d0418d9..a79b5648dd7 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -3,10 +3,10 @@ import { createNativeChatTranscriptRetention, encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' -import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' import { applyMobileNativeChatStreamFrame, type MobileNativeChatStreamFrame @@ -273,11 +273,12 @@ export function useMobileNativeChatSession(args: { })() }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + // Held for any unsettled read, not just an in-flight one: a stream error or a + // dropped client would otherwise trade the conversation for an error card. const visibleMessages = transcriptRetentionRef.current.visible({ identity, messages, - settled: settledReady, - loading: status === 'loading' + settled: settledReady }) return { diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts index 2a5460b67fa..bdc405cb57d 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.test.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -14,7 +14,6 @@ describe('useMobileNativeChatStop', () => { beforeEach(() => { vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true sendRequest.mockReset().mockResolvedValue({ ok: true, result: { send: { accepted: true } } diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts index fdfb5041402..ce282fcd5b1 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -17,7 +17,6 @@ describe('useMobileNativeChatTerminalStream', () => { const hasTabsRecoveryNeedRef = { current: (): boolean => false } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true subscriptionsRef.current = new Map([['terminal-1', () => {}]]) subscribingRef.current = new Set() webReadyRef.current = new Set(['terminal-1']) @@ -85,113 +84,73 @@ describe('useMobileNativeChatTerminalStream', () => { } it('replaces output with a lease-only stream while covered, then restores output', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: false })) + }) + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + expect(harnessRenderCount).toBe(1) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: false })) - }) - await act(async () => { - notifyWebReadyRef.current('terminal-1', false) - }) - expect(harnessRenderCount).toBe(1) - await act(async () => { - renderer?.update(createElement(Harness, { showNativeChat: true })) - }) - expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1') - expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1') - expect(initializedRef.current.has('terminal-1')).toBe(false) + expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(initializedRef.current.has('terminal-1')).toBe(false) - await act(async () => { - renderer?.update(createElement(Harness, { showNativeChat: false })) - }) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) - expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - } finally { - consoleSpy.mockRestore() - } + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') }) it('re-subscribes a covered stream torn down under chat (#10681)', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + unsubscribe.mockClear() + // What a terminal.list prune / `end` frame does to the lease-only stream: + // the subscription is gone and the lease drops with it. + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true, leaseReady: false })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - subscribe.mockClear() - unsubscribe.mockClear() - // What a terminal.list prune / `end` frame does to the lease-only stream: - // the subscription is gone and the lease drops with it. - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update(createElement(Harness, { showNativeChat: true, leaseReady: false })) - }) - expect(subscribe).toHaveBeenCalledOnce() - expect(subscribe).toHaveBeenCalledWith('terminal-1') - // Pins the rearm branch specifically: without it the action falls through to - // the resume tail, which also subscribes — but drops coverage on the way. - expect(unsubscribe).not.toHaveBeenCalled() - } finally { - consoleSpy.mockRestore() - } + expect(subscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledWith('terminal-1') + // Pins the rearm branch specifically: without it the action falls through to + // the resume tail, which also subscribes — but drops coverage on the way. + expect(unsubscribe).not.toHaveBeenCalled() }) it('stops rearming a handle whose stream never comes back (#10681)', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) }) - try { + subscribe.mockClear() + // A dead PTY answers every subscribe with `subscribed`+`end`, so the stream is + // gone again on each pass. Unbounded, that is a ~10s resubscribe loop. + for (let revision = 1; revision <= 6; revision += 1) { + subscriptionsRef.current.delete('terminal-1') await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) }) - subscribe.mockClear() - // A dead PTY answers every subscribe with `subscribed`+`end`, so the stream is - // gone again on each pass. Unbounded, that is a ~10s resubscribe loop. - for (let revision = 1; revision <= 6; revision += 1) { - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update( - createElement(Harness, { - showNativeChat: true, - leaseReady: false, - streamRevision: revision - }) - ) - }) - } - - expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) - } finally { - consoleSpy.mockRestore() } + + expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) }) it('does not charge the rearm budget for a subscribe its own gates turned away', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) try { await act(async () => { renderer = create(createElement(Harness, { showNativeChat: true })) @@ -218,89 +177,66 @@ describe('useMobileNativeChatTerminalStream', () => { subscribe.mockImplementation((handle: string) => subscriptionsRef.current.set(handle, () => {}) ) - consoleSpy.mockRestore() } }) it('refills the rearm budget when terminal.list reports the handle again (#10681)', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - for (let revision = 1; revision <= 5; revision += 1) { - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update( - createElement(Harness, { - showNativeChat: true, - leaseReady: false, - streamRevision: revision - }) - ) - }) - } - subscribe.mockClear() - // Budget is spent, so a further teardown signal alone changes nothing. - await act(async () => { - renderer?.update( - createElement(Harness, { showNativeChat: true, leaseReady: false, streamRevision: 6 }) - ) - }) - expect(subscribe).not.toHaveBeenCalled() - - // A dead-but-listed handle must not buy a new budget on every list refresh, - // or the resubscribe loop this bound exists to stop comes straight back. - await act(async () => { - notifyListedHandlesRef.current(new Set(['terminal-1'])) - notifyListedHandlesRef.current(new Set(['terminal-1'])) - }) - expect(subscribe).not.toHaveBeenCalled() - - // The handle actually went away and came back: it may have a live PTY once more. - await act(async () => { - notifyListedHandlesRef.current(new Set()) - notifyListedHandlesRef.current(new Set(['terminal-1'])) - }) - - expect(subscribe).toHaveBeenCalledWith('terminal-1') - } finally { - consoleSpy.mockRestore() - } - }) - - it('rearms on a teardown the lease cannot report (#10681)', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - subscribe.mockClear() - // `end` with no preceding `subscribed` leaves the lease untouched, so - // `leaseReady` holds its value and only the revision bump can re-run us. + for (let revision = 1; revision <= 5; revision += 1) { subscriptionsRef.current.delete('terminal-1') await act(async () => { renderer?.update( - createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 1 }) + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) ) }) - - expect(subscribe).toHaveBeenCalledWith('terminal-1') - } finally { - consoleSpy.mockRestore() } + subscribe.mockClear() + // Budget is spent, so a further teardown signal alone changes nothing. + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: false, streamRevision: 6 }) + ) + }) + expect(subscribe).not.toHaveBeenCalled() + + // A dead-but-listed handle must not buy a new budget on every list refresh, + // or the resubscribe loop this bound exists to stop comes straight back. + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-1'])) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + expect(subscribe).not.toHaveBeenCalled() + + // The handle actually went away and came back: it may have a live PTY once more. + await act(async () => { + notifyListedHandlesRef.current(new Set()) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + }) + + it('rearms on a teardown the lease cannot report (#10681)', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // `end` with no preceding `subscribed` leaves the lease untouched, so + // `leaseReady` holds its value and only the revision bump can re-run us. + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 1 }) + ) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') }) it('does not let a dead PTY buy a new budget with its own `subscribed` ack', async () => { @@ -411,34 +347,23 @@ describe('useMobileNativeChatTerminalStream', () => { }) it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + webReadyRef.current.clear() + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) }) - try { - webReadyRef.current.clear() - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - await act(async () => { - renderer?.update(createElement(Harness, { showNativeChat: false })) - }) - expect(unsubscribe).toHaveBeenCalledOnce() - expect(subscribe).toHaveBeenCalledOnce() + expect(unsubscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledOnce() - webReadyRef.current.add('terminal-1') - await act(async () => { - notifyWebReadyRef.current('terminal-1', false) - }) + webReadyRef.current.add('terminal-1') + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) - expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - } finally { - consoleSpy.mockRestore() - } + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') }) }) diff --git a/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts b/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts index 2ec37155bda..33c507f2712 100644 --- a/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts +++ b/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts @@ -112,8 +112,6 @@ async function setAppState(state: string): Promise { describe('useMobileSessionTabsReconciliation', () => { let renderer: ReactTestRenderer | null = null - let consoleErrorSpy: ReturnType - async function mount(): Promise { await act(async () => { renderer = create(createElement(Harness)) @@ -124,14 +122,6 @@ describe('useMobileSessionTabsReconciliation', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(0) - globalThis.IS_REACT_ACT_ENVIRONMENT = true - const originalConsoleError = console.error - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) lifecycle.appState = 'active' lifecycle.focused = true lifecycle.listeners.clear() @@ -157,7 +147,6 @@ describe('useMobileSessionTabsReconciliation', () => { act(() => renderer?.unmount()) renderer = null streamListener = null - consoleErrorSpy.mockRestore() vi.useRealTimers() }) diff --git a/mobile/src/session/use-mobile-session-view-mode.test.ts b/mobile/src/session/use-mobile-session-view-mode.test.ts index 4315bda35bf..0eda383487c 100644 --- a/mobile/src/session/use-mobile-session-view-mode.test.ts +++ b/mobile/src/session/use-mobile-session-view-mode.test.ts @@ -52,7 +52,6 @@ describe('useMobileSessionViewMode', () => { let controller: MobileSessionViewModeController | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal') vi.mocked(readSessionViewOverridesPreference) .mockReset() diff --git a/mobile/src/session/use-native-chat-action-outcomes.ts b/mobile/src/session/use-native-chat-action-outcomes.ts index 51a6f831b5a..a81d08dfdff 100644 --- a/mobile/src/session/use-native-chat-action-outcomes.ts +++ b/mobile/src/session/use-native-chat-action-outcomes.ts @@ -1,5 +1,4 @@ import { useCallback } from 'react' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' /** Wraps a chat card action so an accepted write also retires the route's held * failure banner. @@ -23,15 +22,3 @@ export function useNativeChatAcceptedAction( [action, onAccepted] ) } - -/** Boolean surface for callers with no pre-pasted input: 'unknown' stays true - * (the send usually landed; the optimistic echo is already held unconfirmed). */ -export function useNativeChatSentFlag( - send: (text: string, images?: string[]) => Promise -): (text: string, images?: string[]) => Promise { - return useCallback( - async (text: string, images?: string[]): Promise => - (await send(text, images)) !== 'rejected', - [send] - ) -} diff --git a/mobile/src/session/use-quick-commands.test.ts b/mobile/src/session/use-quick-commands.test.ts index 66fec5bf4e5..a4f93b326fc 100644 --- a/mobile/src/session/use-quick-commands.test.ts +++ b/mobile/src/session/use-quick-commands.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../src/shared/types' import type { RpcClient } from '../transport/rpc-client' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' @@ -49,24 +49,10 @@ function deferred() { describe('useQuickCommands', () => { let renderer: ReactTestRenderer | null = null let state: ReturnType | null = null - let consoleSpy: MockInstance - - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - const original = console.error - consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null state = null - consoleSpy.mockRestore() }) async function mount(client: RpcClient, enabled = true): Promise { diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts index d95fab23698..0a98d737792 100644 --- a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts +++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts @@ -34,18 +34,6 @@ function createDeferred(): Deferred { return { promise, resolve } } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness { let current: ReturnType | null = null let renderer: ReactTestRenderer | null = null @@ -58,14 +46,9 @@ function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePr return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - restoreConsoleError() - } + act(() => { + renderer = create(createElement(Harness)) + }) if (!current || !renderer) { throw new Error('terminal live input mode preference hook did not render') } diff --git a/mobile/src/session/use-throttled-latest-value.test.ts b/mobile/src/session/use-throttled-latest-value.test.ts index bdea29c7459..29e58c8fa35 100644 --- a/mobile/src/session/use-throttled-latest-value.test.ts +++ b/mobile/src/session/use-throttled-latest-value.test.ts @@ -1,12 +1,11 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useThrottledLatestValue } from './use-throttled-latest-value' describe('useThrottledLatestValue', () => { let renderer: ReactTestRenderer | null = null let latest: string | undefined - let consoleSpy: MockInstance function Harness({ value }: { value: string | undefined }): null { latest = useThrottledLatestValue(value, 50) @@ -25,22 +24,13 @@ describe('useThrottledLatestValue', () => { beforeEach(() => { vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true latest = undefined - const original = console.error - consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) }) afterEach(() => { act(() => renderer?.unmount()) renderer = null vi.useRealTimers() - consoleSpy.mockRestore() }) it('emits the first frame immediately', () => { diff --git a/mobile/src/source-control/MobileGitHistoryList.test.tsx b/mobile/src/source-control/MobileGitHistoryList.test.tsx index 50b0fa7fe73..b19a069c80f 100644 --- a/mobile/src/source-control/MobileGitHistoryList.test.tsx +++ b/mobile/src/source-control/MobileGitHistoryList.test.tsx @@ -1,6 +1,6 @@ import { createElement, type ReactElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { MobileGitHistoryList } from './MobileGitHistoryList' @@ -44,10 +44,6 @@ const compareResponse = { describe('MobileGitHistoryList', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/source-control/MobileSourceControlModals.tsx b/mobile/src/source-control/MobileSourceControlModals.tsx index b58d2520723..f9659240e4a 100644 --- a/mobile/src/source-control/MobileSourceControlModals.tsx +++ b/mobile/src/source-control/MobileSourceControlModals.tsx @@ -1,7 +1,7 @@ import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal' import { ConfirmModal } from '../components/ConfirmModal' import { PickerModal } from '../components/PickerModal' -import { openMobilePrUrl } from '../components/MobilePrComposeSheet' +import { openMobilePrUrl } from '../components/mobile-pr-url' import { MobileBranchDiffPreviewDrawer } from './MobileBranchDiffPreviewDrawer' import type { MobileSourceControlState } from './use-mobile-source-control-state' diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx index 9c3ebb27062..3244d440b01 100644 --- a/mobile/src/source-control/MobileSourceControlPanel.tsx +++ b/mobile/src/source-control/MobileSourceControlPanel.tsx @@ -18,7 +18,7 @@ import { isMobileConflictAborting } from './mobile-source-control-conflict-abort import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller' import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state' import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel' -import { openMobilePrUrl } from '../components/MobilePrComposeSheet' +import { openMobilePrUrl } from '../components/mobile-pr-url' export type MobileSourceControlPanelProps = { hostId: string diff --git a/mobile/src/source-control/mobile-base-ref-search.test.ts b/mobile/src/source-control/mobile-base-ref-search.test.ts deleted file mode 100644 index b59b4fe8da2..00000000000 --- a/mobile/src/source-control/mobile-base-ref-search.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mapBaseRefResults } from './mobile-base-ref-search' - -describe('mapBaseRefResults', () => { - it('extracts a clean string list from a well-formed payload', () => { - expect(mapBaseRefResults({ refs: ['main', 'dev'] })).toEqual(['main', 'dev']) - }) - - it('returns [] for malformed payloads and drops bad entries', () => { - expect(mapBaseRefResults(null)).toEqual([]) - expect(mapBaseRefResults({})).toEqual([]) - expect(mapBaseRefResults({ refs: 'x' })).toEqual([]) - expect(mapBaseRefResults({ refs: ['ok', 2, '', null, 'two'] })).toEqual(['ok', 'two']) - }) -}) diff --git a/mobile/src/source-control/mobile-base-ref-search.ts b/mobile/src/source-control/mobile-base-ref-search.ts deleted file mode 100644 index 8e51fe0d582..00000000000 --- a/mobile/src/source-control/mobile-base-ref-search.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' -import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create' - -// Base-branch selection for the create-PR composer, mirroring the desktop -// useCreatePullRequestDialogFields flow: a default ref from repo.baseRefDefault and -// a debounced search via repo.searchRefs (both allowlisted for mobile). Result -// mapping is pure + unit-tested; the wrappers are thin sendRequest calls. - -// Defensively normalize the repo.searchRefs payload (`{ refs: string[] }`) to a -// clean string[] — drops non-string / malformed entries instead of throwing. -export function mapBaseRefResults(raw: unknown): string[] { - if (raw === null || typeof raw !== 'object') { - return [] - } - const refs = (raw as { refs?: unknown }).refs - if (!Array.isArray(refs)) { - return [] - } - return refs.filter((r): r is string => typeof r === 'string' && r.length > 0) -} - -export async function fetchDefaultBaseRef( - client: Pick, - worktreeId: string -): Promise { - const response = await client.sendRequest('repo.baseRefDefault', { - repo: mobileRepoSelectorFromWorktreeId(worktreeId) - }) - if (!response.ok) { - return null - } - const result = (response as RpcSuccess).result as { defaultBaseRef?: string | null } - return typeof result?.defaultBaseRef === 'string' ? result.defaultBaseRef : null -} - -export async function searchBaseRefs( - client: Pick, - worktreeId: string, - query: string, - limit = 20 -): Promise { - const response = await client.sendRequest('repo.searchRefs', { - repo: mobileRepoSelectorFromWorktreeId(worktreeId), - query, - limit - }) - if (!response.ok) { - return [] - } - return mapBaseRefResults((response as RpcSuccess).result) -} diff --git a/mobile/src/source-control/mobile-create-pr-action.test.ts b/mobile/src/source-control/mobile-create-pr-action.test.ts index c665b034f7f..ec399b45913 100644 --- a/mobile/src/source-control/mobile-create-pr-action.test.ts +++ b/mobile/src/source-control/mobile-create-pr-action.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { HostedReviewCreationBlockedReason, - HostedReviewCreationEligibility, - HostedReviewProvider + HostedReviewCreationEligibility } from '../../../src/shared/hosted-review' import { buildMobileCreatePrAction, @@ -146,7 +145,7 @@ describe('buildMobileCreatePrAction', () => { it('keeps a disabled status row for providers without review creation', () => { const { descriptor } = action({ - eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider }) + eligibility: eligibility({ provider: 'unsupported' }) }) expect(descriptor).toMatchObject({ @@ -211,7 +210,7 @@ describe('cold-mount layout stability (issue #8411)', () => { 'unsupported provider', { kind: 'ready', - eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider }) + eligibility: eligibility({ provider: 'unsupported' }) } satisfies MobileCreatePrEligibilityState ], ['eligibility error', { kind: 'error' } satisfies MobileCreatePrEligibilityState] diff --git a/mobile/src/source-control/mobile-create-pr-action.ts b/mobile/src/source-control/mobile-create-pr-action.ts index fe75cc60a58..3268a1796c3 100644 --- a/mobile/src/source-control/mobile-create-pr-action.ts +++ b/mobile/src/source-control/mobile-create-pr-action.ts @@ -72,7 +72,7 @@ export function buildMobileCreatePrAction({ } // Why: mirror desktop's structural provider gate (supportsHostedReviewCreation) // instead of relying on the host always emitting a hidden blockedReason for - // non-creatable providers like bitbucket. + // non-creatable providers. if (!supportsHostedReviewCreation(eligibility.provider)) { return { visible: true, diff --git a/mobile/src/source-control/mobile-open-pr-prefill.ts b/mobile/src/source-control/mobile-open-pr-prefill.ts index 705b4dc68e5..2369095f328 100644 --- a/mobile/src/source-control/mobile-open-pr-prefill.ts +++ b/mobile/src/source-control/mobile-open-pr-prefill.ts @@ -1,14 +1,7 @@ -import type { RpcClient } from '../transport/rpc-client' import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc' import type { MobileGitStatusResult } from './mobile-git-status' -import { resolveMobilePrPrefill, type MobilePrPrefill } from './mobile-pr-create' -// Resolves the create-PR prefill from a git status snapshot. Split from the -// runners hook to keep that file under the line limit. - -// Reads a fresh git.status after a push so the prefill reflects the just-pushed -// branch's upstream/ahead data instead of the pre-push captured status. Best-effort: -// returns the captured status on any read failure. +// Refresh after a push when possible so readiness reflects the new upstream state. export async function readFreshGitStatus( worktreeId: string, fallback: MobileGitStatusResult | null, @@ -22,23 +15,6 @@ export async function readFreshGitStatus( } } -export async function buildOpenPrPrefill( - client: Pick | null, - worktreeId: string, - status: MobileGitStatusResult | null, - branchLabel: string -): Promise { - if (!client) { - return { provider: 'github', base: 'main', title: branchLabel, body: '' } - } - const gitReadiness = getMobilePrEligibilityReadiness(status) - return resolveMobilePrPrefill(client, worktreeId, { - branch: status?.branch, - title: branchLabel, - ...gitReadiness - }) -} - export function getMobilePrEligibilityReadiness(status: MobileGitStatusResult | null): { hasUncommittedChanges?: boolean hasUpstream?: boolean diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts index 20f908d3df2..65a70a63ed9 100644 --- a/mobile/src/source-control/mobile-pr-create.ts +++ b/mobile/src/source-control/mobile-pr-create.ts @@ -2,7 +2,6 @@ import { hostedReviewCopy } from './hosted-review-copy' import { buildMobileHostedReviewCreateParams, createMobileHostedReview, - fetchMobileHostedReviewEligibility, mobileRepoSelectorFromWorktreeId, resolveMobileHostedReviewPrefill, shouldPushBeforeMobileHostedReviewCreate, @@ -20,7 +19,6 @@ export type MobilePrCreateOutcome = MobileHostedReviewCreateOutcome export { buildMobileHostedReviewCreateParams as buildMobilePrCreateParams, createMobileHostedReview as createMobilePr, - fetchMobileHostedReviewEligibility as fetchMobilePrEligibility, mobileRepoSelectorFromWorktreeId, resolveMobileHostedReviewPrefill as resolveMobilePrPrefill, shouldPushBeforeMobileHostedReviewCreate as shouldPushBeforeMobilePrCreate diff --git a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts b/mobile/src/source-control/mobile-source-control-primary-action-decision.ts deleted file mode 100644 index eb0927bcff6..00000000000 --- a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { MobileGitUpstreamStatus } from './mobile-git-status' - -export type MobileSourceControlPrimaryActionKind = - | 'commit' - | 'stage' - | 'push' - | 'pull' - | 'sync' - | 'publish' - -export type MobileSourceControlRemoteOpKind = - | 'push' - | 'force_push' - | 'pull' - | 'sync' - | 'fetch' - | 'fast_forward' - | 'publish' - | 'rebase' - -export type MobileSourceControlPrimaryActionTitleIntent = - | 'commit_in_progress' - | 'force_push_in_progress' - | 'action_in_progress' - | 'remote_operation_in_progress' - | 'remote_operation_blocks_commit' - | 'resolve_conflicts_before_commit' - | 'commit_staged_changes' - | 'enter_commit_message' - | 'stage_all_changes' - | 'stage_file_to_commit' - | 'checkout_branch_before_publish' - | 'publish_branch' - | 'force_push_with_lease' - | 'sync_counts' - | 'pull_count' - | 'push_count' - | 'nothing_to_commit_up_to_date' - -export type MobileSourceControlPrimaryActionDecision = { - kind: MobileSourceControlPrimaryActionKind - disabled: boolean - labelIntent: MobileSourceControlPrimaryActionKind | 'force_push' - titleIntent: MobileSourceControlPrimaryActionTitleIntent - count?: number - ahead?: number - behind?: number - upstreamName?: string - requiresForceWithLease?: boolean -} - -export type MobileSourceControlPrimaryActionDecisionInputs = { - stagedCount: number - hasUnstagedChanges: boolean - hasStageableChanges: boolean - hasPartiallyStagedChanges: boolean - hasMessage: boolean - hasUnresolvedConflicts: boolean - isCommitting: boolean - isRemoteOperationActive: boolean - upstreamStatus: MobileGitUpstreamStatus | undefined - inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null - branchCommitsAhead?: number - hasCurrentBranch?: boolean -} - -// Why: Metro cannot load runtime modules from the desktop/root `src/shared` -// tree. Keep this mobile mirror narrow and parity-tested against the shared -// commit-area decision core so the semantic ladder cannot drift silently. -export function resolveMobileSourceControlCommitAreaPrimaryActionDecision( - inputs: MobileSourceControlPrimaryActionDecisionInputs -): MobileSourceControlPrimaryActionDecision { - const { - stagedCount, - hasUnstagedChanges, - hasStageableChanges, - hasMessage, - hasUnresolvedConflicts, - isCommitting, - isRemoteOperationActive, - upstreamStatus, - branchCommitsAhead, - hasCurrentBranch = true - } = inputs - - if (isCommitting) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'commit_in_progress', - disabled: true - } - } - - if (isRemoteOperationActive) { - return resolveMobilePrimaryActionDuringRemoteOp(inputs) - } - - if (hasUnresolvedConflicts) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'resolve_conflicts_before_commit', - disabled: true - } - } - - const hasStaged = stagedCount > 0 - - if (hasStaged && hasMessage) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'commit_staged_changes', - disabled: false - } - } - - if (hasStaged && !hasMessage) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'enter_commit_message', - disabled: true - } - } - - if (!hasStaged && hasStageableChanges) { - return { - kind: 'stage', - labelIntent: 'stage', - titleIntent: 'stage_all_changes', - disabled: false - } - } - - if (!upstreamStatus) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'stage_file_to_commit', - disabled: true - } - } - - if (!upstreamStatus.hasUpstream) { - if (!hasCurrentBranch) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'checkout_branch_before_publish', - disabled: true - } - } - return { - kind: 'publish', - labelIntent: 'publish', - titleIntent: 'publish_branch', - disabled: false - } - } - - if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) { - if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) { - return { - kind: 'push', - labelIntent: 'force_push', - titleIntent: 'force_push_with_lease', - disabled: false, - count: branchCommitsAhead, - upstreamName: upstreamStatus.upstreamName, - requiresForceWithLease: true - } - } - return { - kind: 'sync', - labelIntent: 'sync', - titleIntent: 'sync_counts', - disabled: false, - ahead: upstreamStatus.ahead, - behind: upstreamStatus.behind - } - } - - if (upstreamStatus.behind > 0) { - return { - kind: 'pull', - labelIntent: 'pull', - titleIntent: 'pull_count', - disabled: false, - count: upstreamStatus.behind - } - } - - if (upstreamStatus.ahead > 0) { - return { - kind: 'push', - labelIntent: 'push', - titleIntent: 'push_count', - disabled: false, - count: upstreamStatus.ahead - } - } - - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date', - disabled: true - } -} - -function resolveMobilePrimaryActionDuringRemoteOp( - inputs: MobileSourceControlPrimaryActionDecisionInputs -): MobileSourceControlPrimaryActionDecision { - const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs - const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ - ...inputs, - isRemoteOperationActive: false - }) - const inFlightIsPrimaryKind = - inFlightRemoteOpKind === 'push' || - inFlightRemoteOpKind === 'pull' || - inFlightRemoteOpKind === 'sync' || - inFlightRemoteOpKind === 'publish' - - if (inFlightRemoteOpKind === 'force_push') { - return { - kind: 'push', - labelIntent: 'force_push', - titleIntent: 'force_push_in_progress', - disabled: true, - requiresForceWithLease: true - } - } - - if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) { - return { - kind: inFlightRemoteOpKind, - labelIntent: inFlightRemoteOpKind, - titleIntent: 'action_in_progress', - disabled: true - } - } - - const titleIntent = hasUnresolvedConflicts - ? 'resolve_conflicts_before_commit' - : candidate.kind === 'commit' - ? 'remote_operation_blocks_commit' - : 'remote_operation_in_progress' - - return { - ...candidate, - titleIntent, - disabled: true - } -} - -function shouldForcePushWithLeaseForMobileUpstream( - status: MobileGitUpstreamStatus | undefined -): boolean { - return ( - status?.hasUpstream === true && - status.ahead > 0 && - status.behind > 0 && - status.behindCommitsArePatchEquivalent === true - ) -} diff --git a/mobile/src/source-control/mobile-source-control-primary-action.test.ts b/mobile/src/source-control/mobile-source-control-primary-action.test.ts index 0b127d9bde3..8314ecb2d80 100644 --- a/mobile/src/source-control/mobile-source-control-primary-action.test.ts +++ b/mobile/src/source-control/mobile-source-control-primary-action.test.ts @@ -1,9 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision' -import { - resolveMobileSourceControlCommitAreaPrimaryActionDecision, - type MobileSourceControlPrimaryActionDecisionInputs -} from './mobile-source-control-primary-action-decision' import { buildMobileSourceControlPrimaryAction, type MobileSourceControlPrimaryActionArgs, @@ -159,113 +154,3 @@ describe('buildMobileSourceControlPrimaryAction', () => { expect(action.disabled).toBe(false) }) }) - -function decisionInputs( - overrides: Partial = {} -): MobileSourceControlPrimaryActionDecisionInputs { - return { - stagedCount: 0, - hasUnstagedChanges: false, - hasStageableChanges: false, - hasPartiallyStagedChanges: false, - hasMessage: false, - hasUnresolvedConflicts: false, - isCommitting: false, - isRemoteOperationActive: false, - upstreamStatus: undefined, - ...overrides - } -} - -describe('mobile source-control primary action decision parity', () => { - it.each([ - { - name: 'dirty tree stages first', - input: decisionInputs({ - hasUnstagedChanges: true, - hasStageableChanges: true, - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 } - }) - }, - { - name: 'staged message commits', - input: decisionInputs({ stagedCount: 1, hasMessage: true }) - }, - { - name: 'staged without message blocks commit', - input: decisionInputs({ stagedCount: 1, hasMessage: false }) - }, - { - name: 'unresolved conflicts block commit', - input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true }) - }, - { - name: 'unpublished branch publishes', - input: decisionInputs({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, - hasCurrentBranch: true - }) - }, - { - name: 'detached head blocks publish', - input: decisionInputs({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, - hasCurrentBranch: false - }) - }, - { - name: 'tracked ahead pushes', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } }) - }, - { - name: 'tracked behind pulls', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } }) - }, - { - name: 'tracked diverged syncs', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } }) - }, - { - name: 'patch-equivalent diverged force-pushes with lease', - input: decisionInputs({ - branchCommitsAhead: 4, - upstreamStatus: { - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 10, - behind: 2, - behindCommitsArePatchEquivalent: true - } - }) - }, - { - name: 'in-flight pull mirrors pull', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'pull', - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } - }) - }, - { - name: 'in-flight force push mirrors force push', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'force_push', - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } - }) - }, - { - name: 'in-flight push blocks committable candidate', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'push', - stagedCount: 1, - hasMessage: true - }) - } - ])('matches the shared commit-area decision for $name', ({ input }) => { - expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual( - resolveSourceControlCommitAreaPrimaryActionDecision(input) - ) - }) -}) diff --git a/mobile/src/source-control/mobile-source-control-primary-action.ts b/mobile/src/source-control/mobile-source-control-primary-action.ts index f62a7770d16..2a993967b52 100644 --- a/mobile/src/source-control/mobile-source-control-primary-action.ts +++ b/mobile/src/source-control/mobile-source-control-primary-action.ts @@ -1,14 +1,17 @@ import { - resolveMobileSourceControlCommitAreaPrimaryActionDecision, - type MobileSourceControlPrimaryActionDecision, - type MobileSourceControlPrimaryActionKind, - type MobileSourceControlRemoteOpKind -} from './mobile-source-control-primary-action-decision' + resolveSourceControlCommitAreaPrimaryActionDecision, + type SourceControlCommitAreaPrimaryActionDecision, + type SourceControlRemoteOpKind +} from '../../../src/shared/source-control-primary-action-decision' import type { MobileGitBranchCompareResult } from './mobile-branch-compare' import type { MobileGitStatusResult } from './mobile-git-status' type GitStep = { method: string; params?: Record } +type MobileSourceControlPrimaryActionKind = SourceControlCommitAreaPrimaryActionDecision['kind'] +type MobileSourceControlPrimaryActionDecision = SourceControlCommitAreaPrimaryActionDecision +type MobileSourceControlRemoteOpKind = SourceControlRemoteOpKind + export type MobileSourceControlPrimaryAction = { kind: MobileSourceControlPrimaryActionKind label: string @@ -44,7 +47,7 @@ export type MobileSourceControlPrimaryActionArgs = { export function buildMobileSourceControlPrimaryAction( args: MobileSourceControlPrimaryActionArgs ): MobileSourceControlPrimaryAction { - const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ + const decision = resolveSourceControlCommitAreaPrimaryActionDecision({ stagedCount: args.stagedCount, hasUnstagedChanges: args.unstagedCount > 0, hasStageableChanges: args.stageablePaths.length > 0, @@ -167,8 +170,16 @@ function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDe return 'Stage at least one file to commit.' case 'checkout_branch_before_publish': return 'Check out a branch before publishing commits.' + case 'checking_review_status': + return 'Checking review status.' + case 'review_already_merged': + return 'Nothing to commit. The review is already merged.' case 'publish_branch': return 'Publish this branch to origin.' + case 'push_linked_review': + return 'Push updates to the linked review branch.' + case 'linked_review_target_unavailable': + return 'The linked review branch is unavailable.' case 'force_push_with_lease': return 'Force push with lease to update the remote branch.' case 'sync_counts': diff --git a/mobile/src/source-control/pr-compose-validation.test.ts b/mobile/src/source-control/pr-compose-validation.test.ts deleted file mode 100644 index d0f76f55e4b..00000000000 --- a/mobile/src/source-control/pr-compose-validation.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - canSubmitPrCompose, - getPrComposeDisabledReason, - isBaseHeadDistinct -} from './pr-compose-validation' - -describe('isBaseHeadDistinct', () => { - it('is true when base and head differ', () => { - expect(isBaseHeadDistinct('main', 'feature')).toBe(true) - }) - - it('is false when they match after normalization (case-insensitive, prefix-stripped)', () => { - expect(isBaseHeadDistinct('main', 'main')).toBe(false) - expect(isBaseHeadDistinct('refs/heads/main', 'main')).toBe(false) - expect(isBaseHeadDistinct('origin/Main', 'main')).toBe(false) - expect(isBaseHeadDistinct('upstream/main', 'refs/heads/main')).toBe(false) - }) - - it('is false for an empty base', () => { - expect(isBaseHeadDistinct('', 'feature')).toBe(false) - }) -}) - -describe('canSubmitPrCompose', () => { - it('requires a non-empty title and a distinct base', () => { - expect(canSubmitPrCompose('Title', 'main', 'feature')).toBe(true) - expect(canSubmitPrCompose(' ', 'main', 'feature')).toBe(false) - expect(canSubmitPrCompose('Title', 'main', 'main')).toBe(false) - }) -}) - -describe('getPrComposeDisabledReason', () => { - it('returns null when the form can submit', () => { - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'feature', - generating: false, - reviewLabel: 'pull request' - }) - ).toBeNull() - }) - - it('names the active blocker', () => { - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'feature', - generating: true, - reviewLabel: 'pull request' - }) - ).toBe('Wait for generation to finish.') - expect( - getPrComposeDisabledReason({ - title: '', - base: 'main', - head: 'feature', - generating: false, - reviewLabel: 'merge request' - }) - ).toBe('Enter a merge request title.') - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: '', - head: 'feature', - generating: false, - reviewLabel: 'pull request' - }) - ).toBe('Choose a base branch.') - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'main', - generating: false, - reviewLabel: 'pull request' - }) - ).toBe('Base branch must differ from the head branch.') - }) -}) diff --git a/mobile/src/source-control/pr-compose-validation.ts b/mobile/src/source-control/pr-compose-validation.ts deleted file mode 100644 index c04f5c3f5b2..00000000000 --- a/mobile/src/source-control/pr-compose-validation.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Ref normalizers ported from src/shared/hosted-review-refs.ts (value-imported, not -// referenced across the package boundary — Metro's resolver is rooted at mobile/ and -// can't bundle a runtime import from repo-root/src, unlike erased `import type`s). -// Keep in sync with the shared versions so mobile and desktop compare refs identically. -function normalizeHeadRef(ref: string): string { - return ref - .trim() - .replace(/^refs\/heads\//, '') - .replace(/^refs\/remotes\/[^/]+\//, '') -} - -function normalizeBaseRef(ref: string): string { - return normalizeHeadRef(ref).replace(/^(origin|upstream)\//, '') -} - -// Submit-gating for the create-PR composer, matching desktop CreatePullRequestDialog: -// the base ref must be non-empty and must differ from the head branch after ref -// normalization (strip refs/heads, remote prefixes, origin/upstream), case-insensitive. -export function isBaseHeadDistinct(base: string, head: string): boolean { - const b = normalizeBaseRef(base).toLowerCase() - const h = normalizeHeadRef(head).toLowerCase() - return b.length > 0 && b !== h -} - -export function canSubmitPrCompose(title: string, base: string, head: string): boolean { - return title.trim().length > 0 && isBaseHeadDistinct(base, head) -} - -export function getPrComposeDisabledReason({ - title, - base, - head, - generating, - reviewLabel -}: { - title: string - base: string - head: string - generating: boolean - reviewLabel: string -}): string | null { - if (generating) { - return 'Wait for generation to finish.' - } - if (title.trim().length === 0) { - return `Enter a ${reviewLabel} title.` - } - if (base.trim().length === 0) { - return 'Choose a base branch.' - } - if (!isBaseHeadDistinct(base, head)) { - return 'Base branch must differ from the head branch.' - } - return null -} diff --git a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts index 13109a610db..f6e3eab8873 100644 --- a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts +++ b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review' import { buildMobileHostedReviewEligibilityLoadKey, @@ -206,10 +206,6 @@ describe('eligibility request ordering', () => { let renderer: ReactTestRenderer | null = null let renderedState: MobileCreatePrEligibilityState = { kind: 'idle' } - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/tasks/mobile-github-project-group-sort.ts b/mobile/src/tasks/mobile-github-project-group-sort.ts deleted file mode 100644 index 7b86d3c38c0..00000000000 --- a/mobile/src/tasks/mobile-github-project-group-sort.ts +++ /dev/null @@ -1,261 +0,0 @@ -export type GitHubProjectSortDirection = 'ASC' | 'DESC' - -export type GitHubProjectFieldValue = - | { kind: 'text'; text: string } - | { kind: 'number'; number: number } - | { kind: 'date'; date: string } - | { kind: 'single-select'; optionId: string; name: string } - | { kind: 'iteration'; iterationId: string; title: string } - | { kind: 'labels'; labels: Array<{ name: string }> } - | { kind: 'users'; users: Array<{ login: string }> } - -export type GitHubProjectField = { - id: string - name: string - kind: string - options?: Array<{ id: string }> - iterations?: Array<{ - id: string - title?: string - startDate: string - duration: number - completed: boolean - }> -} - -export type GitHubProjectRow = { - position?: number | null - fieldValuesByFieldId: Record -} - -export type GitHubProjectSort = { - direction: GitHubProjectSortDirection - field: GitHubProjectField -} - -export type GitHubProjectTable = { - selectedView: { - groupByFields: GitHubProjectField[] - sortByFields: GitHubProjectSort[] - } - rows: GitHubProjectRow[] -} - -export type ProjectGroup = { - key: string - label: string - iteration: { - startDate: string - duration: number - completed: boolean - } | null - rows: GitHubProjectRow[] -} - -const EMPTY_GROUP_KEY = '__empty__' -const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER - -function labelForEmpty(field: GitHubProjectField): string { - return `No ${field.name}` -} - -function deriveStringValue(value: GitHubProjectFieldValue): string { - switch (value.kind) { - case 'text': - return value.text - case 'number': - return String(value.number) - case 'date': - return value.date - case 'single-select': - return value.name - case 'iteration': - return value.title - case 'labels': - return value.labels.map((label) => label.name).join(', ') - case 'users': - return value.users.map((user) => user.login).join(', ') - } -} - -function getFieldValueForGrouping( - row: GitHubProjectRow, - field: GitHubProjectField -): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } { - const value = row.fieldValuesByFieldId[field.id] - if (!value) { - return { - key: EMPTY_GROUP_KEY, - label: labelForEmpty(field), - orderHint: UNKNOWN_INDEX_SENTINEL, - iteration: null - } - } - if (field.kind === 'iteration' && value.kind === 'iteration') { - const iterations = field.iterations ?? [] - const idx = iterations.findIndex((it) => it.id === value.iterationId) - const meta = iterations.find((it) => it.id === value.iterationId) - return { - key: value.iterationId, - label: value.title || meta?.title || 'Iteration', - orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, - iteration: meta - ? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed } - : null - } - } - if (field.kind === 'single-select' && value.kind === 'single-select') { - const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId) - return { - key: value.optionId, - label: value.name, - orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, - iteration: null - } - } - const label = deriveStringValue(value) - return { key: `raw:${label}`, label, orderHint: 0, iteration: null } -} - -export function groupRows( - table: GitHubProjectTable, - rowsInOrder: GitHubProjectRow[] -): ProjectGroup[] { - const groupField = table.selectedView.groupByFields[0] - if (!groupField) { - return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }] - } - const buckets = new Map< - string, - { - label: string - orderHint: number - iteration: ProjectGroup['iteration'] - rows: GitHubProjectRow[] - } - >() - for (const row of rowsInOrder) { - const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField) - let bucket = buckets.get(key) - if (!bucket) { - bucket = { label, orderHint, iteration, rows: [] } - buckets.set(key, bucket) - } - bucket.rows.push(row) - } - const entries = Array.from(buckets.entries()) - entries.sort((a, b) => { - if (a[0] === EMPTY_GROUP_KEY) { - return 1 - } - if (b[0] === EMPTY_GROUP_KEY) { - return -1 - } - if (groupField.kind === 'iteration' || groupField.kind === 'single-select') { - return a[1].orderHint - b[1].orderHint - } - return a[1].label.localeCompare(b[1].label) - }) - return entries.map(([key, value]) => ({ - key, - label: value.label, - iteration: value.iteration, - rows: value.rows - })) -} - -function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number { - const field = sort.field - const aValue = a.fieldValuesByFieldId[field.id] - const bValue = b.fieldValuesByFieldId[field.id] - if (!aValue && !bValue) { - return 0 - } - if (!aValue) { - return 1 - } - if (!bValue) { - return -1 - } - - let cmp = 0 - if ( - field.kind === 'single-select' && - aValue.kind === 'single-select' && - bValue.kind === 'single-select' - ) { - const options = field.options ?? [] - const aIdx = options.findIndex((option) => option.id === aValue.optionId) - const bIdx = options.findIndex((option) => option.id === bValue.optionId) - cmp = - (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) - } else if ( - field.kind === 'iteration' && - aValue.kind === 'iteration' && - bValue.kind === 'iteration' - ) { - const iterations = field.iterations ?? [] - const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId) - const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId) - cmp = - (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) - } else if (aValue.kind === 'number' && bValue.kind === 'number') { - cmp = aValue.number - bValue.number - } else if (aValue.kind === 'date' && bValue.kind === 'date') { - cmp = aValue.date.localeCompare(bValue.date) - } else if (aValue.kind === 'text' && bValue.kind === 'text') { - cmp = aValue.text.localeCompare(bValue.text) - } else if (aValue.kind === 'users' && bValue.kind === 'users') { - const aLogin = aValue.users[0]?.login ?? '' - const bLogin = bValue.users[0]?.login ?? '' - if (!aLogin && !bLogin) { - cmp = 0 - } else if (!aLogin) { - cmp = 1 - } else if (!bLogin) { - cmp = -1 - } else { - cmp = aLogin.localeCompare(bLogin) - } - } else if (aValue.kind === 'labels' && bValue.kind === 'labels') { - const aName = aValue.labels[0]?.name ?? '' - const bName = bValue.labels[0]?.name ?? '' - if (!aName && !bName) { - cmp = 0 - } else if (!aName) { - cmp = 1 - } else if (!bName) { - cmp = -1 - } else { - cmp = aName.localeCompare(bName) - } - } else { - return 0 - } - return sort.direction === 'DESC' ? -cmp : cmp -} - -export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] { - const sorts = table.selectedView.sortByFields - const out = [...rows] - out.sort((a, b) => { - for (const sort of sorts) { - const cmp = compareSort(a, b, sort) - if (cmp !== 0) { - return cmp - } - } - return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL) - }) - return out -} - -export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean { - const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime() - if (Number.isNaN(start)) { - return false - } - const end = start + iteration.duration * 86_400_000 - const now = Date.now() - return now >= start && now < end -} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index f7ab3af1a3c..d09c0661240 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,7 +1,7 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' import { Platform, View } from 'react-native' import { WebView, type WebViewMessageEvent } from 'react-native-webview' -import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' import { TerminalWebViewEngineErrorOverlay, diff --git a/mobile/src/terminal/quick-commands.ts b/mobile/src/terminal/quick-commands.ts index 40f92a55439..d6e6b86caf3 100644 --- a/mobile/src/terminal/quick-commands.ts +++ b/mobile/src/terminal/quick-commands.ts @@ -2,6 +2,7 @@ import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' import { applyTerminalQuickCommandMutation, flattenTerminalQuickCommand, + getTerminalQuickCommandBody, isTerminalAgentQuickCommand, MAX_QUICK_COMMANDS, MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, @@ -20,6 +21,7 @@ import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents' // — agent-branded labels, native row truncation, and the launch plan — live here. export { isTerminalAgentQuickCommand as isAgentQuickCommand, + getTerminalQuickCommandBody, terminalQuickCommandMatchesRepo as quickCommandMatchesRepo, supportsTerminalAgentQuickCommand, MAX_QUICK_COMMANDS, diff --git a/mobile/src/terminal/terminal-osc-link-ranges.ts b/mobile/src/terminal/terminal-osc-link-ranges.ts deleted file mode 100644 index 5f7f18f82d1..00000000000 --- a/mobile/src/terminal/terminal-osc-link-ranges.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' - -export type { TerminalOscLinkRange } - -export function isTerminalOscLinkRanges(value: unknown): value is TerminalOscLinkRange[] { - return ( - Array.isArray(value) && - value.every( - (entry) => - entry != null && - typeof entry === 'object' && - Number.isInteger((entry as TerminalOscLinkRange).row) && - Number.isInteger((entry as TerminalOscLinkRange).startCol) && - Number.isInteger((entry as TerminalOscLinkRange).endCol) && - typeof (entry as TerminalOscLinkRange).uri === 'string' - ) - ) -} diff --git a/mobile/src/terminal/terminal-settings-screen-styles.ts b/mobile/src/terminal/terminal-settings-screen-styles.ts new file mode 100644 index 00000000000..a3ff5c8dd6d --- /dev/null +++ b/mobile/src/terminal/terminal-settings-screen-styles.ts @@ -0,0 +1,91 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const terminalSettingsScreenStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg, + paddingTop: 0 + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + scrollContent: { + paddingBottom: spacing.xl + }, + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + groupDescription: { + fontSize: typography.bodySize - 1, + color: colors.textSecondary, + lineHeight: 20, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { + marginTop: spacing.sm + }, + inputGroupGap: { + marginTop: spacing.xl + }, + emptyText: { + fontSize: typography.bodySize, + color: colors.textSecondary, + padding: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index c28fd4a5038..45b9a38c05a 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -1,6 +1,6 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' import type { StyleProp, ViewStyle } from 'react-native' -import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' diff --git a/mobile/src/terminal/terminal-webview-engine-error.test.ts b/mobile/src/terminal/terminal-webview-engine-error.test.ts index 4e29c287321..2a1ba5df93d 100644 --- a/mobile/src/terminal/terminal-webview-engine-error.test.ts +++ b/mobile/src/terminal/terminal-webview-engine-error.test.ts @@ -41,18 +41,6 @@ vi.mock('lucide-react-native', () => ({ RefreshCw: 'RefreshCw' })) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - // Why: mounting TerminalWebView arms the web-ready watchdog; tests must // unmount so the timer can't survive into later tests and fire in teardown. let activeRenderer: ReactTestRenderer | null = null @@ -62,14 +50,9 @@ function createTerminalWebViewRenderer( props: Record = {} ) { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(TerminalWebView, { onEngineError, ...props })) - }) - } finally { - restoreConsoleError() - } + act(() => { + renderer = create(createElement(TerminalWebView, { onEngineError, ...props })) + }) if (!renderer) { throw new Error('TerminalWebView did not render') } diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index c031363268c..35c6e991b68 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -1,5 +1,5 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' -import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' export type TerminalWebViewCommand = | { type: 'ping'; id?: number } diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index 1ca5216c7a8..2542b315a8f 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -25,18 +25,6 @@ function createDeferredBoolean(): DeferredBoolean { return { promise, resolve: resolvePromise } } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - type AccessoryInputCommitHarnessOptions = { readonly heldText?: string readonly sentText?: string @@ -105,14 +93,9 @@ function createAccessoryInputCommitHarness({ return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - restoreConsoleError() - } + act(() => { + renderer = create(createElement(Harness)) + }) if (!commit || !renderer) { throw new Error('terminal live accessory input hook did not render') } 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 c211ff447ec..544d57a1bf1 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -20,18 +20,6 @@ type TerminalLiveInputCommitHarnessOptions = { readonly sendResult?: boolean } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - function createTerminalLiveInputCommitHarness({ sendResult = true }: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness { @@ -77,14 +65,9 @@ function createTerminalLiveInputCommitHarness({ return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - restoreConsoleError() - } + act(() => { + renderer = create(createElement(Harness)) + }) if (!handlers || !renderer) { throw new Error('terminal live input hook did not render') } diff --git a/mobile/src/terminal/use-terminal-live-input-focus.test.ts b/mobile/src/terminal/use-terminal-live-input-focus.test.ts index f1b6c5eadb9..92ce4e2c455 100644 --- a/mobile/src/terminal/use-terminal-live-input-focus.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-focus.test.ts @@ -1,6 +1,6 @@ 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 { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLiveInputFocusTarget, TerminalLiveInputFocusTimerRef @@ -20,17 +20,6 @@ type HarnessProps = { type FocusHandlers = ReturnType -function suppressReactTestRendererWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - function createFocusTarget(initiallyFocused = false): TerminalLiveInputFocusTarget & { readonly blur: ReturnType readonly focus: ReturnType @@ -67,14 +56,9 @@ function createHarness(initialProps: HarnessProps): { return null } - const restoreWarning = suppressReactTestRendererWarning() - try { - act(() => { - renderer = create(createElement(Harness, initialProps)) - }) - } finally { - restoreWarning() - } + act(() => { + renderer = create(createElement(Harness, initialProps)) + }) if (!handlers || !renderer) { throw new Error('terminal live input focus harness did not render') } @@ -113,10 +97,6 @@ function connectedProps( } describe('terminal live input focus hook', () => { - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { vi.useRealTimers() }) diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index b08899f5333..59b0a7ac5dc 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -71,18 +71,6 @@ type Harness = { readonly unmount: () => void } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - async function renderHarness(hostId: string): Promise { let hook: ReturnType | null = null let closeHost: ((hostId: string) => void) | null = null @@ -94,14 +82,9 @@ async function renderHarness(hostId: string): Promise { return null } - const restore = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) if (!hook || !closeHost || !renderer) { throw new Error('harness did not render') } @@ -124,7 +107,6 @@ async function renderHarness(hostId: string): Promise { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true connectMock.mockReset() loadHostsMock.mockReset() }) @@ -149,7 +131,6 @@ describe('useHostClient', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -169,7 +150,6 @@ describe('useHostClient', () => { expect(selectedState).toBe('disconnected') expect(connectMock).toHaveBeenCalledTimes(2) } finally { - restore() act(() => renderer?.unmount()) } }) @@ -188,7 +168,6 @@ describe('useHostClient', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -211,7 +190,6 @@ describe('useHostClient', () => { }) expect(stateByRenderTick.get(2)).toBe('connecting') } finally { - restore() act(() => renderer?.unmount()) } }) @@ -263,7 +241,6 @@ describe('useHostClient', () => { states.push(useHostClient(HOST.id).state) return null } - const restore = suppressReactTestRendererDeprecationWarning() try { act(() => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -277,7 +254,6 @@ describe('useHostClient', () => { expect(states.at(-1)).toBe('connecting') expect(states).not.toContain('disconnected') } finally { - restore() act(() => renderer?.unmount()) } }) @@ -296,7 +272,6 @@ describe('useHostClient', () => { states.push(useHostClient(HOST.id).state) return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -311,7 +286,6 @@ describe('useHostClient', () => { expect(states.at(-1)).toBe('connecting') expect(states).not.toContain('disconnected') } finally { - restore() act(() => renderer?.unmount()) } }) @@ -333,14 +307,9 @@ describe('useHostClient', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) expect(loadHostsMock).toHaveBeenCalledOnce() if (!closeHost || !resolveHosts || !renderer) { throw new Error('pending-open harness did not initialize') @@ -370,14 +339,9 @@ describe('useHostClient', () => { useHostClient(HOST.id) return null } - const restore = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) expect(loadHostsMock).toHaveBeenCalledOnce() act(() => renderer?.unmount()) await act(async () => { @@ -401,7 +365,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -410,7 +373,6 @@ describe('useAllHostClients', () => { expect(connectMock).toHaveBeenCalledOnce() expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function)) } finally { - restore() act(() => renderer?.unmount()) } }) @@ -433,7 +395,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -446,7 +407,6 @@ describe('useAllHostClients', () => { 'host-997' ]) } finally { - restore() act(() => renderer?.unmount()) } }) @@ -477,7 +437,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create( @@ -504,7 +463,6 @@ describe('useAllHostClients', () => { expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce() expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled() } finally { - restore() act(() => renderer?.unmount()) } }) @@ -520,7 +478,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -528,7 +485,6 @@ describe('useAllHostClients', () => { }) expect(connectMock).toHaveBeenCalledTimes(2) } finally { - restore() act(() => renderer?.unmount()) } }) @@ -545,7 +501,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -559,7 +514,6 @@ describe('useAllHostClients', () => { }) expect(connectMock).toHaveBeenCalledOnce() } finally { - restore() act(() => renderer?.unmount()) } }) diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts index 483cfcca50f..f0454243703 100644 --- a/mobile/src/transport/host-status-gates.test.ts +++ b/mobile/src/transport/host-status-gates.test.ts @@ -4,17 +4,6 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from './rpc-client' import { useHostStatusGates, type HostStatusGates } from './host-status-gates' -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - describe('useHostStatusGates', () => { it('clears every prior-host gate and ignores its late response while the client is replaced', async () => { let resolveOldStatus: ((response: unknown) => void) | null = null @@ -43,7 +32,6 @@ describe('useHostStatusGates', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(Probe, { hostId: 'host-1', client: oldClient })) @@ -80,7 +68,6 @@ describe('useHostStatusGates', () => { expect(oldSendRequest).toHaveBeenCalledOnce() expect(newSendRequest).toHaveBeenCalledOnce() } finally { - restore() renderer?.unmount() } }) @@ -102,7 +89,6 @@ describe('useHostStatusGates', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(Probe, { hostId: 'host-1' })) @@ -115,7 +101,6 @@ describe('useHostStatusGates', () => { expect(sendRequest).toHaveBeenCalledOnce() } finally { - restore() renderer?.unmount() } }) @@ -141,7 +126,6 @@ describe('useHostStatusGates', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(Probe, { connState: 'connected' })) @@ -181,7 +165,6 @@ describe('useHostStatusGates', () => { statusPending: false }) } finally { - restore() renderer?.unmount() } }) @@ -204,7 +187,6 @@ describe('useHostStatusGates', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(Probe, { client: firstClient })) @@ -217,7 +199,6 @@ describe('useHostStatusGates', () => { }) expect(gates).toMatchObject({ hostCapabilities: [], statusPending: true }) } finally { - restore() renderer?.unmount() } }) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 99421ffd192..c32ac44ac77 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -182,7 +182,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } } let next: StoredHostProfile[] - if (index >= 0) { + if (index !== -1) { updatedExistingHost = true // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. next = hosts @@ -195,7 +195,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] } if (duplicateHostIds.size > 0) { - if (index < 0) { + if (index === -1) { // Why: process death between the early token write and metadata publication must leave cleanup discoverable. await recordHostCredentialCleanupIntent(stored.id) cleanupIntentRecordedBeforeMetadata = true @@ -304,7 +304,7 @@ export async function updateHostNameAndEndpoint( ): Promise { await mutateStoredHosts((hosts) => { const index = hosts.findIndex((host) => host.id === hostId) - if (index < 0) { + if (index === -1) { throw new Error('Host not found') } const next = hosts.slice() @@ -321,7 +321,7 @@ export async function updateLastConnected(hostId: string): Promise { try { await mutateStoredHosts((hosts) => { const index = hosts.findIndex((h) => h.id === hostId) - if (index < 0) { + if (index === -1) { return hosts } const next = hosts.slice() diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index 7f1519eb5c9..4960fbec3f0 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -80,7 +80,7 @@ export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay const validated = MobileRelayHostOverlaySchema.parse(overlay) return mutateOverlays((overlays) => { const index = overlays.findIndex(({ hostId }) => hostId === validated.hostId) - if (index < 0) { + if (index === -1) { return [...overlays, validated] } const next = overlays.slice() diff --git a/mobile/src/transport/pairing-connection-attempt.test.ts b/mobile/src/transport/pairing-connection-attempt.test.ts deleted file mode 100644 index b05867eb7c0..00000000000 --- a/mobile/src/transport/pairing-connection-attempt.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { startPairingConnectionAttempt } from './pairing-connection-attempt' - -describe('pairing connection attempt cleanup', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('closes the temporary client when the overall pairing timeout fires', () => { - vi.useFakeTimers() - const closeClient = vi.fn() - - const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) - - expect(attempt.timedOut).toBe(false) - vi.advanceTimersByTime(24_999) - expect(closeClient).not.toHaveBeenCalled() - - vi.advanceTimersByTime(1) - expect(attempt.timedOut).toBe(true) - expect(closeClient).toHaveBeenCalledTimes(1) - - attempt.dispose() - expect(closeClient).toHaveBeenCalledTimes(1) - }) - - it('clears the timeout and closes the temporary client when disposed early', () => { - vi.useFakeTimers() - const closeClient = vi.fn() - - const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) - attempt.dispose() - - expect(attempt.timedOut).toBe(false) - expect(closeClient).toHaveBeenCalledTimes(1) - - vi.advanceTimersByTime(25_000) - expect(attempt.timedOut).toBe(false) - expect(closeClient).toHaveBeenCalledTimes(1) - }) -}) diff --git a/mobile/src/transport/pairing-connection-attempt.ts b/mobile/src/transport/pairing-connection-attempt.ts deleted file mode 100644 index 0a2d564949a..00000000000 --- a/mobile/src/transport/pairing-connection-attempt.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type PairingConnectionAttempt = { - readonly timedOut: boolean - dispose: () => void -} - -export function startPairingConnectionAttempt({ - timeoutMs, - closeClient -}: { - timeoutMs: number - closeClient: () => void -}): PairingConnectionAttempt { - let disposed = false - let clientClosed = false - let timedOut = false - let timer: ReturnType | null = null - - function closeClientOnce() { - if (clientClosed) { - return - } - clientClosed = true - closeClient() - } - - function dispose() { - if (disposed) { - return - } - disposed = true - if (timer) { - clearTimeout(timer) - timer = null - } - closeClientOnce() - } - - timer = setTimeout(() => { - timer = null - timedOut = true - dispose() - }, timeoutMs) - - return { - get timedOut() { - return timedOut - }, - dispose - } -} diff --git a/mobile/src/transport/rpc-response-shape.ts b/mobile/src/transport/rpc-response-shape.ts index 54903f5f3e1..61ecaf57362 100644 --- a/mobile/src/transport/rpc-response-shape.ts +++ b/mobile/src/transport/rpc-response-shape.ts @@ -13,7 +13,7 @@ export function isRpcResponse(value: unknown): value is RpcResponse { return false } if (response.ok === true) { - return Object.prototype.hasOwnProperty.call(response, 'result') + return Object.hasOwn(response, 'result') } return ( response.ok === false && diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts index 7a23c86ac71..2762e7e85a5 100644 --- a/mobile/src/transport/settings-host-client-lifecycle.test.ts +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -151,29 +151,13 @@ function TestApp({ ) } -function suppressRendererWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - async function renderScreen(screen: Screen, detailHostId?: string): Promise { let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(TestApp, { screen, detailHostId })) - await Promise.resolve() - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) if (!renderer) { throw new Error('settings lifecycle harness did not render') } @@ -203,7 +187,6 @@ function activeHostIds(): string[] { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true context = null routeFocus.effect = null connectMock.mockReset() @@ -235,16 +218,11 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(NavigationStack, { settingsVisible: false })) - await Promise.resolve() - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(NavigationStack, { settingsVisible: false })) + await Promise.resolve() + await Promise.resolve() + }) if (!renderer) { throw new Error('navigation stack harness did not render') } @@ -354,20 +332,15 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(HostListApp, { - settingsHostIds: [retainedHostId, sharedHostId, removedHostId] - }) - ) - await Promise.resolve() - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, sharedHostId, removedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) if (!renderer) { throw new Error('host-list lifecycle harness did not render') } @@ -451,14 +424,9 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - act(() => { - renderer = create(createElement(RetryApp, { settingsVisible: true })) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(RetryApp, { settingsVisible: true })) + }) if (!renderer || !resolveInitial || !resolveRetry) { throw new Error('retry lifecycle harness did not initialize') } @@ -517,14 +485,9 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - act(() => { - renderer = create(createElement(PendingApp, { visible: true })) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(PendingApp, { visible: true })) + }) if (!renderer || !resolveFirst || !resolveSecond) { throw new Error('pending settings harness did not initialize') } @@ -580,17 +543,12 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) - ) - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + await Promise.resolve() + }) if (!renderer || !context) { throw new Error('replacement lifecycle harness did not initialize') } @@ -665,14 +623,9 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - act(() => { - renderer = create(createElement(ManualApp, { selected: false })) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(ManualApp, { selected: false })) + }) if (!renderer || !context) { throw new Error('manual connection harness did not initialize') } @@ -735,16 +688,11 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(FocusStack)) - await Promise.resolve() - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(FocusStack)) + await Promise.resolve() + await Promise.resolve() + }) if (!renderer || !routeFocus.effect) { throw new Error('settings focus harness did not initialize') } diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts index e39f90eedf1..981a1b55e74 100644 --- a/mobile/vitest.config.ts +++ b/mobile/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ oxc: vitestOxcConfig, test: { environment: 'node', + setupFiles: ['./vitest.setup.ts'], + onConsoleLog: (log) => !log.includes('react-test-renderer is deprecated'), // .tsx too: component tests exist (react-test-renderer + mocked react-native) and were // silently never collected, so render-level regressions shipped untested. include: ['src/**/*.test.ts', 'src/**/*.test.tsx'] diff --git a/mobile/vitest.setup.ts b/mobile/vitest.setup.ts new file mode 100644 index 00000000000..740c1eb1bab --- /dev/null +++ b/mobile/vitest.setup.ts @@ -0,0 +1 @@ +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) diff --git a/native/computer-use-linux/runtime.py b/native/computer-use-linux/runtime.py index baeed544bd6..ac53f6cf65f 100644 --- a/native/computer-use-linux/runtime.py +++ b/native/computer-use-linux/runtime.py @@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs item = record(node, len(records), path, window_rect) child_items = list(children(node)) role_key = (item["controlType"] or "").lower() - summary_values = text_snippets(node, limit=8, max_depth=4) - generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None + generic_summary = None + if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]: + summary_values = text_snippets(node, limit=8, max_depth=4) + if len(summary_values) >= 2 and is_plain_text_subtree(node): + generic_summary = " ".join(summary_values) if should_elide(item, len(child_items), generic_summary): for child_index, child in child_items: walk(child, depth, path + [child_index]) diff --git a/native/computer-use-linux/runtime_render_test.py b/native/computer-use-linux/runtime_render_test.py new file mode 100644 index 00000000000..83a600c7d2d --- /dev/null +++ b/native/computer-use-linux/runtime_render_test.py @@ -0,0 +1,198 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +class FakeStateType: + PROTECTED = 1 + SELECTED = 2 + + +class FakeText: + @staticmethod + def get_character_count(node): + return len(node.value) + + @staticmethod + def get_text(node, start, end): + return node.value[start:end] + + +def load_runtime(): + gi = types.ModuleType("gi") + repository = types.ModuleType("gi.repository") + gi.require_version = lambda *_: None + repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText) + repository.Gdk = types.SimpleNamespace() + repository.GdkPixbuf = types.SimpleNamespace() + gi.repository = repository + sys.modules["gi"] = gi + sys.modules["gi.repository"] = repository + + path = Path(__file__).with_name("runtime.py") + spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +runtime = load_runtime() + + +class FakeAccessible: + def __init__(self, role, name="", value="", children=(), counter=None): + self.role = role + self.name = name + self.value = value + self.children = list(children) + self.counter = counter or {"child_reads": 0} + for child in self.children: + child.use_counter(self.counter) + + def use_counter(self, counter): + self.counter = counter + for child in self.children: + child.use_counter(counter) + + def get_child_count(self): + self.counter["child_reads"] += 1 + return len(self.children) + + def get_child_at_index(self, index): + self.counter["child_reads"] += 1 + return self.children[index] + + def get_role_name(self): + return self.role + + def get_name(self): + return self.name + + def get_accessible_id(self): + return "" + + def get_toolkit_name(self): + return "fake" + + def get_component_iface(self): + return None + + def get_state_set(self): + return None + + def get_n_actions(self): + return 0 + + def is_text(self): + return bool(self.value) + + def get_text_iface(self): + return self if self.value else None + + def get_value_iface(self): + return None + + +class FailingChildrenAccessible(FakeAccessible): + def get_child_count(self): + raise RuntimeError("defunct node") + + +def render(root): + return runtime.render_accessibility_tree(root, None, [0]) + + +class RuntimeRenderTest(unittest.TestCase): + def test_named_non_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")]) + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Save"]) + self.assertEqual(lines, ["0 button Save"]) + self.assertEqual(root.counter["child_reads"], 2) + self.assertFalse(truncation["truncated"]) + + def test_named_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")]) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Details", "body"]) + self.assertEqual(lines, ["0 section Details", "\t1 text body"]) + self.assertEqual(root.counter["child_reads"], 3) + + def test_unnamed_generic_node_keeps_plain_text_summary(self): + root = FakeAccessible( + "section", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual(len(records), 1) + self.assertEqual(lines, ["0 section, Text: Alpha Beta"]) + self.assertEqual(root.counter["child_reads"], 13) + + def test_row_keeps_its_specific_summary_walk(self): + root = FakeAccessible( + "row", + "Invoice", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"]) + self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"]) + self.assertEqual(root.counter["child_reads"], 10) + + def test_empty_generic_wrapper_keeps_elision_path_and_depth(self): + root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")]) + + records, lines, _ = render(root) + + self.assertEqual([record["runtimeId"] for record in records], [[0, 0]]) + self.assertEqual(lines, ["0 button Continue"]) + + def test_child_failure_keeps_fail_soft_row_output(self): + root = FailingChildrenAccessible("row", "Invoice") + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice"]) + self.assertEqual(lines, ["0 row Invoice"]) + self.assertFalse(truncation["truncated"]) + + def test_node_limit_keeps_exact_prefix_and_truncation(self): + root = FakeAccessible( + "document", + "Results", + children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)], + ) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_NODES) + self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}") + self.assertTrue(truncation["truncated"]) + self.assertFalse(truncation["maxDepthReached"]) + + def test_depth_limit_keeps_exact_prefix_and_flag(self): + root = FakeAccessible("document", "Depth 65") + for depth in range(runtime.MAX_DEPTH, -1, -1): + root = FakeAccessible("document", f"Depth {depth}", children=[root]) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_DEPTH + 1) + self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}") + self.assertTrue(truncation["truncated"]) + self.assertTrue(truncation["maxDepthReached"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 61ee277156d..0c56041ee46 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -1044,11 +1044,7 @@ private func screenCaptureTrusted() -> Bool { } private func screenCaptureTrustedSettled() -> Bool { - PermissionTrustSettling.settleWithFallback( - finalTimeoutMs: 500, - probe: screenCaptureTrusted, - fallbackProbe: screenCaptureTrustedByCaptureProbe - ) + PermissionTrustSettling.settle(timeoutMs: 2_000, probe: screenCaptureTrusted).settled } private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot { @@ -1058,40 +1054,6 @@ private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot { ) } -private func screenCaptureTrustedByCaptureProbe() -> Bool { - guard let infos = CGWindowListCopyWindowInfo( - [.optionOnScreenOnly], - kCGNullWindowID - ) as? [[String: Any]] else { - return false - } - let windows = infos.compactMap { info -> ScreenCaptureProbeWindow? in - guard let layer = info[kCGWindowLayer as String] as? Int, - let ownerPid = info[kCGWindowOwnerPID as String] as? NSNumber, - let number = info[kCGWindowNumber as String] as? NSNumber - else { - return nil - } - return ScreenCaptureProbeWindow( - layer: layer, - ownerPid: ownerPid.int32Value, - windowId: number.uint32Value - ) - } - guard let windowId = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: ProcessInfo.processInfo.processIdentifier, - windows: windows - ) else { - return false - } - return CGWindowListCreateImage( - .null, - [.optionIncludingWindow], - CGWindowID(windowId), - [.boundsIgnoreFraming] - ) != nil -} - private func requestScreenCaptureAccess() -> Bool { CGRequestScreenCaptureAccess() } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift index 80612664454..0f7d0bd8cf6 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift @@ -41,33 +41,4 @@ public enum PermissionTrustSettling { waitedMs += interval } } - - public static func settleWithFallback( - initialTimeoutMs: Int = defaultTimeoutMs, - finalTimeoutMs: Int, - intervalMs: Int = defaultIntervalMs, - sleepMs: (Int) -> Void = { interval in - Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000) - }, - probe: () -> Bool, - fallbackProbe: () -> Bool - ) -> Bool { - if settle( - timeoutMs: initialTimeoutMs, - intervalMs: intervalMs, - sleepMs: sleepMs, - probe: probe - ).settled { - return true - } - if fallbackProbe() { - return true - } - return settle( - timeoutMs: finalTimeoutMs, - intervalMs: intervalMs, - sleepMs: sleepMs, - probe: probe - ).settled - } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift deleted file mode 100644 index 36b4346a8bc..00000000000 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift +++ /dev/null @@ -1,22 +0,0 @@ -public struct ScreenCaptureProbeWindow: Equatable { - public let layer: Int - public let ownerPid: Int32 - public let windowId: UInt32 - - public init(layer: Int, ownerPid: Int32, windowId: UInt32) { - self.layer = layer - self.ownerPid = ownerPid - self.windowId = windowId - } -} - -public enum ScreenCaptureProbeWindowSelection { - public static func firstCrossProcessNormalWindow( - ownPid: Int32, - windows: [ScreenCaptureProbeWindow] - ) -> UInt32? { - windows.first { window in - window.layer == 0 && window.ownerPid != ownPid - }?.windowId - } -} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift new file mode 100644 index 00000000000..95deec57a04 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift @@ -0,0 +1,31 @@ +import XCTest + +final class ScreenCapturePermissionPreflightSafetyTests: XCTestCase { + func testPermissionPreflightCannotRequestOrCaptureTheScreen() throws { + let source = try computerUseSource() + let start = try XCTUnwrap(source.range(of: "private func screenCaptureTrusted()")) + let end = try XCTUnwrap( + source.range(of: "private func requestScreenCaptureAccess()", range: start.upperBound.. String { + let testFile = URL(fileURLWithPath: #filePath) + let packageRoot = testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let mainPath = packageRoot + .appendingPathComponent("Sources") + .appendingPathComponent("OrcaComputerUseMacOS") + .appendingPathComponent("main.swift") + return try String(contentsOf: mainPath, encoding: .utf8) + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift deleted file mode 100644 index d53991d36d0..00000000000 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Testing -@testable import OrcaComputerUseMacOSCore - -@Suite("Screen Capture permission settling") -struct ScreenCapturePermissionSettlingTests { - @Test("initial preflight success skips fallback") - func initialSuccess() { - var fallbackCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - finalTimeoutMs: 500, - sleepMs: { _ in }, - probe: { true }, - fallbackProbe: { - fallbackCalls += 1 - return true - } - ) - - #expect(trusted) - #expect(fallbackCalls == 0) - } - - @Test("capture fallback can establish trust") - func fallbackSuccess() { - var fallbackCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - initialTimeoutMs: 200, - finalTimeoutMs: 200, - intervalMs: 100, - sleepMs: { _ in }, - probe: { false }, - fallbackProbe: { - fallbackCalls += 1 - return true - } - ) - - #expect(trusted) - #expect(fallbackCalls == 1) - } - - @Test("preflight is retried after fallback failure") - func finalPreflightSuccess() { - var probeCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - initialTimeoutMs: 100, - finalTimeoutMs: 200, - intervalMs: 100, - sleepMs: { _ in }, - probe: { - probeCalls += 1 - return probeCalls == 4 - }, - fallbackProbe: { false } - ) - - #expect(trusted) - #expect(probeCalls == 4) - } - - @Test("persistent denial remains denied") - func persistentDenial() { - var fallbackCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - initialTimeoutMs: 100, - finalTimeoutMs: 100, - intervalMs: 100, - sleepMs: { _ in }, - probe: { false }, - fallbackProbe: { - fallbackCalls += 1 - return false - } - ) - - #expect(!trusted) - #expect(fallbackCalls == 1) - } -} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift deleted file mode 100644 index 96afd6fbccb..00000000000 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Testing -@testable import OrcaComputerUseMacOSCore - -@Suite("Screen capture probe window selection") -struct ScreenCaptureProbeWindowSelectionTests { - @Test("own process windows cannot prove cross-app capture permission") - func excludesOwnWindows() { - let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: 42, - windows: [ - .init(layer: 0, ownerPid: 42, windowId: 1), - .init(layer: 0, ownerPid: 84, windowId: 2) - ] - ) - - #expect(selected == 2) - } - - @Test("non-normal windows are skipped") - func excludesNonNormalWindows() { - let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: 42, - windows: [ - .init(layer: 1, ownerPid: 84, windowId: 1), - .init(layer: 0, ownerPid: 84, windowId: 2) - ] - ) - - #expect(selected == 2) - } - - @Test("no cross-process normal window returns nil") - func noCandidate() { - let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: 42, - windows: [ - .init(layer: 0, ownerPid: 42, windowId: 1), - .init(layer: 1, ownerPid: 84, windowId: 2) - ] - ) - - #expect(selected == nil) - } -} diff --git a/native/computer-use-windows/runtime-render.test.ps1 b/native/computer-use-windows/runtime-render.test.ps1 new file mode 100644 index 00000000000..3e52b1a203e --- /dev/null +++ b/native/computer-use-windows/runtime-render.test.ps1 @@ -0,0 +1,224 @@ +$ErrorActionPreference = "Stop" + +function Assert-TestEqual($Actual, $Expected, [string]$Label) { + if ($Actual -ne $Expected) { + throw "$Label expected '$Expected', received '$Actual'" + } +} + +Add-Type -TypeDefinition @" +using System.Collections; +using System.Collections.Generic; + +public sealed class OrcaRenderTestCounter { + public int FindAll; +} + +public sealed class OrcaRenderTestBounds { + public bool IsEmpty = true; + public double X; + public double Y; + public double Width; + public double Height; +} + +public sealed class OrcaRenderTestControlType { + public string ProgrammaticName = ""; +} + +public sealed class OrcaRenderTestCurrent { + public string AutomationId = ""; + public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds(); + public string ClassName = "fake"; + public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType(); + public bool IsPassword; + public string LocalizedControlType = ""; + public string Name = ""; + public long NativeWindowHandle; +} + +public sealed class OrcaRenderTestPatternCurrent { + public bool IsSelected; + public string Value = ""; +} + +public sealed class OrcaRenderTestPattern { + public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent(); +} + +public sealed class OrcaRenderTestCollection : IEnumerable { + private readonly OrcaRenderTestElement[] values; + + public OrcaRenderTestCollection(OrcaRenderTestElement[] values) { + this.values = values; + } + + public int Count { + get { return values.Length; } + } + + public OrcaRenderTestElement Item(int index) { + return values[index]; + } + + public IEnumerator GetEnumerator() { + return values.GetEnumerator(); + } +} + +public sealed class OrcaRenderTestElement { + public readonly List Children = + new List(); + public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter(); + public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent(); + public bool FailFindAll; + public int RuntimeIdValue; + public string ValueText = ""; + + public OrcaRenderTestCollection FindAll(object scope, object condition) { + Counter.FindAll++; + if (FailFindAll) { + throw new System.InvalidOperationException("defunct node"); + } + return new OrcaRenderTestCollection(Children.ToArray()); + } + + public OrcaRenderTestPattern GetCurrentPattern(object pattern) { + OrcaRenderTestPattern result = new OrcaRenderTestPattern(); + result.Current.Value = ValueText; + return result; + } + + public int[] GetRuntimeId() { + return new int[] { RuntimeIdValue }; + } + + public object[] GetSupportedPatterns() { + return new object[0]; + } +} +"@ + +function New-TestCounter { + New-Object -TypeName OrcaRenderTestCounter +} + +function New-TestElement { + param( + [string]$Role, + [string]$Name = "", + [string]$Value = "", + [object[]]$Children = @(), + $Counter = $(New-TestCounter), + [switch]$FailFindAll, + [int]$RuntimeId = 1 + ) + $element = New-Object -TypeName OrcaRenderTestElement + $element.Counter = $Counter + $element.FailFindAll = [bool]$FailFindAll + $element.RuntimeIdValue = $RuntimeId + $element.ValueText = $Value + $element.Current.ControlType.ProgrammaticName = $Role + $element.Current.LocalizedControlType = $Role + $element.Current.Name = $Name + foreach ($child in @($Children)) { + [void]$element.Children.Add($child) + } + $element +} + +$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json") +try { + Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}' + $runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath + $handshake = $runtimeOutput | ConvertFrom-Json + Assert-TestEqual $handshake.ok $true "runtime handshake" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "named control record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line" + Assert-TestEqual $counter.findAll 1 "named control child enumeration" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records" + Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines" + Assert-TestEqual $counter.findAll 2 "named generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary" + Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary" + Assert-TestEqual $counter.findAll 6 "row child enumeration" + + $counter = New-TestCounter + $button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Children @($button) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line" + Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration" + + $counter = New-TestCounter + $root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "failed child read record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line" + Assert-TestEqual $counter.findAll 2 "failed child read retries" + + $originalMaxNodes = $MaxNodes + try { + $MaxNodes = 3 + $counter = New-TestCounter + $children = @() + for ($index = 0; $index -lt 3; $index++) { + $children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2) + } + $root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "node limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "node limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag" + } finally { + $MaxNodes = $originalMaxNodes + } + + $originalMaxDepth = $MaxDepth + try { + $MaxDepth = 2 + $counter = New-TestCounter + $root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4 + for ($depth = 2; $depth -ge 0; $depth--) { + $root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1) + } + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "depth limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag" + } finally { + $MaxDepth = $originalMaxDepth + } + + Write-Output "windows-snapshot-render-tests-ok" +} finally { + Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue +} diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 39ab77e1704..2aa581901e6 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs = $title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name } $role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType } $roleKey = $role.ToLowerInvariant() - $snippets = @(Get-OrcaTextSnippets $Node 8 4) $genericSummary = $null - if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { - $genericSummary = ($snippets -join " ") + if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) { + $snippets = @(Get-OrcaTextSnippets $Node 8 4) + if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { + $genericSummary = ($snippets -join " ") + } } if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) { for ($i = 0; $i -lt $children.Count; $i++) { diff --git a/package.json b/package.json index 06407e81144..6a0e4f33814 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,9 @@ "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/main-thread-jank-bench.mjs", "bench:worktree-deletion": "node tests/tools/benchmarks/worktree-deletion-dev-bench.mjs", "bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs", + "bench:worktree-refresh-churn": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/worktree-refresh-churn-benchmark.mjs", "bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs", + "bench:ai-vault-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-ai-vault-typing-bench.mjs", "bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs", "bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs", @@ -215,7 +217,7 @@ "mermaid": "^11.16.1", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", - "oxlint": "^1.75.0", + "oxlint": "^1.77.0", "oxlint-plugin-react-doctor": "0.9.1", "oxlint-tsgolint": "7.0.2001", "pdfjs-dist": "^6.2.108", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1872f9860e8..57885d16e25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,8 +294,8 @@ importers: specifier: ^0.52.0 version: 0.52.0 oxlint: - specifier: ^1.75.0 - version: 1.75.0(oxlint-tsgolint@7.0.2001) + specifier: ^1.77.0 + version: 1.77.0(oxlint-tsgolint@7.0.2001) oxlint-plugin-react-doctor: specifier: 0.9.1 version: 0.9.1 @@ -1488,124 +1488,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.75.0': - resolution: {integrity: sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==} + '@oxlint/binding-android-arm-eabi@1.77.0': + resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.75.0': - resolution: {integrity: sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==} + '@oxlint/binding-android-arm64@1.77.0': + resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.75.0': - resolution: {integrity: sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==} + '@oxlint/binding-darwin-arm64@1.77.0': + resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.75.0': - resolution: {integrity: sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==} + '@oxlint/binding-darwin-x64@1.77.0': + resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.75.0': - resolution: {integrity: sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==} + '@oxlint/binding-freebsd-x64@1.77.0': + resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.75.0': - resolution: {integrity: sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==} + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': + resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.75.0': - resolution: {integrity: sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==} + '@oxlint/binding-linux-arm-musleabihf@1.77.0': + resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.75.0': - resolution: {integrity: sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==} + '@oxlint/binding-linux-arm64-gnu@1.77.0': + resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.75.0': - resolution: {integrity: sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==} + '@oxlint/binding-linux-arm64-musl@1.77.0': + resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.75.0': - resolution: {integrity: sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==} + '@oxlint/binding-linux-ppc64-gnu@1.77.0': + resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.75.0': - resolution: {integrity: sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==} + '@oxlint/binding-linux-riscv64-gnu@1.77.0': + resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.75.0': - resolution: {integrity: sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==} + '@oxlint/binding-linux-riscv64-musl@1.77.0': + resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.75.0': - resolution: {integrity: sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==} + '@oxlint/binding-linux-s390x-gnu@1.77.0': + resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.75.0': - resolution: {integrity: sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==} + '@oxlint/binding-linux-x64-gnu@1.77.0': + resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.75.0': - resolution: {integrity: sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==} + '@oxlint/binding-linux-x64-musl@1.77.0': + resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.75.0': - resolution: {integrity: sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==} + '@oxlint/binding-openharmony-arm64@1.77.0': + resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.75.0': - resolution: {integrity: sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==} + '@oxlint/binding-win32-arm64-msvc@1.77.0': + resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.75.0': - resolution: {integrity: sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==} + '@oxlint/binding-win32-ia32-msvc@1.77.0': + resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.75.0': - resolution: {integrity: sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==} + '@oxlint/binding-win32-x64-msvc@1.77.0': + resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5541,8 +5541,8 @@ packages: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.75.0: - resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} + oxlint@1.77.0: + resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -7809,61 +7809,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.75.0': + '@oxlint/binding-android-arm-eabi@1.77.0': optional: true - '@oxlint/binding-android-arm64@1.75.0': + '@oxlint/binding-android-arm64@1.77.0': optional: true - '@oxlint/binding-darwin-arm64@1.75.0': + '@oxlint/binding-darwin-arm64@1.77.0': optional: true - '@oxlint/binding-darwin-x64@1.75.0': + '@oxlint/binding-darwin-x64@1.77.0': optional: true - '@oxlint/binding-freebsd-x64@1.75.0': + '@oxlint/binding-freebsd-x64@1.77.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.75.0': + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.75.0': + '@oxlint/binding-linux-arm-musleabihf@1.77.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.75.0': + '@oxlint/binding-linux-arm64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.75.0': + '@oxlint/binding-linux-arm64-musl@1.77.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.75.0': + '@oxlint/binding-linux-ppc64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.75.0': + '@oxlint/binding-linux-riscv64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.75.0': + '@oxlint/binding-linux-riscv64-musl@1.77.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.75.0': + '@oxlint/binding-linux-s390x-gnu@1.77.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.75.0': + '@oxlint/binding-linux-x64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-x64-musl@1.75.0': + '@oxlint/binding-linux-x64-musl@1.77.0': optional: true - '@oxlint/binding-openharmony-arm64@1.75.0': + '@oxlint/binding-openharmony-arm64@1.77.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.75.0': + '@oxlint/binding-win32-arm64-msvc@1.77.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.75.0': + '@oxlint/binding-win32-ia32-msvc@1.77.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.75.0': + '@oxlint/binding-win32-x64-msvc@1.77.0': optional: true '@parcel/watcher-android-arm64@2.5.6': @@ -12174,27 +12174,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.75.0(oxlint-tsgolint@7.0.2001): + oxlint@1.77.0(oxlint-tsgolint@7.0.2001): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.75.0 - '@oxlint/binding-android-arm64': 1.75.0 - '@oxlint/binding-darwin-arm64': 1.75.0 - '@oxlint/binding-darwin-x64': 1.75.0 - '@oxlint/binding-freebsd-x64': 1.75.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.75.0 - '@oxlint/binding-linux-arm-musleabihf': 1.75.0 - '@oxlint/binding-linux-arm64-gnu': 1.75.0 - '@oxlint/binding-linux-arm64-musl': 1.75.0 - '@oxlint/binding-linux-ppc64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-musl': 1.75.0 - '@oxlint/binding-linux-s390x-gnu': 1.75.0 - '@oxlint/binding-linux-x64-gnu': 1.75.0 - '@oxlint/binding-linux-x64-musl': 1.75.0 - '@oxlint/binding-openharmony-arm64': 1.75.0 - '@oxlint/binding-win32-arm64-msvc': 1.75.0 - '@oxlint/binding-win32-ia32-msvc': 1.75.0 - '@oxlint/binding-win32-x64-msvc': 1.75.0 + '@oxlint/binding-android-arm-eabi': 1.77.0 + '@oxlint/binding-android-arm64': 1.77.0 + '@oxlint/binding-darwin-arm64': 1.77.0 + '@oxlint/binding-darwin-x64': 1.77.0 + '@oxlint/binding-freebsd-x64': 1.77.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.77.0 + '@oxlint/binding-linux-arm-musleabihf': 1.77.0 + '@oxlint/binding-linux-arm64-gnu': 1.77.0 + '@oxlint/binding-linux-arm64-musl': 1.77.0 + '@oxlint/binding-linux-ppc64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-musl': 1.77.0 + '@oxlint/binding-linux-s390x-gnu': 1.77.0 + '@oxlint/binding-linux-x64-gnu': 1.77.0 + '@oxlint/binding-linux-x64-musl': 1.77.0 + '@oxlint/binding-openharmony-arm64': 1.77.0 + '@oxlint/binding-win32-arm64-msvc': 1.77.0 + '@oxlint/binding-win32-ia32-msvc': 1.77.0 + '@oxlint/binding-win32-x64-msvc': 1.77.0 oxlint-tsgolint: 7.0.2001 p-cancelable@2.1.1: {} diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index bc7b1cbbe3e..20b70a15b63 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -195,7 +195,7 @@ orca orchestration worker-start --task --worktree current --agent claud For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt: ```bash -orca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json +orca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json ``` `--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 487c9152b89..e0a65fc876f 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/handlers/emulator.test.ts b/src/cli/handlers/emulator.test.ts index 07bdd51e2f5..ff0dc5f141a 100644 --- a/src/cli/handlers/emulator.test.ts +++ b/src/cli/handlers/emulator.test.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { callMock, remoteMock } = vi.hoisted(() => ({ callMock: vi.fn(), @@ -29,6 +29,9 @@ import { main } from '../index' import { okFixture, queueFixtures } from '../test-fixtures' describe('orca emulator CLI handlers', () => { + const originalWorkspaceId = process.env.ORCA_WORKSPACE_ID + const originalWorktreeId = process.env.ORCA_WORKTREE_ID + beforeEach(() => { vi.restoreAllMocks() callMock.mockReset() @@ -38,6 +41,19 @@ describe('orca emulator CLI handlers', () => { process.exitCode = undefined }) + afterEach(() => { + if (originalWorkspaceId === undefined) { + delete process.env.ORCA_WORKSPACE_ID + } else { + process.env.ORCA_WORKSPACE_ID = originalWorkspaceId + } + if (originalWorktreeId === undefined) { + delete process.env.ORCA_WORKTREE_ID + } else { + process.env.ORCA_WORKTREE_ID = originalWorktreeId + } + }) + it('resolves relative APK paths before calling the runtime', async () => { queueFixtures(callMock, okFixture('req_install', { ok: true })) @@ -73,6 +89,46 @@ describe('orca emulator CLI handlers', () => { ) }) + it('uses the folder workspace exported by the current Orca terminal', async () => { + process.env.ORCA_WORKSPACE_ID = 'folder:folder-1' + delete process.env.ORCA_WORKTREE_ID + callMock.mockResolvedValue( + okFixture('req_attach', { + attached: true, + info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' } + }) + ) + + await main(['emulator', 'attach', 'device-1'], '/folder/project') + + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith( + 'emulator.attach', + { device: 'device-1', worktree: 'folder:folder-1', focus: false }, + { timeoutMs: 180_000 } + ) + }) + + it('uses the current git worktree exported by the Orca terminal', async () => { + process.env.ORCA_WORKSPACE_ID = 'folder:stale-parent' + process.env.ORCA_WORKTREE_ID = 'repo-1::/repo/project ' + callMock.mockResolvedValue( + okFixture('req_attach', { + attached: true, + info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' } + }) + ) + + await main(['emulator', 'attach', 'device-1'], '/repo/project') + + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith( + 'emulator.attach', + { device: 'device-1', worktree: 'repo-1::/repo/project ', focus: false }, + { timeoutMs: 180_000 } + ) + }) + it('rejects relative APK paths for remote runtimes', async () => { remoteMock.mockReturnValue(true) diff --git a/src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts b/src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts new file mode 100644 index 00000000000..a7b1766437e --- /dev/null +++ b/src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { reportCliError } from '../format' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +afterEach(() => { + vi.restoreAllMocks() +}) + +it('prints a rejected lifecycle verdict as a JSON failure envelope', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const client = { + call: vi.fn().mockResolvedValue({ + result: { + message: { id: 'msg_rejected' }, + lifecycle: { + action: 'rejected', + code: 'sender_not_assignee', + reason: 'dispatch ctx_1 expected the assigned pane' + } + } + }) + } + let rejection: unknown + try { + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_foreign'], + ['to', 'term_coord'], + ['subject', 'done'], + ['type', 'worker_done'], + ['outcome', 'succeeded'] + ]), + client, + cwd: '/tmp/repo', + json: true + } as never) + } catch (error) { + rejection = error + } + + expect(rejection).toBeDefined() + reportCliError(rejection, true, { commandPath: ['orchestration', 'send'] }) + + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + error: { + code: 'sender_not_assignee', + message: 'dispatch ctx_1 expected the assigned pane' + } + }) +}) diff --git a/src/cli/handlers/orchestration-lifecycle-rejection.test.ts b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts index c55eed4391d..369edf9c4f6 100644 --- a/src/cli/handlers/orchestration-lifecycle-rejection.test.ts +++ b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts @@ -10,9 +10,11 @@ import { ORCHESTRATION_HANDLERS } from './orchestration' afterEach(() => { process.exitCode = originalExitCode + vi.clearAllMocks() + callMock.mockReset() }) -it('prints a lifecycle rejection and exits unsuccessfully', async () => { +it('raises a lifecycle rejection for the CLI error boundary', async () => { const response = { result: { message: { id: 'msg_rejected' }, @@ -25,22 +27,328 @@ it('prints a lifecycle rejection and exits unsuccessfully', async () => { } callMock.mockResolvedValueOnce(response) + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_foreign'], + ['to', 'term_coord'], + ['subject', 'done'], + ['type', 'worker_done'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + ).rejects.toMatchObject({ + code: 'sender_not_assignee', + message: 'dispatch ctx_1 expected the assigned pane' + }) + expect(printResult).not.toHaveBeenCalled() +}) + +it('raises a Run-home relay rejection for the CLI error boundary', async () => { + callMock.mockResolvedValueOnce({ + result: { + relay: { + messageId: 'relay_rejected', + sequence: 1, + dispatchId: 'ctx_1', + destination: 'run_home', + accepted: true + }, + lifecycle: { + action: 'rejected', + code: 'task_dispatch_mismatch', + reason: 'wrong task', + authority: 'run_home' + } + } + }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_wrong'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'task_dispatch_mismatch', message: 'wrong task' }) + expect(printResult).not.toHaveBeenCalled() +}) + +it('accepts an explicit current settlement without a compatibility read', async () => { + callMock.mockResolvedValueOnce({ + result: { message: { id: 'msg_done' }, lifecycle: { action: 'completed' } } + }) + await ORCHESTRATION_HANDLERS['orchestration send']({ flags: new Map([ - ['from', 'term_foreign'], - ['to', 'term_coord'], + ['from', 'term_worker'], ['subject', 'done'], ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], ['outcome', 'succeeded'] ]), client: { call: callMock }, cwd: '/tmp/repo', - json: false + json: true } as never) - expect(process.exitCode).toBe(1) - const formatter = vi.mocked(printResult).mock.calls[0]?.[2] - expect(formatter?.(response.result)).toBe( - 'Rejected msg_rejected: dispatch ctx_1 expected the assigned pane' - ) + expect(callMock).toHaveBeenCalledOnce() +}) + +it('fails closed when an older runtime leaves worker_done dispatched without a verdict', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_unconfirmed', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ + result: { dispatch: { id: 'ctx_1', status: 'dispatched' } } + }) + .mockResolvedValueOnce({ result: { tasks: [] } }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_foreign'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'operation_unknown' }) + + expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.dispatchShow', { task: 'task_1' }) + expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.taskList', { + status: 'completed', + run: 'run_1' + }) +}) + +it('normalizes compatibility-read failures to operation_unknown', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_unconfirmed', run_id: 'run_1' } } + }) + .mockRejectedValueOnce(new Error('runtime disconnected')) + .mockResolvedValueOnce({ result: { tasks: [] } }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'operation_unknown' }) +}) + +it('accepts a legacy response only after the authoritative dispatch is terminal', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_legacy_completed', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ + result: { dispatch: { id: 'ctx_1', status: 'completed' } } + }) + .mockResolvedValueOnce({ + result: { + tasks: [ + { + id: 'task_1', + status: 'completed', + result: JSON.stringify({ + provenance: 'worker_report', + messageId: 'msg_legacy_completed', + outcome: 'succeeded' + }) + } + ] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + expect(callMock).toHaveBeenCalledTimes(3) + expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.dispatchShow', { task: 'task_1' }) +}) + +it('accepts a legacy failed report only after the authoritative dispatch failed', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_legacy_failed', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ result: { dispatch: { id: 'ctx_1', status: 'failed' } } }) + .mockResolvedValueOnce({ + result: { + tasks: [ + { + id: 'task_1', + status: 'failed', + result: JSON.stringify({ + provenance: 'worker_report', + messageId: 'msg_legacy_failed', + outcome: 'failed' + }) + } + ] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'failed'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'failed'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + expect(callMock).toHaveBeenCalledTimes(3) +}) + +it('accepts an idempotent retry whose first report already settled', async () => { + callMock + .mockResolvedValueOnce({ + result: { + message: { id: 'msg_retry', run_id: 'run_1', from_handle: 'term_worker' } + } + }) + .mockResolvedValueOnce({ result: { dispatch: { id: 'ctx_1', status: 'completed' } } }) + .mockResolvedValueOnce({ + result: { + tasks: [ + { + id: 'task_1', + status: 'completed', + result: JSON.stringify({ + provenance: 'worker_report', + messageId: 'msg_first', + reportedBy: 'term_worker', + outcome: 'succeeded' + }) + } + ] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + expect(callMock).toHaveBeenCalledTimes(3) +}) + +it('rejects an unrelated failed Dispatch that left its Task blocked', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_after_stop', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ result: { dispatch: { id: 'ctx_1', status: 'failed' } } }) + .mockResolvedValueOnce({ result: { tasks: [] } }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'failed'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'failed'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'operation_unknown' }) +}) + +it('rejects a terminal Dispatch with the wrong identity', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_stale', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ + result: { dispatch: { id: 'ctx_replacement', status: 'completed' } } + }) + .mockResolvedValueOnce({ + result: { + tasks: [ + { + id: 'task_1', + status: 'completed', + result: JSON.stringify({ + provenance: 'worker_report', + messageId: 'msg_stale', + outcome: 'succeeded' + }) + } + ] + } + }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'operation_unknown' }) }) diff --git a/src/cli/handlers/orchestration-migration.test.ts b/src/cli/handlers/orchestration-migration.test.ts index a1a117b90b3..863720eaab1 100644 --- a/src/cli/handlers/orchestration-migration.test.ts +++ b/src/cli/handlers/orchestration-migration.test.ts @@ -12,33 +12,43 @@ afterEach(() => { }) describe('orchestration CLI migration recovery', () => { - it('forwards legacy worker_done without inventing a completion outcome', async () => { - process.env.ORCA_PANE_KEY = 'tab-worker:leaf-worker' - const call = vi.fn().mockResolvedValue({ result: { message: { id: 'msg_done' } } }) + it.each([false, true])( + 'accepts an attested legacy worker_done settlement (replayed: %s)', + async (replayed) => { + process.env.ORCA_PANE_KEY = 'tab-worker:leaf-worker' + const call = vi.fn().mockResolvedValue({ + result: { + message: { id: 'msg_done' }, + lifecycle: { action: 'settled', outcome: 'succeeded', duplicate: replayed } + } + }) - await ORCHESTRATION_HANDLERS['orchestration send']({ - flags: new Map([ - ['from', 'term_worker'], - ['subject', 'Done'], - ['type', 'worker_done'] - ]), - client: { call }, - cwd: '/tmp/repo', - json: true - } as never) + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'Done'], + ['type', 'worker_done'] + ]), + client: { call }, + cwd: '/tmp/repo', + json: true + } as never) - expect(call).toHaveBeenCalledWith('orchestration.send', { - from: 'term_worker', - to: undefined, - run: undefined, - subject: 'Done', - body: undefined, - type: 'worker_done', - priority: undefined, - threadId: undefined, - payload: undefined, - senderPaneKey: 'tab-worker:leaf-worker', - devMode: false - }) - }) + expect(call).toHaveBeenCalledWith('orchestration.send', { + from: 'term_worker', + to: undefined, + run: undefined, + subject: 'Done', + body: undefined, + type: 'worker_done', + priority: undefined, + threadId: undefined, + payload: undefined, + senderPaneKey: 'tab-worker:leaf-worker', + waitForLifecycleSettlement: true, + devMode: false + }) + expect(call).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/cli/handlers/orchestration-worker-settlement.ts b/src/cli/handlers/orchestration-worker-settlement.ts new file mode 100644 index 00000000000..8b9425391ee --- /dev/null +++ b/src/cli/handlers/orchestration-worker-settlement.ts @@ -0,0 +1,147 @@ +import type { RuntimeClient } from '../runtime-client' +import { RuntimeClientError } from '../runtime-client' + +export async function requireWorkerDoneSettlement( + client: RuntimeClient, + type: string | undefined, + payload: string | undefined, + result: unknown +): Promise { + if (type !== 'worker_done' || hasLifecycleVerdict(result)) { + return + } + const target = parseWorkerDoneTarget(payload) + const receipt = parseWorkerDoneReceipt(result) + if (target && receipt) { + const [dispatchVerification, taskVerification] = await Promise.all([ + client.call<{ dispatch: { id: string; status: string } | null }>( + 'orchestration.dispatchShow', + { task: target.taskId } + ), + client.call<{ + tasks: { id: string; status: string; result: string | null }[] + }>('orchestration.taskList', { status: target.expectedStatus, run: receipt.runId }) + ]).catch(() => { + throw workerDoneSettlementUnknown() + }) + const task = taskVerification.result.tasks.find((candidate) => candidate.id === target.taskId) + if ( + dispatchVerification.result.dispatch?.id === target.dispatchId && + dispatchVerification.result.dispatch.status === target.expectedStatus && + task?.status === target.expectedStatus && + isExactWorkerReport(task.result, receipt, target.outcome) + ) { + return + } + } + throw workerDoneSettlementUnknown() +} + +function workerDoneSettlementUnknown(): RuntimeClientError { + return new RuntimeClientError( + 'operation_unknown', + 'The runtime accepted worker_done but did not confirm that the exact report settled its Task and Dispatch. Retry from the assigned worker after verifying its active Dispatch.' + ) +} + +function parseWorkerDoneReceipt( + result: unknown +): { messageId: string; runId: string; fromHandle?: string } | undefined { + if (!result || typeof result !== 'object' || !('message' in result)) { + return undefined + } + const message = (result as { message?: unknown }).message + if (!message || typeof message !== 'object') { + return undefined + } + const { + id, + run_id: runId, + from_handle: fromHandle + } = message as { + id?: unknown + run_id?: unknown + from_handle?: unknown + } + return typeof id === 'string' && typeof runId === 'string' + ? { + messageId: id, + runId, + ...(typeof fromHandle === 'string' ? { fromHandle } : {}) + } + : undefined +} + +function isExactWorkerReport( + result: string | null | undefined, + receipt: { messageId: string; fromHandle?: string }, + outcome: 'succeeded' | 'failed' +): boolean { + if (!result) { + return false + } + try { + const parsed = JSON.parse(result) as Record + return ( + parsed.provenance === 'worker_report' && + parsed.outcome === outcome && + (parsed.messageId === receipt.messageId || + (receipt.fromHandle !== undefined && parsed.reportedBy === receipt.fromHandle)) + ) + } catch { + return false + } +} + +function hasLifecycleVerdict(result: unknown): boolean { + if (!result || typeof result !== 'object' || !('lifecycle' in result)) { + return false + } + const lifecycle = (result as { lifecycle?: unknown }).lifecycle + if (!lifecycle || typeof lifecycle !== 'object') { + return false + } + const action = (lifecycle as { action?: unknown }).action + if ('relay' in result) { + return ( + (lifecycle as { authority?: unknown }).authority === 'run_home' && + (action === 'completed' || action === 'failed' || action === 'rejected') + ) + } + if (action === 'settled') { + const outcome = (lifecycle as { outcome?: unknown }).outcome + return outcome === 'succeeded' || outcome === 'failed' + } + return action === 'completed' || action === 'failed' || action === 'rejected' +} + +function parseWorkerDoneTarget(payload: string | undefined): + | { + taskId: string + dispatchId: string + outcome: 'succeeded' | 'failed' + expectedStatus: 'completed' | 'failed' + } + | undefined { + if (!payload) { + return undefined + } + try { + const parsed = JSON.parse(payload) as Record + if ( + typeof parsed.taskId !== 'string' || + typeof parsed.dispatchId !== 'string' || + (parsed.outcome !== 'succeeded' && parsed.outcome !== 'failed') + ) { + return undefined + } + return { + taskId: parsed.taskId, + dispatchId: parsed.dispatchId, + outcome: parsed.outcome, + expectedStatus: parsed.outcome === 'succeeded' ? 'completed' : 'failed' + } + } catch { + return undefined + } +} diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index ef7299efb43..c43f7b45b62 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -50,7 +50,7 @@ afterEach(() => { describe('orchestration send structured payload flags', () => { beforeEach(() => { - callMock.mockReset().mockResolvedValue({ result: { message: { id: 'msg_1' } } }) + callMock.mockReset().mockResolvedValue({ result: { lifecycle: { action: 'completed' } } }) getTerminalHandleMock.mockReset() delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_PANE_KEY @@ -94,6 +94,7 @@ describe('orchestration send structured payload flags', () => { filesModified: ['src/a.ts', 'src/b.ts'], reportPath: 'reports/done.md' }), + waitForLifecycleSettlement: true, devMode: false }) }) @@ -207,6 +208,7 @@ describe('orchestration send structured payload flags', () => { priority: undefined, threadId: undefined, payload: JSON.stringify({ outcome: 'succeeded' }), + waitForLifecycleSettlement: true, devMode: false }) }) @@ -233,6 +235,7 @@ describe('orchestration send structured payload flags', () => { priority: undefined, threadId: undefined, payload: JSON.stringify({ outcome: 'succeeded' }), + waitForLifecycleSettlement: true, devMode: false }) }) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 5be6e62394d..771b8367b88 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -8,6 +8,7 @@ import { getRequiredStringFlag } from '../flags' import { RuntimeClientError } from '../runtime-client' +import { requireWorkerDoneSettlement } from './orchestration-worker-settlement' import { getTerminalHandle } from '../selectors' import { clampOrchestrationAskTimeoutMs, @@ -90,14 +91,13 @@ const WORKER_TERMINAL_LIST_STATES = [ 'released' ] as const -type LifecycleSendRejection = { - action: 'rejected' - code: string - reason: string -} +type LifecycleSendResult = + | { action: 'completed' | 'failed'; authority?: 'run_home' } + | { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean } + | { action: 'rejected'; code: string; reason: string } type OrchestrationSendResult = - | { message: { id: string }; lifecycle?: LifecycleSendRejection } + | { message: { id: string; run_id?: string }; lifecycle?: LifecycleSendResult } | { messages: { id: string }[]; recipients: number } | { relay: { @@ -107,7 +107,7 @@ type OrchestrationSendResult = destination?: 'run_home' | 'worker' accepted: true } - lifecycle?: { action: 'completed' | 'failed' } + lifecycle?: LifecycleSendResult } function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { @@ -579,6 +579,7 @@ export const ORCHESTRATION_HANDLERS: Record = { payload: getOptionalStructuredMessagePayload(flags), // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. senderPaneKey: process.env.ORCA_PANE_KEY || undefined, + waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, devMode: isDevCliInvocation() } const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability') @@ -589,15 +590,12 @@ export const ORCHESTRATION_HANDLERS: Record = { sendParams, dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined ) - if ('message' in result.result && result.result.lifecycle?.action === 'rejected') { - // Why: a rejected lifecycle signal isn't completion; non-zero exit stops workers from treating it as such. - process.exitCode = 1 + await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result) + if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') { + throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason) } printResult(result, json, (r) => { if ('message' in r) { - if (r.lifecycle?.action === 'rejected') { - return `Rejected ${r.message.id}: ${r.lifecycle.reason}` - } return `Sent ${r.message.id}` } if ('relay' in r) { diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 3729b8598a7..aef583ad0ac 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -237,6 +237,14 @@ export async function getEmulatorWorktreeSelector( if (client.isRemote) { return undefined } + const terminalWorktreeId = process.env.ORCA_WORKTREE_ID + if (terminalWorktreeId?.trim()) { + return terminalWorktreeId + } + const folderWorkspaceId = process.env.ORCA_WORKSPACE_ID?.trim() + if (folderWorkspaceId?.startsWith('folder:')) { + return folderWorkspaceId + } try { return await resolveCurrentWorktreeSelector(cwd, client) } catch { diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index 12ae01f3fc7..6a85e45c41a 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -122,6 +122,42 @@ describe('AgentAwakeService', () => { expect(linuxAssertion.start).toHaveBeenCalledTimes(1) }) + it('stays awake in On mode without a working agent', () => { + const blocker = createBlocker() + const service = createService(() => 1_000, blocker) + + service.setMode('on') + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + expect(service.getStatus()).toEqual({ mode: 'on', active: true }) + }) + + it('publishes Auto activity changes to status subscribers', () => { + const service = createService(() => 1_000) + const listener = vi.fn() + service.subscribe(listener) + + service.setMode('auto') + service.setStatuses([workingStatus()]) + service.setStatuses([workingStatus(), workingStatus()]) + service.setStatuses([workingStatus()]) + service.setStatuses([]) + + expect(listener).toHaveBeenNthCalledWith(1, { + mode: 'auto', + active: false + }) + expect(listener).toHaveBeenNthCalledWith(2, { + mode: 'auto', + active: true + }) + expect(listener).toHaveBeenNthCalledWith(3, { + mode: 'auto', + active: false + }) + expect(listener).toHaveBeenCalledTimes(3) + }) + it('starts and stops from settings flips around an already-running status', () => { const blocker = createBlocker() const macosAssertion = createMacosAssertion() diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index a0cffcf27c4..adba93e7ea1 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -1,5 +1,10 @@ import { powerMonitor, powerSaveBlocker } from 'electron' import type { AgentStatusState } from '../shared/agent-status-types' +import { + normalizeComputerAwakeMode, + type ComputerAwakeMode, + type ComputerAwakeStatus +} from '../shared/computer-awake-mode' import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion' import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion' @@ -40,10 +45,12 @@ type AgentAwakeServiceOptions = { } export class AgentAwakeService { - private enabled = false + private mode: ComputerAwakeMode = 'off' private statuses: AgentAwakeStatus[] = [] private blockerId: number | null = null private staleTimer: ReturnType | null = null + private readonly statusListeners = new Set<(status: ComputerAwakeStatus) => void>() + private lastPublishedStatus: ComputerAwakeStatus | null = null private readonly blocker: PowerSaveBlocker private readonly linuxAssertion: PlatformAwakeAssertion private readonly logger: Logger @@ -82,10 +89,15 @@ export class AgentAwakeService { } setEnabled(enabled: boolean): void { - if (this.enabled === enabled) { + this.setMode(enabled ? 'auto' : 'off') + } + + setMode(mode: ComputerAwakeMode): void { + const normalized = normalizeComputerAwakeMode(mode) + if (this.mode === normalized) { return } - this.enabled = enabled + this.mode = normalized this.refresh('settings-change') } @@ -94,6 +106,19 @@ export class AgentAwakeService { this.refresh('status-change') } + getStatus(): ComputerAwakeStatus { + const workingAgentCount = this.getEligibleRunningStatusCount() + return { + mode: this.mode, + active: this.mode === 'on' || (this.mode === 'auto' && workingAgentCount > 0) + } + } + + subscribe(listener: (status: ComputerAwakeStatus) => void): () => void { + this.statusListeners.add(listener) + return () => this.statusListeners.delete(listener) + } + dispose(): void { this.clearStaleTimer() this.unsubscribeResume?.() @@ -105,7 +130,7 @@ export class AgentAwakeService { private refresh(reason: string): void { this.scheduleStaleTimer() const runningStatusCount = this.getEligibleRunningStatusCount() - const shouldBlock = this.enabled && runningStatusCount > 0 + const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0) if (shouldBlock) { this.startBlocker(reason, runningStatusCount) this.startMacosAssertion(reason) @@ -115,6 +140,21 @@ export class AgentAwakeService { this.stopMacosAssertion(reason) this.stopLinuxAssertion(reason) } + this.publishStatus(shouldBlock) + } + + private publishStatus(active: boolean): void { + const status = { mode: this.mode, active } + if ( + this.lastPublishedStatus?.mode === status.mode && + this.lastPublishedStatus.active === status.active + ) { + return + } + this.lastPublishedStatus = status + for (const listener of this.statusListeners) { + listener(status) + } } private getEligibleRunningStatusCount(): number { @@ -182,7 +222,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start blocker', { reason, - enabled: this.enabled, + mode: this.mode, runningStatusCount, error: err }) @@ -195,7 +235,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start macOS system sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -207,7 +247,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start Linux lid sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -219,7 +259,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop macOS system sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -231,7 +271,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop Linux lid sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -247,7 +287,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop blocker', { reason, - enabled: this.enabled, + mode: this.mode, runningStatusCount, blockerId: id, error: err diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index a85997d87de..fb7b3d85487 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -29,9 +29,12 @@ export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"' export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul` // Why (#11549): missing Orca context means the hook ran outside an Orca pane, where the caller -// may abandon stdin rather than close it — more.com then drains forever and strands a visible -// cmd.exe per hook event. Batch can exit instead because it streams to curl rather than -// capturing, so unlike the POSIX/PowerShell hooks it loses no payload by giving up stdin. +// may abandon stdin rather than close it — a read-to-EOF then blocks forever and strands a +// visible window per hook event. The Windows rule: a hook must check the Orca env before it +// owns stdin, and exit without reading when the env is missing — the payload is discarded on +// that path anyway. This applies to .cmd, the copilot .ps1, and the Git Bash kimi .sh alike. +// POSIX hooks keep capture-first: their callers close stdin, and exiting mid-write there +// surfaces as EPIPE the agent can see (#8110). export function buildWindowsHookEnvironmentGuardLines(): string[] { return [ 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index aa7e47d258a..b4518220e90 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -8,8 +8,6 @@ // We deliberately keep the JSON merge logic in the existing // `installer-utils.ts` and only swap fs primitives — the JSON shape and // managed-command matching must stay identical to the local install. -// -// See docs/design/agent-status-over-ssh.md §8 (commit #8). import { randomUUID } from 'node:crypto' import type { SFTPWrapper, FileEntryWithStats } from 'ssh2' @@ -83,9 +81,9 @@ export async function writeHooksJsonRemote( } } -/** Write the managed hook script to the remote and chmod 0o755. POSIX-only — - * the relay deliberately does not support Windows-remote in v1 (see design - * doc §3 + §6). */ +/** Write the managed hook script to the remote and chmod 0o755. POSIX-only: + * the payload is a shell script and every path here is POSIX-joined, so a + * Windows SSH target would need a separate installer, not a tweak to this one. */ export async function writeManagedScriptRemote( sftp: SFTPWrapper, remotePath: string, diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 75aabc6fc7e..2013865aa96 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -24,7 +24,6 @@ import { removeManagedCommands, wrapPosixHookCommand, wrapWindowsCmdHookCommand, - wrapWindowsGitBashHookCommand, readHooksJsonWithRaw, wrapWindowsHookCommand, writeManagedScript, @@ -32,6 +31,7 @@ import { type HooksConfig } from './installer-utils' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' +import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' let tmpDir: string let configPath: string @@ -320,6 +320,53 @@ describe('removeManagedCommands', () => { expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }]) }) + + it('removes exec-form hooks whose managed script path is an argument', () => { + const cleaned = removeManagedCommands( + [ + { + hooks: [ + { + type: 'command', + command: 'C:\\Windows\\System32\\conhost.exe', + args: [ + '--headless', + 'C:\\Windows\\System32\\cmd.exe', + '/d', + '/c', + 'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.cmd' + ] + }, + { type: 'command', command: 'echo keep me' } + ] + } + ], + match + ) + + expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }]) + }) + + it('preserves user hooks with malformed args fields', () => { + const definitions = [ + { + hooks: [ + { + type: 'command' as const, + command: 'echo keep me', + args: 'not-an-array' as unknown as string[] + }, + { + type: 'command' as const, + command: 'echo keep me too', + args: [42] as unknown as string[] + } + ] + } + ] + + expect(removeManagedCommands(definitions, match)).toEqual(definitions) + }) }) describe('hookDefinitionHasManagedCommand', () => { @@ -338,6 +385,34 @@ describe('hookDefinitionHasManagedCommand', () => { match ) ).toBe(true) + expect( + hookDefinitionHasManagedCommand( + { + hooks: [ + { + type: 'command', + command: 'C:\\Windows\\System32\\conhost.exe', + args: ['--headless', 'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.cmd'] + } + ] + }, + match + ) + ).toBe(true) + expect( + hookDefinitionHasManagedCommand( + { + hooks: [ + { + type: 'command', + command: 'echo no', + args: 'not-an-array' as unknown as string[] + } + ] + }, + match + ) + ).toBe(false) expect(hookDefinitionHasManagedCommand({ bash: 'echo no' }, match)).toBe(false) }) }) @@ -573,28 +648,87 @@ describe('wrapWindowsCmdHookCommand', () => { }) }) -describe('wrapWindowsGitBashHookCommand', () => { - it('guards the forward-slash fast path and drains when missing', () => { - expect( - wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd') - ).toBe( - `if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` +describe('wrapRuntimeHomeHookCommand', () => { + it('selects the runtime platform variant under HOME', () => { + const command = wrapRuntimeHomeHookCommand('claude-hook') + + expect(command).toContain('case "${OSTYPE-}" in msys*|cygwin*|win32*)') + expect(command).toContain('case "$HOME" in *\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*)') + expect(command).not.toContain('uname') + expect(command).toContain('"$HOME/.orca/agent-hooks/claude-hook.cmd"') + expect(command).toContain('/bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"') + expect(command).not.toMatch(/[A-Z]:[\\/]|\/Users\/|\/home\//) + }) + + it('rejects a script base name that could inject shell syntax', () => { + expect(() => wrapRuntimeHomeHookCommand('claude-hook; echo injected')).toThrow( + 'Invalid managed script base name' ) }) - it('falls back to the encoded launcher when bash would split the path', () => { - const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd' - const command = wrapWindowsGitBashHookCommand(scriptPath) - expect(command).toMatch(qualifiedWindowsPowerShellCommand) - expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath)) + it('executes the destination HOME script for the current runtime', () => { + const sourceHome = join(tmpDir, 'source profile') + const destinationHome = join(tmpDir, "destination $HOME ' & profile") + const sourceScriptDir = join(sourceHome, '.orca', 'agent-hooks') + const destinationScriptDir = join(destinationHome, '.orca', 'agent-hooks') + mkdirSync(sourceScriptDir, { recursive: true }) + mkdirSync(destinationScriptDir, { recursive: true }) + const windowsExitCode = process.platform === 'win32' ? 7 : 9 + const posixExitCode = process.platform === 'win32' ? 9 : 7 + writeFileSync( + join(destinationScriptDir, 'claude-hook.cmd'), + `@echo off\r\nexit /b ${windowsExitCode}\r\n`, + 'utf-8' + ) + writeFileSync( + join(destinationScriptDir, 'claude-hook.sh'), + `#!/bin/sh\nexit ${posixExitCode}\n`, + 'utf-8' + ) + writeFileSync(join(sourceScriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 9\r\n', 'utf-8') + writeFileSync(join(sourceScriptDir, 'claude-hook.sh'), '#!/bin/sh\nexit 9\n', 'utf-8') + chmodSync(join(destinationScriptDir, 'claude-hook.sh'), 0o755) + + const shell = + process.platform === 'win32' + ? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + : '/bin/sh' + const result = spawnSync(shell, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], { + env: { + ...process.env, + HOME: destinationHome.replaceAll('\\', '/'), + USERPROFILE: destinationHome + } + }) + + expect(result.error).toBeUndefined() + expect(result.status, result.stderr.toString()).toBe(7) }) - it('falls back to the encoded launcher when bash metacharacters are present', () => { - const scriptPath = 'C:\\Users\\alice & bob\\.orca\\agent-hooks\\claude-hook.cmd' - const command = wrapWindowsGitBashHookCommand(scriptPath) - expect(command).toMatch(qualifiedWindowsPowerShellCommand) - expect(command).not.toContain('& bob') - expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath)) + it.skipIf(process.platform !== 'win32')('keeps common Windows profiles on the fast path', () => { + const destinationHome = join(tmpDir, 'destination 国際 profile') + const scriptDir = join(destinationHome, '.orca', 'agent-hooks') + mkdirSync(scriptDir, { recursive: true }) + writeFileSync(join(scriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 7\r\n', 'utf-8') + const gitBash = join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + const result = spawnSync(gitBash, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], { + env: { ...process.env, HOME: destinationHome.replaceAll('\\', '/') } + }) + + expect(result.error).toBeUndefined() + expect(result.status, result.stderr.toString()).toBe(7) + }) + + it('drains stdin when HOME is unavailable', () => { + const command = `unset HOME; ${wrapRuntimeHomeHookCommand('claude-hook')}` + const shell = + process.platform === 'win32' + ? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + : '/bin/sh' + const result = spawnSync(shell, ['-c', command], { input: Buffer.alloc(1_000_000, 'x') }) + + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) }) }) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index a6d5d61fd41..f6e4c131826 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -20,6 +20,7 @@ import { writeRollingFileBackup } from '../rolling-file-backup' export type HookCommandConfig = { type: 'command' command: string + args?: string[] timeout?: number async?: boolean statusMessage?: string @@ -151,16 +152,6 @@ export function wrapWindowsCmdHookCommand(scriptPath: string): string { return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath) } -export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/ - -export function wrapWindowsGitBashHookCommand(scriptPath: string): string { - const bashPath = scriptPath.replaceAll('\\', '/') - // Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; unsafe paths stay encoded. - return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath) - ? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` - : wrapWindowsHookCommand(scriptPath) -} - /** * Extra form lines inserted before the final `payload@-` line (each should end with ` ^`). * Used by Grok to attach `grokHome` without fragile string replace on the shared template. @@ -215,7 +206,8 @@ export function removeManagedCommands( const directManagedKeys = directCommandKeys.filter((key) => isManagedCommand(definition[key])) const hasNestedHooks = Array.isArray(definition.hooks) const hasManagedNestedHook = - hasNestedHooks && definition.hooks!.some((hook) => isManagedCommand(hook.command)) + hasNestedHooks && + definition.hooks!.some((hook) => hookHasManagedCommand(hook, isManagedCommand)) if (directManagedKeys.length === 0 && !hasManagedNestedHook) { return [definition] @@ -227,7 +219,9 @@ export function removeManagedCommands( } if (hasManagedNestedHook) { - const filteredHooks = definition.hooks!.filter((hook) => !isManagedCommand(hook.command)) + const filteredHooks = definition.hooks!.filter( + (hook) => !hookHasManagedCommand(hook, isManagedCommand) + ) if (filteredHooks.length > 0) { nextDefinition.hooks = filteredHooks } else { @@ -246,6 +240,11 @@ export function removeManagedCommands( }) } +function hookHasManagedCommand(hook: HookCommandConfig, matches: (value?: string) => boolean) { + const args = Array.isArray(hook.args) ? hook.args : [] + return matches(hook.command) || args.some((arg) => typeof arg === 'string' && matches(arg)) +} + export function hookDefinitionHasManagedCommand( definition: HookDefinition, isManagedCommand: (command: string | undefined) => boolean @@ -255,7 +254,7 @@ export function hookDefinitionHasManagedCommand( isManagedCommand(definition.bash) || isManagedCommand(definition.powershell) || (Array.isArray(definition.hooks) && - definition.hooks.some((hook) => isManagedCommand(hook.command))) + definition.hooks.some((hook) => hookHasManagedCommand(hook, isManagedCommand))) ) } diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index f87fd8a06dd..60004f94df0 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({ removeClaude: vi.fn(), removeCodex: vi.fn(), statusClaude: vi.fn(), - statusCodex: vi.fn() + statusCodex: vi.fn(), + refreshClaude: vi.fn(), + refreshCodex: vi.fn() })) vi.mock('./local-agent-cli-presence', () => ({ @@ -26,6 +28,10 @@ vi.mock('./managed-agent-hook-registry', () => ({ MANAGED_AGENT_HOOK_STATUS_READERS: [ ['claude', mocks.statusClaude], ['codex', mocks.statusCodex] + ], + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: [ + ['claude', mocks.refreshClaude], + ['codex', mocks.refreshCodex] ] })) @@ -51,6 +57,8 @@ describe('managed agent hook controls', () => { mocks.installCodex.mockReturnValue(status('codex', 'installed')) mocks.removeClaude.mockReturnValue(status('claude', 'not_installed')) mocks.removeCodex.mockReturnValue(status('codex', 'not_installed')) + mocks.refreshClaude.mockResolvedValue(undefined) + mocks.refreshCodex.mockResolvedValue(undefined) }) it('installs only agents with positively detected CLIs', async () => { @@ -73,6 +81,78 @@ describe('managed agent hook controls', () => { ]) }) + it('refreshes existing scripts for agents whose CLI is no longer detected', async () => { + mocks.detect.mockResolvedValue({ + claude: { state: 'missing' }, + codex: { state: 'found' } + }) + + await installManagedAgentHooks({ agentCmdOverrides: {} }) + + // Why (#11549 aftermath): the skipped agent's user-wide config still invokes the + // script, so a stale (leaking) script must not be frozen by the presence gate. + expect(mocks.refreshClaude).toHaveBeenCalledTimes(1) + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + expect(mocks.installClaude).not.toHaveBeenCalled() + }) + + it('refreshes existing scripts even when CLI detection rejects', async () => { + mocks.detect.mockRejectedValue(new Error('detection unavailable')) + + await installManagedAgentHooks({ agentCmdOverrides: {} }) + + expect(mocks.refreshClaude).toHaveBeenCalledTimes(1) + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + }) + + it('awaits each script refresh before probing for CLIs', async () => { + let releaseRefresh: (() => void) | undefined + mocks.refreshClaude.mockImplementation( + () => + new Promise((resolve) => { + releaseRefresh = resolve + }) + ) + mocks.detect.mockResolvedValue({ + claude: { state: 'missing' }, + codex: { state: 'missing' } + }) + + const install = installManagedAgentHooks({ agentCmdOverrides: {} }) + await Promise.resolve() + + expect(mocks.detect).not.toHaveBeenCalled() + releaseRefresh?.() + await install + expect(mocks.detect).toHaveBeenCalledTimes(1) + }) + + it('keeps installing when a script refresh throws', async () => { + mocks.refreshClaude.mockRejectedValue(new Error('disk full')) + mocks.detect.mockResolvedValue({ + claude: { state: 'found' }, + codex: { state: 'found' } + }) + + const results = await installManagedAgentHooks({ agentCmdOverrides: {} }) + + expect(mocks.installClaude).toHaveBeenCalledTimes(1) + expect(mocks.installCodex).toHaveBeenCalledTimes(1) + expect(results).toEqual([ + expect.objectContaining({ agent: 'claude', state: 'installed' }), + expect.objectContaining({ agent: 'codex', state: 'installed' }) + ]) + }) + + it('only refreshes scripts for the selected agents', async () => { + mocks.detect.mockResolvedValue({ codex: { state: 'found' } }) + + await installManagedAgentHooks({ agentCmdOverrides: {} }, { agents: ['codex'] }) + + expect(mocks.refreshClaude).not.toHaveBeenCalled() + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + }) + it('fails closed when CLI detection rejects', async () => { mocks.detect.mockRejectedValue(new Error('detection unavailable')) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 0e767ee72b0..0de10c6417c 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -9,6 +9,7 @@ import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence' import { MANAGED_AGENT_HOOK_INSTALLERS, MANAGED_AGENT_HOOK_REMOVERS, + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS, MANAGED_AGENT_HOOK_STATUS_READERS, type ManagedAgentHookInstaller } from './managed-agent-hook-registry' @@ -87,10 +88,29 @@ function runInstaller( } } +// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking +// Orca's script, but the presence gate below then skips install() forever, freezing the +// script at whatever Orca generated last. Existing scripts are Orca-owned, so bring them +// current before any gating; creating new ones remains install()'s presence-gated job. +async function refreshExistingManagedScripts(options: InstallOptions): Promise { + const allowed = options.agents ? new Set(options.agents) : null + for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + if (allowed !== null && !allowed.has(agent)) { + continue + } + try { + await refresh() + } catch (error) { + console.error(`[agent-hooks] Failed to refresh ${agent} managed script:`, error) + } + } +} + export async function installManagedAgentHooks( settings: ManagedHookSettings = null, options: InstallOptions = {} ): Promise { + await refreshExistingManagedScripts(options) const installers = selectedInstallers(options) const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents)) const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent)) diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 1c17da381ce..4772a763b6c 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -16,6 +16,7 @@ import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus] +export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise] export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus] @@ -36,6 +37,27 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['kimi', () => kimiHookService.install()] ] +// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a +// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write +// provider-native plugin code into their own config dirs with their own install lifecycles, +// not shared launchers, so they are deliberately absent. Enforced by the coverage test in +// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding +// a refresher here fails that test. +export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [ + ['claude', () => claudeHookService.refreshManagedScripts()], + ['openclaude', () => openClaudeHookService.refreshManagedScripts()], + ['codex', () => codexHookService.refreshManagedScripts()], + ['gemini', () => geminiHookService.refreshManagedScripts()], + ['antigravity', () => antigravityHookService.refreshManagedScripts()], + ['cursor', () => cursorHookService.refreshManagedScripts()], + ['droid', () => droidHookService.refreshManagedScripts()], + ['command-code', () => commandCodeHookService.refreshManagedScripts()], + ['grok', () => grokHookService.refreshManagedScripts()], + ['copilot', () => copilotHookService.refreshManagedScripts()], + ['devin', () => devinHookService.refreshManagedScripts()], + ['kimi', () => kimiHookService.refreshManagedScripts()] +] + export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['claude', () => claudeHookService.remove()], ['openclaude', () => openClaudeHookService.remove()], diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index 7f60956e6e4..51f299595cd 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -32,7 +32,7 @@ function hasCode(error: unknown, code: string): boolean { export function parseLinuxStartTicks(statLine: string): string | null { const commandEnd = statLine.lastIndexOf(')') - if (commandEnd < 0) { + if (commandEnd === -1) { return null } // Field 22 is index 19 after removing pid and the parenthesized command. diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts index 456a741df87..5422de4e692 100644 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ b/src/main/agent-hooks/managed-hook-runtime.test.ts @@ -1,5 +1,38 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { resolveRelayGrokHome } from './managed-hook-runtime' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeChildProcess from 'node:child_process' + +// Why: the probe spawns a real login shell, and `resolveRelayGrokHome` swallows every +// spawn failure into its fallback. Left unmocked this asserts the runner's scheduling +// latency, not the parser: on a loaded sharded CI box the 8s timeout expires and the +// first case silently flips to the fallback path. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, execFile: vi.fn() } +}) + +const { execFile } = await import('node:child_process') +const execFileMock = vi.mocked(execFile) +const { resolveRelayGrokHome } = await import('./managed-hook-runtime') + +type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void + +function stubProbeOutput(stdout: string): void { + execFileMock.mockImplementation(((...args: unknown[]) => { + ;(args.at(-1) as ExecFileCallback)(null, { stdout, stderr: '' }) + return undefined + }) as unknown as typeof execFile) +} + +function stubProbeFailure(error: Error): void { + execFileMock.mockImplementation(((...args: unknown[]) => { + ;(args.at(-1) as ExecFileCallback)(error) + return undefined + }) as unknown as typeof execFile) +} + +beforeEach(() => { + execFileMock.mockReset() +}) afterEach(() => { vi.unstubAllEnvs() @@ -8,14 +41,39 @@ afterEach(() => { describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => { it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => { vi.stubEnv('SHELL', '/bin/sh') - vi.stubEnv('GROK_HOME', '/srv/grok///') + stubProbeOutput('/srv/grok///\n') await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const [shell, args] = execFileMock.mock.calls[0] ?? [] + expect(shell).toBe('/bin/sh') + // `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test. + expect(args?.[0]).toBe('-c') + }) + + it('passes -lc to a login shell that supports it', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput('/srv/grok\n') + + await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const [shell, args] = execFileMock.mock.calls[0] ?? [] + expect(shell).toBe('/bin/zsh') + expect(args?.[0]).toBe('-lc') }) it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => { vi.stubEnv('SHELL', '/bin/sh') - vi.stubEnv('GROK_HOME', '../relative') + stubProbeOutput('../relative\n') + + await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') + }) + + // Why: this is the branch that made the old test flaky — pin it so a probe failure is + // an asserted fallback rather than an invisible substitution for a real answer. + it('falls back when the probe fails or times out', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true })) await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') }) diff --git a/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts new file mode 100644 index 00000000000..e80011d803f --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts @@ -0,0 +1,101 @@ +import type * as NodeFsModule from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import { tmpdir } from 'node:os' +import type * as NodeOsModule from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + home: '', + syncCalls: [] as { name: string; target: string }[], + blockAsyncReads: false +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const wrapped: Record = { ...actual } + for (const [name, value] of Object.entries(actual)) { + if (!name.endsWith('Sync') || typeof value !== 'function') { + continue + } + const original = value as ((...args: unknown[]) => unknown) & Record + const recorder = (...args: unknown[]): unknown => { + state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' }) + return original(...args) + } + Object.assign(recorder, original) + wrapped[name] = recorder + } + return { ...wrapped, default: wrapped } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: actual, + readFile: (...args: Parameters) => + state.blockAsyncReads && String(args[0]).startsWith(state.home) + ? new Promise(() => {}) + : actual.readFile(...args) + } +}) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, default: actual, homedir: () => state.home } +}) + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } })) + +import { MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS } from './managed-agent-hook-registry' + +function syncCallsUnderHome(): string[] { + return state.syncCalls + .filter((call) => call.target.startsWith(state.home)) + .map((call) => `${call.name}(${call.target})`) +} + +describe('managed hook script refresh stays off the main thread', () => { + beforeEach(async () => { + state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-')) + state.syncCalls = [] + state.blockAsyncReads = false + }) + + afterEach(async () => { + state.blockAsyncReads = false + await rm(state.home, { recursive: true, force: true }) + }) + + it('uses no synchronous HOME filesystem calls for missing or stale scripts', async () => { + const hooksDir = join(state.home, '.orca', 'agent-hooks') + const claudeScript = join( + hooksDir, + process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' + ) + await mkdir(hooksDir, { recursive: true }) + await writeFile(claudeScript, 'stale', 'utf-8') + state.syncCalls = [] + + for (const [, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + await refresh() + } + + expect(syncCallsUnderHome()).toEqual([]) + }) + + it('keeps the event loop responsive while a HOME read is stalled', async () => { + state.blockAsyncReads = true + const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]() + let settled = false + void refresh.then(() => { + settled = true + }) + + await new Promise((resolve) => setTimeout(resolve, 5)) + + expect(settled).toBe(false) + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts new file mode 100644 index 00000000000..799f8acf58c --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -0,0 +1,206 @@ +// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking +// Orca's script while the presence gate skips install() forever. These tests pin the +// repair — existing scripts come current, missing ones are never created. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as osModule from 'node:os' + +let isolatedUserDataDir = '' +let previousUserDataPath: string | undefined + +beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-user-data-')) + process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir +}) + +afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(isolatedUserDataDir, { recursive: true, force: true }) +}) + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-user-data' + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock.mockImplementation(actual.homedir) + } +}) + +import { refreshManagedScriptIfPresent } from './managed-hook-script-refresh' +import { + MANAGED_AGENT_HOOK_INSTALLERS, + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS +} from './managed-agent-hook-registry' +import { ClaudeHookService } from '../claude/hook-service' + +async function withPlatform(platform: NodeJS.Platform, run: () => T | Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await run() + } finally { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + +const STALE_WINDOWS_HOOK = [ + '@echo off', + 'setlocal', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin', + ':orca_agent_hook_drain_stdin', + '"%SystemRoot%\\System32\\more.com" >nul 2>nul', + 'exit /b 0', + '' +].join('\r\n') + +describe('refreshManagedScriptIfPresent', () => { + it('rewrites an existing script and refuses to create a missing one', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-unit-')) + try { + const present = join(dir, 'present.cmd') + writeFileSync(present, 'stale') + expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true) + expect(readFileSync(present, 'utf8')).toBe('fresh') + + if (process.platform !== 'win32') { + chmodSync(present, 0o600) + } + const fixedTime = new Date(1_000) + utimesSync(present, fixedTime, fixedTime) + expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true) + expect(statSync(present).mtimeMs).toBe(fixedTime.getTime()) + if (process.platform !== 'win32') { + expect(statSync(present).mode & 0o777).toBe(0o755) + } + + const missing = join(dir, 'missing.cmd') + expect(await refreshManagedScriptIfPresent(missing, 'fresh')).toBe(false) + expect(existsSync(missing)).toBe(false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('managed hook script refresh', () => { + it('brings a stale leaking script current without touching agent config', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-')) + homedirMock.mockReturnValue(home) + try { + // Why: the bug population has a script (from a past install) but no reachable + // CLI — and possibly no config dir Orca may create. Seed only the script. + const hooksDir = join(home, '.orca', 'agent-hooks') + mkdirSync(hooksDir, { recursive: true }) + writeFileSync(join(hooksDir, 'claude-hook.cmd'), STALE_WINDOWS_HOOK) + + await withPlatform('win32', () => new ClaudeHookService().refreshManagedScripts()) + + const refreshed = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8') + expect(refreshed).toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0') + expect(refreshed).not.toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" goto') + // Why: refresh must not resurrect config for a CLI the user may have removed. + expect(existsSync(join(home, '.claude'))).toBe(false) + // Why: the statusline script was never installed here, so it must not appear. + expect(existsSync(join(hooksDir, 'claude-statusline.cmd'))).toBe(false) + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }) + + it('covers every shared launcher script with a refresher', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-coverage-')) + homedirMock.mockReturnValue(home) + const previousGrokHome = process.env.GROK_HOME + const previousKimiHome = process.env.KIMI_CODE_HOME + delete process.env.GROK_HOME + delete process.env.KIMI_CODE_HOME + try { + await withPlatform('win32', () => { + for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) { + install() + } + }) + const hooksDir = join(home, '.orca', 'agent-hooks') + const files = readdirSync(hooksDir) + expect(files.length).toBeGreaterThan(0) + const refresherAgents = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.map(([agent]) => agent) + // Why: an installer that writes a shared launcher but skips the refresher list would + // recreate the frozen-stale-script class this suite exists to prevent. + for (const file of files) { + expect( + refresherAgents.some((agent) => file.startsWith(`${agent}-`)), + `${file} is written to ~/.orca/agent-hooks but no refresher owns it` + ).toBe(true) + } + // Why: the reverse direction — a refresher naming an agent that writes nothing is a + // stale registry entry, likely a renamed script file. + for (const agent of refresherAgents) { + expect( + files.some((file) => file.startsWith(`${agent}-`)), + `refresher for ${agent} matches no installed script` + ).toBe(true) + } + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + if (previousGrokHome === undefined) { + delete process.env.GROK_HOME + } else { + process.env.GROK_HOME = previousGrokHome + } + if (previousKimiHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = previousKimiHome + } + rmSync(home, { recursive: true, force: true }) + } + }) + + it('creates nothing anywhere when no managed scripts exist', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-empty-')) + homedirMock.mockReturnValue(home) + try { + await withPlatform('win32', async () => { + for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + await expect(refresh(), `${agent} refresh on empty home`).resolves.toBeUndefined() + } + }) + // Why: a refresh pass on a machine with no prior installs must be a strict no-op. + expect(readdirSync(home)).toEqual([]) + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.ts b/src/main/agent-hooks/managed-hook-script-refresh.ts new file mode 100644 index 00000000000..5842a9dfbfe --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto' +import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { grantDirAclAsync, isPermissionError } from '../win32-utils' + +type ExistingScript = { exists: false } | { exists: true; content: string | null } + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingScript(scriptPath: string): Promise { + try { + return { exists: true, content: await readFile(scriptPath, 'utf-8') } + } catch (error) { + if (isMissingPathError(error)) { + return { exists: false } + } + try { + await stat(scriptPath) + return { exists: true, content: null } + } catch (statError) { + if (isMissingPathError(statError)) { + return { exists: false } + } + throw error + } + } +} + +async function scriptStillExists(scriptPath: string): Promise { + try { + await stat(scriptPath) + return true + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } +} + +async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise { + try { + await writeFile(scriptPath, content, 'utf-8') + } catch (error) { + if (isPermissionError(error) && process.platform === 'win32') { + try { + await grantDirAclAsync(dirname(scriptPath)) + await writeFile(scriptPath, content, 'utf-8') + return + } catch { + // Re-throw the original permission error. + } + } + throw error + } +} + +// Why: refresh must not block Electron's main thread or create state for an absent CLI. +export async function refreshManagedScriptIfPresent( + scriptPath: string, + content: string +): Promise { + const existing = await readExistingScript(scriptPath) + if (!existing.exists) { + return false + } + if (existing.content === content) { + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755) + } + return true + } + + const tmpPath = join(dirname(scriptPath), `.${Date.now()}-${randomUUID()}.tmp`) + try { + await writeScriptWithAclRetry(tmpPath, content) + if (process.platform !== 'win32') { + await chmod(tmpPath, 0o755) + } + if (!(await scriptStillExists(scriptPath))) { + return false + } + await rename(tmpPath, scriptPath) + return true + } finally { + await rm(tmpPath, { force: true }).catch(() => undefined) + } +} diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 2ccfe4e54dd..f805e6c25f1 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -79,12 +79,9 @@ import { GeminiHookService } from '../gemini/hook-service' import { GrokHookService } from '../grok/hook-service' import { KimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' -import { - wrapPosixHookCommand, - wrapWindowsGitBashHookCommand, - wrapWindowsHookCommand -} from './installer-utils' +import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils' import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract' +import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture' const REMOTE_HOME = '/home/dev' @@ -296,13 +293,17 @@ describe('Windows managed hook stdin structure', () => { claude.indexOf('if not "%DEVIN_PROJECT_DIR%"=="" goto :orca_agent_hook_drain_stdin') ) + // Why (#11549 class): every Windows-local hook now guards before owning stdin — + // the caller may abandon the pipe, and the payload is discarded on this path anyway. const copilot = readFileSync(join(hooksDir, 'copilot-hook.ps1'), 'utf8') - expect(copilot.indexOf('[Console]::In.ReadToEnd()')).toBeLessThan( - copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT') + expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeGreaterThan(-1) + expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeLessThan( + copilot.indexOf('[Console]::In.ReadToEnd()') ) const kimi = readFileSync(join(hooksDir, 'kimi-hook.sh'), 'utf8') - expect(kimi.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`)).toBeLessThan( - kimi.indexOf('exit 0') + expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeGreaterThan(-1) + expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeLessThan( + kimi.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`) ) } finally { homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) @@ -321,7 +322,7 @@ describe('Windows managed hook stdin structure', () => { }) it.skipIf(process.platform !== 'win32')( - 'exits 0 for every local script and missing-script launcher, and only .cmd drops stdin', + 'exits 0 for every local script and missing-script launcher, dropping stdin only without Orca env', async () => { const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-live-')) homedirMock.mockReturnValue(home) @@ -359,16 +360,13 @@ describe('Windows managed hook stdin structure', () => { : [scriptPath] const result = await runHookProcess(executable, args, hookEnvironment()) expect(result.exitCode, `${fileName} exit code`).toBe(0) - if (fileName.endsWith('.cmd')) { - // Why (#11549): these guards exit rather than own stdin, so the writer breaks — - // EPIPE, or ECONNRESET when Windows tears the pipe down first. hookEnvironment() - // strips every ORCA_* var, so the relaxation only ever covers the missing-env - // path — a happy-path case added to this loop must not reuse it. - for (const error of result.stdinErrors) { - expect(['EPIPE', 'ECONNRESET'], `${fileName} stdin error`).toContain(error.code) - } - } else { - expect(result.stdinErrors, `${fileName} stdin errors`).toHaveLength(0) + // Why (#11549 class): every Windows-local hook exits before owning stdin when the + // Orca env is missing, so the writer may break — EPIPE, or ECONNRESET when Windows + // tears the pipe down first. hookEnvironment() strips every ORCA_* var, so this + // relaxation only ever covers the missing-env path — a happy-path case added to + // this loop must not reuse it. + for (const error of result.stdinErrors) { + expect(['EPIPE', 'ECONNRESET'], `${fileName} stdin error`).toContain(error.code) } } @@ -387,9 +385,9 @@ describe('Windows managed hook stdin structure', () => { args: ['/d', '/c', wrapWindowsHookCommand(missingScript)] }, { - name: 'Git Bash fast path', + name: 'portable Git Bash launcher', executable: gitBash, - args: ['-lc', wrapWindowsGitBashHookCommand(missingScript)] + args: ['-lc', wrapRuntimeHomeHookCommand('missing-orca-hook')] } ] for (const launcher of launcherCases) { diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts new file mode 100644 index 00000000000..3ae887fc054 --- /dev/null +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -0,0 +1,22 @@ +import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' + +const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/ +const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*' + +export function wrapRuntimeHomeHookCommand(scriptBaseName: string): string { + if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { + throw new Error(`Invalid managed script base name: ${scriptBaseName}`) + } + const windowsScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.cmd"` + const posixScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.sh"` + const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND + const powershell = '"$SYSTEMROOT/System32/WindowsPowerShell/v1.0/powershell.exe"' + const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + const encodedCommand = Buffer.from(powershellCommand, 'utf16le').toString('base64') + const powershellInvocation = `${powershell} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}` + const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${drain}; fi` + const windowsBranch = `if [ -f ${windowsScript} ]; then case "$HOME" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${drain}; fi` + const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${drain}; fi` + // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. + return `if [ -z "\${HOME-}" ]; then ${drain}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` +} diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e5ef70e1395..4dc64d523de 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */ -// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron. See docs/design/agent-status-over-ssh.md §5. +// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron; parsing that drifts back into this file stops applying to SSH panes. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { createHash, randomBytes, randomUUID } from 'node:crypto' import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index ad7e81893b3..960bbc5576c 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,8 +1,8 @@ import { join } from 'node:path' import { - resetAiVaultScannerWorkerForTests, - scanAiVaultSessionsInWorker -} from './session-scanner-worker-spawn' + resetAiVaultScannerBackgroundForTests, + scanAiVaultSessionsInBackground +} from './session-scanner-background' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -77,7 +77,7 @@ export async function listAiVaultSessions( start: async (scanSignal) => { const additionalCodexSessionsDirs = sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [] - const result = await scanAiVaultSessionsInWorker( + const result = await scanAiVaultSessionsInBackground( { limit: args?.limit, unlimited: args?.unlimited, @@ -142,5 +142,5 @@ export function resetAiVaultSessionListCacheForTests(): void { invalidateAiVaultSessionListCache() scanCoordinator = new AiVaultScanCoordinator() sources = {} - resetAiVaultScannerWorkerForTests() + resetAiVaultScannerBackgroundForTests() } diff --git a/src/main/ai-vault/session-delete-liveness.repro.test.ts b/src/main/ai-vault/session-delete-liveness.repro.test.ts deleted file mode 100644 index 6e4928a84ea..00000000000 --- a/src/main/ai-vault/session-delete-liveness.repro.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { existsSync } from 'node:fs' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import type { AiVaultDeleteSessionResult } from '../../shared/ai-vault-session-deletion' -import type { ValidateAiVaultSessionDeleteTargetArgs } from './session-delete-target' - -const { trashItemMock } = vi.hoisted(() => ({ trashItemMock: vi.fn() })) - -vi.mock('electron', () => ({ shell: { trashItem: trashItemMock } })) -vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: vi.fn().mockResolvedValue(false) })) - -import { deleteAiVaultSessionFile } from './session-delete' -import { resolveAiVaultSessionLiveness } from './session-liveness' - -type AiVaultSessionLiveness = 'live' | 'not-live' | 'unknown' - -type DeleteWithLiveness = ( - args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId: string }, - deps: { getSessionLiveness: () => Promise } -) => Promise - -const deleteWithLiveness = deleteAiVaultSessionFile as unknown as DeleteWithLiveness -const fixtureRoots: string[] = [] - -async function createTranscript(): Promise<{ filePath: string; root: string }> { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-live-delete-repro-')) - fixtureRoots.push(root) - const filePath = join(root, 'session-live.json') - await writeFile(filePath, '{"sessionId":"session-live"}\n') - return { filePath, root } -} - -async function attemptDelete( - getSessionLiveness: () => Promise -): Promise<{ filePath: string; result: AiVaultDeleteSessionResult }> { - const { filePath, root } = await createTranscript() - const result = await deleteWithLiveness( - { - agent: 'gemini', - sessionId: 'session-live', - filePath, - executionHostId: 'local', - rootOptions: { geminiSessionsDir: root } - }, - { getSessionLiveness } - ) - return { filePath, result } -} - -describe('live AI Vault session delete safety invariant', () => { - afterEach(async () => { - trashItemMock.mockReset() - await Promise.all( - fixtureRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) - ) - }) - - it('survives when the session becomes live after confirmation but before main authorization', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const rendererPaneSnapshot = new Set() - expect(rendererPaneSnapshot.has('session-live')).toBe(false) - - let authoritativeLiveness: AiVaultSessionLiveness = 'not-live' - const confirmationOpened = true - authoritativeLiveness = 'live' - expect(confirmationOpened).toBe(true) - - const { filePath, result } = await attemptDelete(async () => authoritativeLiveness) - - expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' }) - expect.soft(existsSync(filePath), 'live transcript must survive on disk').toBe(true) - }) - - it('survives when a paired owner is absent from the renderer pane snapshot', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const rendererPaneSnapshot = new Set() - const authoritativeOwner = { - runtimeId: 'host-runtime', - connectionId: 'paired-client', - generation: 'generation-1', - sessionId: 'session-live', - liveness: 'live' as const - } - expect(rendererPaneSnapshot.has(authoritativeOwner.sessionId)).toBe(false) - - const { filePath, result } = await attemptDelete(async () => authoritativeOwner.liveness) - - expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' }) - expect.soft(existsSync(filePath), 'externally owned transcript must survive on disk').toBe(true) - }) - - it('survives an exact live local hook identity absent from managed PTY inventory', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const inspectForegroundProcess = vi.fn() - const externalLiveStatus: AgentStatusIpcPayload = { - paneKey: 'tab:external-live', - terminalHandle: 'term_external-live', - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - state: 'working', - prompt: 'test', - agentType: 'gemini', - providerSession: { key: 'session_id', id: 'session-live' } - } - - const { filePath, result } = await attemptDelete(() => - resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - { - listProcesses: async () => [], - getStatusSnapshot: () => [externalLiveStatus], - inspectForegroundProcess, - getStatusPtyId: () => 'external-live', - getAgentHint: () => null - } - ) - ) - - expect.soft(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'session-liveness-unknown' - }) - expect.soft(existsSync(filePath), 'external live transcript must survive on disk').toBe(true) - expect(inspectForegroundProcess).not.toHaveBeenCalled() - }) - - it('survives a dismissed live row retained as liveness-only identity', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const retainedIdentity: AgentStatusIpcPayload = { - paneKey: 'tab:external-live', - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - state: 'working', - prompt: 'test', - agentType: 'gemini', - providerSession: { key: 'session_id', id: 'session-live' }, - providerSessionOnly: true - } - - const { filePath, result } = await attemptDelete(() => - resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - { - listProcesses: async () => [], - getStatusSnapshot: () => [retainedIdentity], - inspectForegroundProcess: vi.fn(), - getStatusPtyId: () => null, - getAgentHint: () => null - } - ) - ) - - expect.soft(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'session-liveness-unknown' - }) - expect.soft(existsSync(filePath), 'dismissed live transcript must survive on disk').toBe(true) - }) - - it('fails closed when authoritative liveness is unavailable', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - - const { filePath, result } = await attemptDelete(async () => 'unknown') - - expect(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'session-liveness-unknown' - }) - expect(existsSync(filePath), 'unknown-liveness transcript must survive on disk').toBe(true) - }) -}) diff --git a/src/main/ai-vault/session-delete.test.ts b/src/main/ai-vault/session-delete.test.ts index 3c6f86f43a7..cacb0dbbda5 100644 --- a/src/main/ai-vault/session-delete.test.ts +++ b/src/main/ai-vault/session-delete.test.ts @@ -33,21 +33,13 @@ vi.mock('../wsl-unc-delete', () => ({ WslDeleteValidationError: WslDeleteValidationErrorMock })) -import { deleteAiVaultSessionFile as deleteAiVaultSessionFileWithLiveness } from './session-delete' +import { deleteAiVaultSessionFile } from './session-delete' const HOME = join('/tmp', 'orca-ai-vault-delete-exec-fixture-home') const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') const CLAUDE_ROOT = join(HOME, '.claude', 'projects') const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') -function deleteAiVaultSessionFile( - args: Parameters[0] -) { - return deleteAiVaultSessionFileWithLiveness(args, { - getSessionLiveness: async () => 'not-live' - }) -} - function enoent(): NodeJS.ErrnoException { const error = new Error('not found') as NodeJS.ErrnoException error.code = 'ENOENT' diff --git a/src/main/ai-vault/session-delete.ts b/src/main/ai-vault/session-delete.ts index fb6876904ae..4676749068b 100644 --- a/src/main/ai-vault/session-delete.ts +++ b/src/main/ai-vault/session-delete.ts @@ -2,10 +2,8 @@ import { lstat, realpath } from 'node:fs/promises' import { shell } from 'electron' import type { AiVaultDeleteSessionResult, - AiVaultSessionDeleteRemoval, - AiVaultSessionLiveness + AiVaultSessionDeleteRemoval } from '../../shared/ai-vault-session-deletion' -import type { AiVaultAgent } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { validateAiVaultSessionDeleteTarget, @@ -19,14 +17,7 @@ import { isENOENT } from '../ipc/filesystem-auth' // Never throws: IPC payloads are untyped at runtime, so a bad input and an fs // error both resolve to a discriminated result the handler can render. export async function deleteAiVaultSessionFile( - args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string }, - deps: { - getSessionLiveness: (target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string - }) => Promise - } + args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string } ): Promise { const validation = validateAiVaultSessionDeleteTarget(args) if (!validation.allowed) { @@ -34,24 +25,6 @@ export async function deleteAiVaultSessionFile( } const { agent, removals } = validation - let liveness: AiVaultSessionLiveness = 'unknown' - try { - liveness = await deps.getSessionLiveness({ - agent, - sessionId: args.sessionId, - filePath: validation.resolvedPath - }) - } catch { - // Inspection failure is unknown, never permission to delete. - } - if (liveness !== 'not-live') { - return { - outcome: 'rejected', - agent, - reason: liveness === 'live' ? 'session-live' : 'session-liveness-unknown' - } - } - try { for (const removal of removals) { const rejection = await removeOne(removal) diff --git a/src/main/ai-vault/session-first-user-prompt-handler.ts b/src/main/ai-vault/session-first-user-prompt-handler.ts new file mode 100644 index 00000000000..d20a3508202 --- /dev/null +++ b/src/main/ai-vault/session-first-user-prompt-handler.ts @@ -0,0 +1,20 @@ +import type { + AiVaultFirstUserPromptArgs, + AiVaultFirstUserPromptResult +} from '../../shared/ai-vault-types' +import { readAiVaultFirstUserPromptInBackground } from './session-scanner-background' + +export function handleAiVaultGetFirstUserPrompt( + args?: AiVaultFirstUserPromptArgs +): Promise { + if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') { + return Promise.resolve({ prompt: null }) + } + return readAiVaultFirstUserPromptInBackground({ + agent: args.agent, + filePath: args.filePath, + sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined, + executionHostId: args.executionHostId, + codexHome: args.codexHome + }) +} diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts index b656494f61c..f250846a20e 100644 --- a/src/main/ai-vault/session-first-user-prompt-read.ts +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -1,7 +1,6 @@ import { stat } from 'node:fs/promises' import type { AiVaultAgent, - AiVaultFirstUserPromptArgs, AiVaultFirstUserPromptResult, AiVaultSession } from '../../shared/ai-vault-types' @@ -22,22 +21,6 @@ export type ReadAiVaultFirstUserPromptArgs = { export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult -/** IPC-safe entry: validates untyped payload then reads the full first prompt. */ -export async function handleAiVaultGetFirstUserPrompt( - args?: AiVaultFirstUserPromptArgs -): Promise { - if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') { - return { prompt: null } - } - return readAiVaultFirstUserPrompt({ - agent: args.agent, - filePath: args.filePath, - sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined, - executionHostId: args.executionHostId, - codexHome: args.codexHome - }) -} - /** * Re-parse one session transcript under full first-prompt capture and return * the untruncated first real user ask for copy/reuse. diff --git a/src/main/ai-vault/session-liveness.test.ts b/src/main/ai-vault/session-liveness.test.ts deleted file mode 100644 index c4eb2a70328..00000000000 --- a/src/main/ai-vault/session-liveness.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import { - readAiVaultSessionIdentity, - resolveAiVaultSessionLiveness, - type AiVaultSessionLivenessDependencies -} from './session-liveness' - -function processInfo(id: string, terminalHandle = `term_${id}`): PtyProcessInfo { - return { id, terminalHandle, cwd: '/workspace', title: 'terminal' } -} - -function status(args: { - sessionId: string - ptyId: string - connectionId?: string | null -}): AgentStatusIpcPayload { - return { - paneKey: `tab:${args.ptyId}`, - terminalHandle: `term_${args.ptyId}`, - connectionId: args.connectionId ?? null, - receivedAt: 1, - stateStartedAt: 1, - state: 'working', - prompt: 'test', - agentType: 'gemini', - providerSession: { key: 'session_id', id: args.sessionId } - } -} - -function dependencies( - overrides: Partial = {} -): AiVaultSessionLivenessDependencies { - return { - listProcesses: async () => [], - getStatusSnapshot: () => [], - inspectForegroundProcess: async () => ({ available: true, process: 'zsh' }), - getStatusPtyId: (row) => row.terminalHandle?.slice('term_'.length) ?? null, - getAgentHint: () => null, - ...overrides - } -} - -describe('resolveAiVaultSessionLiveness', () => { - it('binds renderer identity to the session parsed from the validated transcript', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-delete-identity-')) - const filePath = join(root, 'session.json') - await writeFile( - filePath, - JSON.stringify({ - sessionId: 'authoritative-session', - startTime: '2026-08-07T00:00:00.000Z', - lastUpdated: '2026-08-07T00:01:00.000Z', - messages: [{ type: 'user', content: 'test' }] - }) - ) - try { - await expect( - readAiVaultSessionIdentity({ - agent: 'gemini', - sessionId: 'spoofed-session', - filePath - }) - ).resolves.toEqual({ outcome: 'unknown' }) - await expect( - readAiVaultSessionIdentity({ - agent: 'gemini', - sessionId: 'authoritative-session', - filePath - }) - ).resolves.toEqual({ outcome: 'found', sessionId: 'authoritative-session' }) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - it('finds a paired-owned live session without consulting a renderer pane snapshot', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('paired-pty')], - getStatusSnapshot: () => [status({ sessionId: 'session-live', ptyId: 'paired-pty' })], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) - ).resolves.toBe('live') - }) - - it('reads hook identity after a controlled process-inventory barrier', async () => { - let releaseInventory: (processes: PtyProcessInfo[]) => void = () => {} - const inventory = new Promise((resolve) => { - releaseInventory = resolve - }) - let statuses = [status({ sessionId: 'session-other', ptyId: 'paired-pty' })] - const deps = dependencies({ - listProcesses: () => inventory, - getStatusSnapshot: () => statuses, - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - const liveness = resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - deps - ) - statuses = [status({ sessionId: 'session-live', ptyId: 'paired-pty' })] - releaseInventory([processInfo('paired-pty')]) - - await expect(liveness).resolves.toBe('live') - }) - - it('reads hook identity after a controlled foreground-inspection barrier', async () => { - let markInspectionStarted: () => void = () => {} - const inspectionStarted = new Promise((resolve) => { - markInspectionStarted = resolve - }) - let releaseInspection: (inspection: { - available: boolean - process: string | null - }) => void = () => {} - const inspection = new Promise<{ available: boolean; process: string | null }>((resolve) => { - releaseInspection = resolve - }) - let statuses = [status({ sessionId: 'session-other', ptyId: 'managed-pty' })] - const deps = dependencies({ - listProcesses: async () => [processInfo('managed-pty')], - getStatusSnapshot: () => statuses, - inspectForegroundProcess: () => { - markInspectionStarted() - return inspection - } - }) - - const liveness = resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - deps - ) - await inspectionStarted - statuses = [status({ sessionId: 'session-live', ptyId: 'managed-pty' })] - releaseInspection({ available: true, process: 'gemini' }) - - await expect(liveness).resolves.toBe('live') - }) - - it('treats WSL hook ownership as local authority', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('wsl-pty')], - getStatusSnapshot: () => [ - status({ sessionId: 'session-live', ptyId: 'wsl-pty', connectionId: 'wsl:Ubuntu' }) - ], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) - ).resolves.toBe('live') - }) - - it('does not use SSH identity as local liveness evidence', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('local-pty')], - getStatusSnapshot: () => [ - status({ sessionId: 'session-live', ptyId: 'local-pty', connectionId: 'ssh:dev-box' }) - ], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) - ).resolves.toBe('unknown') - }) - - it('returns not-live when every local Gemini process owns another known session', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('other-pty')], - getStatusSnapshot: () => [status({ sessionId: 'session-other', ptyId: 'other-pty' })], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('not-live') - }) - - it.each([ - ['missing session identity', dependencies(), undefined], - [ - 'unavailable process inventory', - dependencies({ - listProcesses: async () => { - throw new Error('daemon offline') - } - }), - 'session-live' - ], - [ - 'unattributed live agent process', - dependencies({ - listProcesses: async () => [processInfo('unknown-pty')], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }), - 'session-live' - ] - ])('preserves unknown for %s', async (_name, deps, sessionId) => { - await expect(resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId }, deps)).resolves.toBe( - 'unknown' - ) - }) - - it('bounds foreground inspection concurrency', async () => { - let active = 0 - let maxActive = 0 - const inspectForegroundProcess = vi.fn(async () => { - active += 1 - maxActive = Math.max(maxActive, active) - await new Promise((resolve) => queueMicrotask(resolve)) - active -= 1 - return { available: true, process: 'zsh' } - }) - const deps = dependencies({ - listProcesses: async () => - Array.from({ length: 20 }, (_, index) => processInfo(`shell-${index}`)), - inspectForegroundProcess - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('not-live') - expect(inspectForegroundProcess).toHaveBeenCalledTimes(20) - expect(maxActive).toBeLessThanOrEqual(8) - }) - - it('fails closed before inspecting an oversized inventory', async () => { - const inspectForegroundProcess = vi.fn() - const deps = dependencies({ - listProcesses: async () => - Array.from({ length: 513 }, (_, index) => processInfo(`shell-${index}`)), - inspectForegroundProcess - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('unknown') - expect(inspectForegroundProcess).not.toHaveBeenCalled() - }) - - it('does not start foreground inspections after the shared deadline', async () => { - const inspectForegroundProcess = vi.fn() - const deps = dependencies({ - deadlineMs: 0, - listProcesses: async () => [processInfo('late-pty')], - inspectForegroundProcess - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('unknown') - expect(inspectForegroundProcess).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ai-vault/session-liveness.ts b/src/main/ai-vault/session-liveness.ts deleted file mode 100644 index 6f7170687ad..00000000000 --- a/src/main/ai-vault/session-liveness.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { isShellProcess } from '../../shared/agent-detection' -import { recognizeAgentProcess } from '../../shared/agent-process-recognition' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' -import type { AiVaultAgent } from '../../shared/ai-vault-types' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' -import { lstat } from 'node:fs/promises' -import { parseAgentSessionFileCached } from './session-scanner-parse-cache' - -const MAX_LIVENESS_PROCESSES = 512 -const PROCESS_INSPECTION_CONCURRENCY = 8 - -type ProcessInspection = { - available: boolean - process: string | null -} - -type AgentProcessInspection = 'target-agent' | 'unknown' | 'other' - -export type AiVaultSessionLivenessDependencies = { - deadlineMs?: number - listProcesses: () => Promise - getStatusSnapshot: () => AgentStatusIpcPayload[] - inspectForegroundProcess: (ptyId: string) => Promise - getStatusPtyId: (status: AgentStatusIpcPayload) => string | null - getAgentHint: (process: PtyProcessInfo) => string | null -} - -export type AiVaultSessionIdentityRead = - | { outcome: 'found'; sessionId: string } - | { outcome: 'missing' } - | { outcome: 'unknown' } - -/** Binds the requested identity to the validated transcript on disk. */ -export async function readAiVaultSessionIdentity(target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string -}): Promise { - try { - const fileStats = await lstat(target.filePath) - if (!fileStats.isFile()) { - return { outcome: 'unknown' } - } - const session = await parseAgentSessionFileCached( - { - agent: target.agent, - file: { - path: target.filePath, - mtimeMs: fileStats.mtimeMs, - modifiedAt: fileStats.mtime.toISOString(), - sizeBytes: fileStats.size - }, - codexHome: null - }, - process.platform - ) - return session?.sessionId && session.sessionId === target.sessionId - ? { outcome: 'found', sessionId: session.sessionId } - : { outcome: 'unknown' } - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'ENOENT' - ? { outcome: 'missing' } - : { outcome: 'unknown' } - } -} - -function isLocalStatus(status: AgentStatusIpcPayload): boolean { - return status.connectionId === null || isWslHookRelayConnectionId(status.connectionId) -} - -function isValidSessionId(sessionId: string | undefined): sessionId is string { - return ( - typeof sessionId === 'string' && - sessionId.length > 0 && - sessionId.length <= 512 && - sessionId.trim() === sessionId - ) -} - -async function inspectInBatches( - values: readonly T[], - inspect: (value: T) => Promise, - deadlineMs?: number -): Promise { - const results: AgentProcessInspection[] = [] - let nextIndex = 0 - const worker = async (): Promise => { - while (nextIndex < values.length) { - const index = nextIndex - nextIndex += 1 - if (deadlineMs !== undefined && Date.now() >= deadlineMs) { - results[index] = 'unknown' - continue - } - results[index] = await inspect(values[index]!) - } - } - await Promise.all( - Array.from({ length: Math.min(PROCESS_INSPECTION_CONCURRENCY, values.length) }, worker) - ) - return results -} - -/** Resolves owning-runtime liveness without treating unavailable evidence as absence. */ -export async function resolveAiVaultSessionLiveness( - target: { agent: AiVaultAgent; sessionId: string | undefined }, - deps: AiVaultSessionLivenessDependencies -): Promise { - if (!isValidSessionId(target.sessionId)) { - return 'unknown' - } - - let processes: PtyProcessInfo[] - try { - processes = await deps.listProcesses() - } catch { - return 'unknown' - } - if (processes.length > MAX_LIVENESS_PROCESSES) { - return 'unknown' - } - - const processInspections = await inspectInBatches( - processes, - async (process) => { - let inspection: ProcessInspection - try { - inspection = await deps.inspectForegroundProcess(process.id) - } catch { - return 'unknown' - } - if (!inspection.available) { - return 'unknown' - } - - const recognizedAgent = recognizeAgentProcess(inspection.process)?.agent ?? null - const hintedAgent = deps.getAgentHint(process) - const ownsTargetAgent = - recognizedAgent === target.agent || - (recognizedAgent === null && - (inspection.process === null || !isShellProcess(inspection.process)) && - hintedAgent === target.agent) - if (!ownsTargetAgent) { - return 'other' - } - return 'target-agent' - }, - deps.deadlineMs - ) - - let localStatuses: AgentStatusIpcPayload[] - try { - localStatuses = deps.getStatusSnapshot().filter(isLocalStatus) - } catch { - return 'unknown' - } - const processIds = new Set(processes.map((process) => process.id)) - const hasUnmatchedTargetStatus = localStatuses.some( - (status) => - status.agentType === target.agent && - status.providerSession?.id === target.sessionId && - !processIds.has(deps.getStatusPtyId(status) ?? '') - ) - if (hasUnmatchedTargetStatus) { - return 'unknown' - } - - const results = processInspections.map((inspection, index) => { - if (inspection !== 'target-agent') { - return inspection - } - const identities = localStatuses.filter( - (status) => - status.agentType === target.agent && deps.getStatusPtyId(status) === processes[index]!.id - ) - if (identities.some((status) => status.providerSession?.id === target.sessionId)) { - return 'live' - } - return identities.some((status) => status.providerSession) ? 'other' : 'unknown' - }) - if (results.includes('live')) { - return 'live' - } - return results.includes('unknown') ? 'unknown' : 'not-live' -} diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index 9239d4ccfe2..93e17941ab8 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -86,8 +86,8 @@ export function scheduleSessionParseCachePersist(stats: SessionParseStats): void } } -/** Run any pending debounced save immediately and wait for it. Test-only. */ -export async function flushSessionParseCachePersistForTests(): Promise { +/** Run any pending debounced save immediately and wait for it before process exit. */ +export async function flushSessionParseCachePersist(): Promise { if (saveTimer) { clearTimeout(saveTimer) saveTimer = null @@ -99,6 +99,8 @@ export async function flushSessionParseCachePersistForTests(): Promise { await lastSave } +export const flushSessionParseCachePersistForTests = flushSessionParseCachePersist + async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise { await sweepOrphanedTempFiles(current.filePath) try { diff --git a/src/main/ai-vault/session-scanner-background.test.ts b/src/main/ai-vault/session-scanner-background.test.ts new file mode 100644 index 00000000000..0b0ca493db4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-background.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { shouldUseAiVaultServiceProcess } from './session-scanner-background' + +const originalBackend = process.env.ORCA_AI_VAULT_SERVICE_PROCESS +const originalNodeEnv = process.env.NODE_ENV + +afterEach(() => { + if (originalBackend === undefined) { + delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS + } else { + process.env.ORCA_AI_VAULT_SERVICE_PROCESS = originalBackend + } + process.env.NODE_ENV = originalNodeEnv +}) + +describe('shouldUseAiVaultServiceProcess', () => { + it('keeps unit tests on the worker fallback by default', () => { + delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS + process.env.NODE_ENV = 'test' + expect(shouldUseAiVaultServiceProcess()).toBe(false) + }) + + it('defaults non-test hosts to the service process', () => { + delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS + process.env.NODE_ENV = 'production' + expect(shouldUseAiVaultServiceProcess()).toBe(true) + }) + + it.each([ + ['1', true], + ['0', false] + ] as const)('honors the explicit %s kill switch', (value, expected) => { + process.env.ORCA_AI_VAULT_SERVICE_PROCESS = value + expect(shouldUseAiVaultServiceProcess()).toBe(expected) + }) +}) diff --git a/src/main/ai-vault/session-scanner-background.ts b/src/main/ai-vault/session-scanner-background.ts new file mode 100644 index 00000000000..fb80afa2def --- /dev/null +++ b/src/main/ai-vault/session-scanner-background.ts @@ -0,0 +1,80 @@ +import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../../shared/ai-vault-session-title' +import { + readAiVaultFirstUserPrompt, + type ReadAiVaultFirstUserPromptArgs, + type ReadAiVaultFirstUserPromptResult +} from './session-first-user-prompt-read' +import { + invalidateAiVaultServiceCache, + listAiVaultSubagentSessionsInService, + readAiVaultFirstUserPromptInService, + resetAiVaultScannerServiceForTests, + resolveAiVaultSessionTitlesInService, + scanAiVaultSessionsInService +} from './session-scanner-service-spawn' +import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol' +import { + resetAiVaultScannerWorkerForTests, + resolveAiVaultSessionTitlesInWorker, + scanAiVaultSessionsInWorker +} from './session-scanner-worker-spawn' +import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' +import { listLocalAiVaultSubagentSessions } from './session-subagent-reader' + +export function shouldUseAiVaultServiceProcess(): boolean { + const configured = process.env.ORCA_AI_VAULT_SERVICE_PROCESS + if (configured === '1') { + return true + } + if (configured === '0') { + return false + } + return process.env.NODE_ENV !== 'test' +} + +export function scanAiVaultSessionsInBackground( + options: AiVaultWorkerScanOptions, + signal?: AbortSignal +): Promise { + return shouldUseAiVaultServiceProcess() + ? scanAiVaultSessionsInService(options, signal) + : scanAiVaultSessionsInWorker(options, signal) +} + +export function resolveAiVaultSessionTitlesInBackground( + requests: AiVaultSessionTitleRequest[], + signal?: AbortSignal +): Promise { + return shouldUseAiVaultServiceProcess() + ? resolveAiVaultSessionTitlesInService(requests, signal) + : resolveAiVaultSessionTitlesInWorker(requests, signal) +} + +export function listAiVaultSubagentSessionsInBackground( + request: AiVaultServiceSubagentRequest +): Promise { + return shouldUseAiVaultServiceProcess() + ? listAiVaultSubagentSessionsInService(request) + : listLocalAiVaultSubagentSessions(request) +} + +export function readAiVaultFirstUserPromptInBackground( + request: ReadAiVaultFirstUserPromptArgs +): Promise { + return shouldUseAiVaultServiceProcess() + ? readAiVaultFirstUserPromptInService(request) + : readAiVaultFirstUserPrompt(request) +} + +export function invalidateAiVaultBackgroundCache(paths: string[]): Promise { + return shouldUseAiVaultServiceProcess() ? invalidateAiVaultServiceCache(paths) : Promise.resolve() +} + +export function resetAiVaultScannerBackgroundForTests(): void { + resetAiVaultScannerServiceForTests() + resetAiVaultScannerWorkerForTests() +} diff --git a/src/main/ai-vault/session-scanner-codex-message-records.ts b/src/main/ai-vault/session-scanner-codex-message-records.ts new file mode 100644 index 00000000000..5aa739275ff --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-message-records.ts @@ -0,0 +1,70 @@ +import { normalizePromptField } from '../../shared/agent-status-field-normalization' +import { addPreviewContent } from './session-scanner-accumulator' +import type { SessionAccumulator } from './session-scanner-types' +import { asRecord, extractContentText, extractPreviewContentText } from './session-scanner-values' + +export function consumeCodexResponseMessage( + accumulator: SessionAccumulator, + payload: Record, + timestamp: unknown +): boolean { + accumulator.messageCount++ + const role = + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown' + const setTitle = role === 'user' && !accumulator.title + if (setTitle) { + accumulator.title = extractContentText(payload.content) + } + addPreviewContent(accumulator, role, payload.content, timestamp) + return setTitle && Boolean(accumulator.title) +} + +export function consumeCodexCompletedMessage( + accumulator: SessionAccumulator, + payload: Record, + timestamp: unknown +): boolean { + const item = asRecord(payload.item) + if (!item) { + return false + } + const isUser = item.type === 'UserMessage' || item.type === 'user_message' + const isAssistant = item.type === 'AgentMessage' || item.type === 'agent_message' + if (!isUser && !isAssistant) { + return false + } + accumulator.messageCount++ + const setTitle = isUser && !accumulator.title + if (isUser) { + const prompt = normalizePromptField(extractPreviewContentText(item.content)) + if (prompt) { + accumulator.lastUserPrompt = prompt + } + if (setTitle) { + accumulator.title = extractContentText(item.content) + } + } + addPreviewContent(accumulator, isUser ? 'user' : 'assistant', item.content, timestamp) + return setTitle && Boolean(accumulator.title) +} + +export function consumeCodexLegacyEventMessage( + accumulator: SessionAccumulator, + payload: Record, + timestamp: unknown +): boolean { + const isUser = payload.type === 'user_message' + accumulator.messageCount++ + const setTitle = isUser && !accumulator.title + if (isUser) { + const prompt = normalizePromptField(payload.message) + if (prompt) { + accumulator.lastUserPrompt = prompt + } + if (setTitle) { + accumulator.title = extractContentText(payload.message) + } + } + addPreviewContent(accumulator, isUser ? 'user' : 'assistant', payload.message, timestamp) + return setTitle && Boolean(accumulator.title) +} diff --git a/src/main/ai-vault/session-scanner-codex-parser.test.ts b/src/main/ai-vault/session-scanner-codex-parser.test.ts index d768ad2a330..d0def02af8d 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.test.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts @@ -16,6 +16,76 @@ function jsonLines(records: unknown[]): string { } describe('parseCodexSessionFile', () => { + it('uses completed user items for paginated session metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-paginated-')) + tempRoots.push(root) + const sessionPath = join(root, 'sessions', '2026', '08', '10', 'rollout-paginated.jsonl') + await mkdir(dirname(sessionPath), { recursive: true }) + + await writeFile( + sessionPath, + jsonLines([ + { + timestamp: '2026-08-10T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'paginated-session', history_mode: 'paginated', cwd: '/repo/app' } + }, + { + timestamp: '2026-08-10T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'model-only prompt copy' }] + } + }, + { + timestamp: '2026-08-10T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'UserMessage', + content: [{ type: 'text', text: 'Visible paginated prompt' }] + } + } + }, + { + timestamp: '2026-08-10T10:00:03.000Z', + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'AgentMessage', + content: [{ type: 'Text', text: 'Visible paginated response' }] + } + } + } + ]) + ) + + const sessionStat = await stat(sessionPath) + const session = await parseCodexSessionFile( + { + path: sessionPath, + mtimeMs: sessionStat.mtimeMs, + modifiedAt: sessionStat.mtime.toISOString() + }, + 'darwin', + root + ) + + expect(session).toMatchObject({ + title: 'Visible paginated prompt', + lastUserPrompt: 'Visible paginated prompt', + messageCount: 2, + previewMessages: [ + { role: 'user', text: 'Visible paginated prompt' }, + { role: 'assistant', text: 'Visible paginated response' } + ] + }) + }) + it('uses user-message events instead of later injected user-role records', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-last-prompt-')) tempRoots.push(root) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 74e92165c61..0f73fa54126 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -3,15 +3,18 @@ import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' import type { ExecutionHostId } from '../../shared/execution-host' -import { normalizePromptField } from '../../shared/agent-status-field-normalization' import { - addPreviewContent, cloneSessionAccumulator, createAccumulator, finalizeSession, sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' +import { + consumeCodexCompletedMessage, + consumeCodexLegacyEventMessage, + consumeCodexResponseMessage +} from './session-scanner-codex-message-records' import type { CodexUsageSnapshot, FileWithMtime, @@ -22,7 +25,6 @@ import type { import { addCodexUsage, asRecord, - extractContentText, extractGitBranch, extractModel, extractString, @@ -80,6 +82,7 @@ type CodexSessionParseState = { previousTotals: CodexUsageSnapshot | null rejectedWorkerSession: boolean sawSessionMeta: boolean + historyMode: string | null // Which source set the current title; an index-file title outranks the raw // first user prompt, so finalize must know whether 'meta' already won. titleSource: 'meta' | 'user' | null @@ -95,6 +98,7 @@ function createCodexParseState(file: FileWithMtime): CodexSessionParseState { previousTotals: null, rejectedWorkerSession: false, sawSessionMeta: false, + historyMode: null, titleSource: null } } @@ -128,6 +132,7 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo return } state.sawSessionMeta = true + state.historyMode = extractString(payload.history_mode) const sessionId = extractString(payload.id) if (sessionId) { accumulator.sessionId = sessionId @@ -162,17 +167,12 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo } if (record.type === 'response_item' && payload.type === 'message') { - accumulator.messageCount++ - if (payload.role === 'user' && !accumulator.title) { - accumulator.title = extractContentText(payload.content) - state.titleSource = accumulator.title ? 'user' : state.titleSource + if (state.historyMode === 'paginated') { + return + } + if (consumeCodexResponseMessage(accumulator, payload, record.timestamp)) { + state.titleSource = 'user' } - addPreviewContent( - accumulator, - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', - payload.content, - record.timestamp - ) return } @@ -180,23 +180,17 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo return } - if (payload.type === 'user_message') { - accumulator.messageCount++ - const prompt = normalizePromptField(payload.message) - if (prompt) { - accumulator.lastUserPrompt = prompt + if (state.historyMode === 'paginated' && payload.type === 'item_completed') { + if (consumeCodexCompletedMessage(accumulator, payload, record.timestamp)) { + state.titleSource = 'user' } - if (!accumulator.title) { - accumulator.title = extractContentText(payload.message) - state.titleSource = accumulator.title ? 'user' : state.titleSource - } - addPreviewContent(accumulator, 'user', payload.message, record.timestamp) return } - if (payload.type === 'agent_message') { - accumulator.messageCount++ - addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) + if (payload.type === 'user_message' || payload.type === 'agent_message') { + if (consumeCodexLegacyEventMessage(accumulator, payload, record.timestamp)) { + state.titleSource = 'user' + } return } diff --git a/src/main/ai-vault/session-scanner-directory-reader.test.ts b/src/main/ai-vault/session-scanner-directory-reader.test.ts new file mode 100644 index 00000000000..e56acbfb758 --- /dev/null +++ b/src/main/ai-vault/session-scanner-directory-reader.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { walkSessionFiles } from './session-scanner-discovery' + +let tempRoot: string | null = null + +afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }) + tempRoot = null + } +}) + +describe('walkSessionFiles directory reader', () => { + it('uses the injected reader for the root and nested directories', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'orca-session-reader-')) + const nested = join(tempRoot, '2026', '08', '09') + await mkdir(nested, { recursive: true }) + const rollout = join(nested, 'rollout-session.jsonl') + await writeFile(rollout, '{}\n') + const readDirectory = vi.fn((dirPath: string) => readdir(dirPath, { withFileTypes: true })) + + await expect( + walkSessionFiles(tempRoot, 'codex', [], { + extensions: new Set(['.jsonl']), + readDirectory + }) + ).resolves.toEqual([rollout]) + expect(readDirectory).toHaveBeenCalledTimes(4) + }) + + it('preserves unreadable-directory handling for an injected reader', async () => { + const readDirectory = vi.fn(async () => { + throw new Error('unreachable') + }) + + await expect( + walkSessionFiles('missing', 'codex', [], { + extensions: new Set(['.jsonl']), + readDirectory + }) + ).resolves.toEqual([]) + }) + + it('does not turn cancellation into an unreadable-directory miss', async () => { + const controller = new AbortController() + const cancelled = new Error('scan cancelled') + const readDirectory = vi.fn(async () => { + controller.abort(cancelled) + throw cancelled + }) + + await expect( + walkSessionFiles('cancelled', 'codex', [], { + extensions: new Set(['.jsonl']), + readDirectory, + signal: controller.signal + }) + ).rejects.toBe(cancelled) + }) +}) diff --git a/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts new file mode 100644 index 00000000000..ce24afccc2c --- /dev/null +++ b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import { walkSessionFiles } from './session-scanner-discovery' + +describe('walkSessionFiles WSL gate refusals', () => { + it('rethrows gate refusals instead of reporting an empty tree', async () => { + const refusal = new WslTranscriptFsError('timeout', 'slow share') + await expect( + walkSessionFiles('\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions', 'codex', [], { + extensions: new Set(['.jsonl']), + readDirectory: async () => { + throw refusal + } + }) + ).rejects.toBe(refusal) + }) + + it('still treats ordinary readdir failures as an empty tree', async () => { + await expect( + walkSessionFiles('/missing/root', 'codex', [], { + extensions: new Set(['.jsonl']), + readDirectory: async () => { + throw Object.assign(new Error('no such directory'), { code: 'ENOENT' }) + } + }) + ).resolves.toEqual([]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 39398fd4d09..7f72082a266 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,6 +1,8 @@ +import type { Dirent } from 'node:fs' import { readdir, stat } from 'node:fs/promises' import { extname, join } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -52,18 +54,30 @@ export async function walkSessionFiles( // Return false to skip descending into a directory; depth 0 is a child of // rootDir, so pruned subtrees are never stat'd or parsed. directoryPredicate?: (name: string, depth: number) => boolean + readDirectory?: (dirPath: string) => Promise + signal?: AbortSignal }, depth = 0 ): Promise { + options.signal?.throwIfAborted() let entries try { - entries = await readdir(dirPath, { withFileTypes: true }) - } catch { + entries = options.readDirectory + ? await options.readDirectory(dirPath) + : await readdir(dirPath, { withFileTypes: true }) + } catch (error) { + options.signal?.throwIfAborted() + // Why: a gate refusal means the scan could not run, not that the tree is + // empty — swallowing it would misreport a stalled distro as "no transcript". + if (error instanceof WslTranscriptFsError) { + throw error + } return [] } const files: string[] = [] for (const entry of entries) { + options.signal?.throwIfAborted() const fullPath = join(dirPath, entry.name) if (entry.isDirectory()) { // Skip whole subtrees an agent never wants (e.g. subagent transcripts), diff --git a/src/main/ai-vault/session-scanner-service-client-state.ts b/src/main/ai-vault/session-scanner-service-client-state.ts new file mode 100644 index 00000000000..ea56bd534f5 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-client-state.ts @@ -0,0 +1,184 @@ +import type { ChildProcess } from 'node:child_process' +import type { + AiVaultServiceInit, + AiVaultServiceLane, + AiVaultServiceRequest +} from './session-scanner-service-protocol' + +export const AI_VAULT_SERVICE_READY_TIMEOUT_MS = 5_000 +export const AI_VAULT_SERVICE_SCAN_TIMEOUT_MS = 130_000 +export const AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS = 15_000 +export const AI_VAULT_SERVICE_MAX_CALLS = 16 +export const AI_VAULT_SERVICE_IDLE_TIMEOUT_MS = 10 * 60_000 +export const AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000 + +export type AiVaultServiceProcessFactory = () => ChildProcess +export type AiVaultServiceClientOptions = { + processFactory: AiVaultServiceProcessFactory + init: Omit + idleTimeoutMs?: number + onStderr?: (text: string) => void +} + +export type AiVaultServiceInvalidation = { + resolve: () => void + reject: (error: Error) => void + timer: NodeJS.Timeout +} + +export class AiVaultServiceInvalidations { + private readonly pending = new Map() + private generation = 0 + + get size(): number { + return this.pending.size + } + + open( + timeoutMs: number, + onTimeout: (generation: number) => void, + send: (generation: number) => void + ): Promise { + const generation = ++this.generation + return new Promise((resolve, reject) => { + const timer = setTimeout(() => onTimeout(generation), timeoutMs) + timer.unref?.() + this.pending.set(generation, { resolve, reject, timer }) + send(generation) + }) + } + + settle(generation: number): boolean { + const entry = this.pending.get(generation) + if (!entry) { + return false + } + clearTimeout(entry.timer) + this.pending.delete(generation) + entry.resolve() + return true + } + + rejectAll(error: Error): void { + for (const entry of this.pending.values()) { + clearTimeout(entry.timer) + entry.reject(error) + } + this.pending.clear() + } +} + +export function createAiVaultServiceReadyWaiter( + timeoutMs: number, + onTimeout: () => void +): AiVaultServiceReadyWaiter { + let resolve!: (child: ChildProcess) => void + let reject!: (error: Error) => void + const promise = new Promise((resolveReady, rejectReady) => { + resolve = resolveReady + reject = rejectReady + }) + const timer = setTimeout(onTimeout, timeoutMs) + timer.unref?.() + return { promise, resolve, reject, timer } +} + +export function retireAiVaultServiceChild(child: ChildProcess): void { + child.removeAllListeners('message') + child.removeAllListeners('disconnect') + child.removeAllListeners('error') + child.removeAllListeners('exit') + const killTimer = setTimeout(() => child.kill(), AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS) + killTimer.unref?.() + child.once('exit', () => clearTimeout(killTimer)) + child.send({ type: 'shutdown' }, () => undefined) + child.unref() +} + +export function armAiVaultServiceCancellationTimeout( + call: AiVaultServicePendingCall, + onExpired: () => void +): void { + if (call.timer) { + clearTimeout(call.timer) + } + call.timer = setTimeout(onExpired, AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS) + call.timer.unref?.() +} + +/** + * A cold start that faults before the request reached the child self-heals on + * the scheduled respawn. Requeue once; the caller rejects when this returns false. + */ +export function requeueAiVaultServiceStart( + call: AiVaultServicePendingCall, + queue: AiVaultServicePendingCall[] +): boolean { + if (call.sent || call.cancelled || call.startRetried) { + return false + } + call.startRetried = true + queue.unshift(call) + return true +} + +export function clearAiVaultServiceCall(call: AiVaultServicePendingCall): void { + if (call.timer) { + clearTimeout(call.timer) + call.timer = null + } + if (call.signal && call.onAbort) { + call.signal.removeEventListener('abort', call.onAbort) + call.onAbort = null + } +} + +export function rejectAiVaultServiceCall(call: AiVaultServicePendingCall, error: Error): void { + clearAiVaultServiceCall(call) + if (!call.cancelled) { + call.reject(error) + } +} + +export type AiVaultServicePendingCall = { + request: AiVaultServiceRequest + lane: AiVaultServiceLane + signal?: AbortSignal + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: NodeJS.Timeout | null + onAbort: (() => void) | null + cancelled: boolean + /** Whether the child received the request; an unsent call gets no reply. */ + sent: boolean + startRetried: boolean +} + +export type AiVaultServiceReadyWaiter = { + promise: Promise + resolve: (child: ChildProcess) => void + reject: (error: Error) => void + timer: NodeJS.Timeout +} + +export class AiVaultServiceIdleRetirement { + private timer: NodeJS.Timeout | null = null + + clear(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } + + schedule(busy: boolean, timeoutMs: number, retire: () => void): void { + if (busy || this.timer) { + return + } + this.timer = setTimeout(() => { + this.timer = null + retire() + }, timeoutMs) + this.timer.unref?.() + } +} diff --git a/src/main/ai-vault/session-scanner-service-client.test.ts b/src/main/ai-vault/session-scanner-service-client.test.ts new file mode 100644 index 00000000000..6dd415cafa5 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-client.test.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AiVaultScannerServiceClient } from './session-scanner-service-client' +import { AI_VAULT_SERVICE_READY_TIMEOUT_MS } from './session-scanner-service-client-state' +import { + AiVaultServiceTestChild, + aiVaultServiceRequestId, + readyAiVaultServiceChild +} from './session-scanner-service-test-child' + +function setup(idleTimeoutMs?: number): { + child: AiVaultServiceTestChild + client: AiVaultScannerServiceClient +} { + const child = new AiVaultServiceTestChild() + const client = new AiVaultScannerServiceClient({ + processFactory: () => child.asChildProcess(), + init: { sessionParseCache: null }, + idleTimeoutMs + }) + return { child, client } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('AiVaultScannerServiceClient', () => { + it('waits for ready and runs cache and interactive lanes independently', async () => { + const { child, client } = setup() + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + const subagents = client.request({ + type: 'request', + operation: 'subagents', + request: { agent: 'claude', parentFilePath: '/tmp/parent.jsonl' } + }) + + expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })]) + readyAiVaultServiceChild(child) + await Promise.resolve() + expect(child.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ operation: 'titles' }), + expect.objectContaining({ operation: 'subagents' }) + ]) + ) + + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'subagents'), + operation: 'subagents', + value: { sessions: [], issues: [] } + }) + await expect(titles).resolves.toEqual({ titles: [] }) + await expect(subagents).resolves.toEqual({ sessions: [], issues: [] }) + client.dispose() + }) + + it('bounds active and queued calls together at sixteen', async () => { + const { child, client } = setup() + const calls = Array.from({ length: 16 }, () => + client.request({ type: 'request', operation: 'titles', requests: [] }) + ) + + await expect( + client.request({ type: 'request', operation: 'titles', requests: [] }) + ).rejects.toThrow('queue is full') + readyAiVaultServiceChild(child) + client.dispose() + await Promise.all(calls.map((call) => expect(call).rejects.toThrow('disposed'))) + }) + + it('cancels active work and kills a child that ignores cancellation', async () => { + vi.useFakeTimers() + const { child, client } = setup() + const controller = new AbortController() + const request = client.request( + { type: 'request', operation: 'titles', requests: [] }, + controller.signal + ) + readyAiVaultServiceChild(child) + await Promise.resolve() + const id = aiVaultServiceRequestId(child, 'titles') + + controller.abort() + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + expect(child.sent).toContainEqual({ type: 'cancel', id }) + vi.advanceTimersByTime(1_999) + expect(child.killed).toBe(false) + vi.advanceTimersByTime(1) + expect(child.killed).toBe(true) + client.dispose() + }) + + it('drops a call cancelled before the child received it', async () => { + vi.useFakeTimers() + const { child, client } = setup() + const controller = new AbortController() + const cancelled = client.request( + { type: 'request', operation: 'titles', requests: [] }, + controller.signal + ) + + controller.abort() + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' })) + + readyAiVaultServiceChild(child) + const next = client.request({ type: 'request', operation: 'titles', requests: [] }) + await Promise.resolve() + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await expect(next).resolves.toEqual({ titles: [] }) + vi.advanceTimersByTime(2_000) + + expect(child.killed).toBe(false) + client.dispose() + }) + + it('retries once when the first cold start misses the ready deadline', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null } + }) + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + expect(children).toHaveLength(1) + + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + expect(children[0]!.killed).toBe(true) + await Promise.resolve() + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + children[1]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[1]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + + await expect(titles).resolves.toEqual({ titles: [] }) + client.dispose() + }) + + it('surfaces the startup error when the retried cold start also fails', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null } + }) + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + await Promise.resolve() + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + + await expect(titles).rejects.toThrow('did not become ready') + client.dispose() + }) + + it('acknowledges cache invalidation through the running child', async () => { + const { child, client } = setup() + const invalidation = client.invalidate(['/tmp/deleted.jsonl']) + readyAiVaultServiceChild(child) + await vi.waitFor(() => + expect(child.sent).toContainEqual({ + type: 'invalidate', + generation: 1, + paths: ['/tmp/deleted.jsonl'] + }) + ) + child.emit('message', { type: 'invalidated', generation: 1 }) + + await expect(invalidation).resolves.toBeUndefined() + client.dispose() + }) + + it('leaves a scanning child alone when cache invalidation is slow to acknowledge', async () => { + vi.useFakeTimers() + const { child, client } = setup() + const scan = client.request({ type: 'request', operation: 'scan', options: {} }) + readyAiVaultServiceChild(child) + await Promise.resolve() + + const invalidation = client.invalidate(['/tmp/deleted.jsonl']) + await Promise.resolve() + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + + // The scan owns liveness through its own 130s deadline; killing the child + // here would abort it and burn a slot toward the restart circuit. + await expect(invalidation).resolves.toBeUndefined() + expect(child.killed).toBe(false) + + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'scan'), + operation: 'scan', + value: { result: { sessions: [], issues: [], scannedAt: '2026-08-10' }, durationMs: 1 } + }) + await expect(scan).resolves.toMatchObject({ result: { sessions: [] } }) + client.dispose() + }) + + it('replaces a child that does not acknowledge cache invalidation', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null } + }) + const invalidation = client.invalidate(['/tmp/deleted.jsonl']) + readyAiVaultServiceChild(children[0]!) + await Promise.resolve() + + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + + await expect(invalidation).rejects.toThrow('cache invalidation timed out') + expect(children[0]!.killed).toBe(true) + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + expect(children).toHaveLength(1) + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + children[1]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[1]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + + await expect(titles).resolves.toEqual({ titles: [] }) + client.dispose() + }) + + it('keeps invalidation-only children alive through acknowledgement, then retires them', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null }, + idleTimeoutMs: 100 + }) + + const first = client.request({ type: 'request', operation: 'titles', requests: [] }) + readyAiVaultServiceChild(children[0]!) + await Promise.resolve() + children[0]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[0]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await first + vi.advanceTimersByTime(100) + + const invalidation = client.invalidate(['/tmp/deleted.jsonl']) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + vi.advanceTimersByTime(100) + expect(children[1]!.sent).not.toContainEqual({ type: 'shutdown' }) + children[1]!.emit('message', { type: 'invalidated', generation: 1 }) + await invalidation + vi.advanceTimersByTime(100) + + expect(children[1]!.sent).toContainEqual({ type: 'shutdown' }) + client.dispose() + }) + + it('faults the child on malformed output without affecting later processes', async () => { + const { child, client } = setup() + const request = client.request({ type: 'request', operation: 'titles', requests: [] }) + readyAiVaultServiceChild(child) + await Promise.resolve() + + child.emit('message', { nope: true }) + + await expect(request).rejects.toThrow('malformed') + expect(child.killed).toBe(true) + client.dispose() + }) + + it('retires an idle child gracefully, then kills it after the shutdown bound', async () => { + vi.useFakeTimers() + const { child, client } = setup(100) + const request = client.request({ type: 'request', operation: 'titles', requests: [] }) + readyAiVaultServiceChild(child) + await Promise.resolve() + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await request + + vi.advanceTimersByTime(100) + expect(child.sent).toContainEqual({ type: 'shutdown' }) + vi.advanceTimersByTime(2_000) + expect(child.killed).toBe(true) + client.dispose() + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-client.ts b/src/main/ai-vault/session-scanner-service-client.ts new file mode 100644 index 00000000000..4f46dc643c5 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-client.ts @@ -0,0 +1,325 @@ +import type { ChildProcess } from 'node:child_process' +import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' +import { + AI_VAULT_SERVICE_IDLE_TIMEOUT_MS, + AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS, + AI_VAULT_SERVICE_MAX_CALLS, + AI_VAULT_SERVICE_READY_TIMEOUT_MS, + AI_VAULT_SERVICE_SCAN_TIMEOUT_MS, + AiVaultServiceIdleRetirement, + AiVaultServiceInvalidations, + armAiVaultServiceCancellationTimeout, + clearAiVaultServiceCall, + createAiVaultServiceReadyWaiter, + rejectAiVaultServiceCall, + requeueAiVaultServiceStart, + retireAiVaultServiceChild, + type AiVaultServiceClientOptions, + type AiVaultServicePendingCall, + type AiVaultServiceReadyWaiter +} from './session-scanner-service-client-state' +import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy' +import { + AI_VAULT_SERVICE_PROTOCOL_VERSION, + aiVaultServiceLane, + isAiVaultServiceChildMessage, + type AiVaultServiceChildMessage, + type AiVaultServiceInit, + type AiVaultServiceRequest, + type AiVaultServiceRequestBody, + type AiVaultServiceResultValue +} from './session-scanner-service-protocol' + +export class AiVaultScannerServiceClient { + private child: ChildProcess | null = null + private readyWaiter: AiVaultServiceReadyWaiter | null = null + private readonly active = new Map() + private readonly queue: AiVaultServicePendingCall[] = [] + private readonly invalidations = new AiVaultServiceInvalidations() + private nextId = 1 + private readonly idleRetirement = new AiVaultServiceIdleRetirement() + private readonly restartPolicy = new AiVaultServiceRestartPolicy() + private disposed = false + + constructor(private readonly options: AiVaultServiceClientOptions) {} + + request(body: AiVaultServiceRequestBody, signal?: AbortSignal): Promise { + if (this.disposed) { + return Promise.reject(new Error('AI Vault service client was disposed.')) + } + if (signal?.aborted) { + return Promise.reject(createAiVaultScanCancelledError()) + } + if (this.queue.length + this.active.size >= AI_VAULT_SERVICE_MAX_CALLS) { + return Promise.reject(new Error('AI Vault service queue is full.')) + } + const request = { ...body, id: this.nextId++ } as AiVaultServiceRequest + return new Promise((resolve, reject) => { + const call: AiVaultServicePendingCall = { + request, + lane: aiVaultServiceLane(request.operation), + signal, + resolve: resolve as (value: unknown) => void, + reject, + timer: null, + onAbort: null, + cancelled: false, + sent: false, + startRetried: false + } + if (signal) { + call.onAbort = () => this.cancel(call) + signal.addEventListener('abort', call.onAbort, { once: true }) + } + this.queue.push(call) + this.idleRetirement.clear() + this.pump() + }) + } + + async invalidate(paths: string[]): Promise { + if (paths.length === 0 || this.disposed) { + return + } + this.idleRetirement.clear() + const child = await this.ensureChild() + return this.invalidations.open( + AI_VAULT_SERVICE_READY_TIMEOUT_MS, + (generation) => this.onInvalidationDeadline(generation), + (generation) => child.send({ type: 'invalidate', generation, paths }) + ) + } + + /** + * The deadline is a startup-sized budget, but a child mid-scan can be slow to + * turn the channel around. Fork IPC ordering already guarantees the child + * applies the invalidation before any request sent after it, so a busy child + * owes nothing here — only an idle one that misses the deadline is wedged. + */ + private onInvalidationDeadline(generation: number): void { + if (this.active.size > 0) { + this.invalidations.settle(generation) + return + } + this.onFault(new Error('AI Vault service cache invalidation timed out.')) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.restartPolicy.dispose() + this.idleRetirement.clear() + const error = new Error('AI Vault service client was disposed.') + for (const call of [...this.active.values(), ...this.queue]) { + rejectAiVaultServiceCall(call, error) + } + this.active.clear() + this.queue.length = 0 + this.invalidations.rejectAll(error) + this.retireChild() + } + + private pump(): void { + if (this.restartPolicy.restartScheduled) { + return + } + for (const lane of ['cache', 'interactive'] as const) { + if (this.active.has(lane)) { + continue + } + const index = this.queue.findIndex((call) => call.lane === lane) + if (index === -1) { + continue + } + const call = this.queue.splice(index, 1)[0]! + this.active.set(lane, call) + void this.ensureChild().then( + (child) => this.sendCall(child, call), + (error: Error) => { + if (this.active.get(lane) !== call) { + return + } + this.active.delete(lane) + this.retryStartOrReject(call, error) + this.pump() + } + ) + } + this.scheduleIdleIfNeeded() + } + + private sendCall(child: ChildProcess, call: AiVaultServicePendingCall): void { + if (call.cancelled || this.active.get(call.lane) !== call) { + return + } + const timeoutMs = + call.request.operation === 'scan' + ? AI_VAULT_SERVICE_SCAN_TIMEOUT_MS + : AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS + call.timer = setTimeout(() => { + this.onFault(new Error(`AI Vault service timed out after ${timeoutMs}ms.`)) + }, timeoutMs) + call.timer.unref?.() + call.sent = true + child.send(call.request) + } + + private retryStartOrReject(call: AiVaultServicePendingCall, error: Error): void { + if ( + this.disposed || + !this.restartPolicy.restartScheduled || + !requeueAiVaultServiceStart(call, this.queue) + ) { + rejectAiVaultServiceCall(call, error) + } + } + + private ensureChild(): Promise { + if (this.child && !this.readyWaiter) { + return Promise.resolve(this.child) + } + if (this.readyWaiter) { + return this.readyWaiter.promise + } + const startError = this.restartPolicy.startError() + if (startError) { + return Promise.reject(startError) + } + let child: ChildProcess + try { + child = this.options.processFactory() + } catch (error) { + this.restartPolicy.recordFault(() => this.pump()) + return Promise.reject(error instanceof Error ? error : new Error(String(error))) + } + this.child = child + const waiter = createAiVaultServiceReadyWaiter(AI_VAULT_SERVICE_READY_TIMEOUT_MS, () => + this.onFault(new Error('AI Vault service did not become ready.')) + ) + this.readyWaiter = waiter + child.on('message', (message) => this.onMessage(message)) + child.on('error', (error) => this.onFault(error)) + child.on('disconnect', () => this.onFault(new Error('AI Vault service disconnected.'))) + child.on('exit', (code) => this.onFault(new Error(`AI Vault service exited (${code}).`))) + child.stderr?.on('data', (chunk: Buffer) => this.options.onStderr?.(String(chunk))) + child.send({ + type: 'init', + protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, + ...this.options.init + } satisfies AiVaultServiceInit) + return waiter.promise + } + + private onMessage(raw: unknown): void { + if (!isAiVaultServiceChildMessage(raw)) { + this.onFault(new Error('AI Vault service sent a malformed message.')) + return + } + const message = raw as AiVaultServiceChildMessage + if (message.type === 'ready') { + const waiter = this.readyWaiter + if (!waiter || !this.child) { + return + } + clearTimeout(waiter.timer) + this.readyWaiter = null + waiter.resolve(this.child) + return + } + if (message.type === 'invalidated') { + if (this.invalidations.settle(message.generation)) { + this.scheduleIdleIfNeeded() + } + return + } + const call = [...this.active.values()].find((entry) => entry.request.id === message.id) + if (!call) { + return + } + this.active.delete(call.lane) + clearAiVaultServiceCall(call) + if (!call.cancelled) { + if (message.type === 'error') { + call.reject(new Error(message.message)) + } else { + call.resolve((message as { value: AiVaultServiceResultValue['value'] }).value) + } + } + this.pump() + } + + private cancel(call: AiVaultServicePendingCall): void { + if (call.cancelled) { + return + } + call.cancelled = true + call.reject(createAiVaultScanCancelledError()) + const queuedIndex = this.queue.indexOf(call) + if (queuedIndex !== -1) { + this.queue.splice(queuedIndex, 1) + clearAiVaultServiceCall(call) + this.pump() + return + } + if (this.active.get(call.lane) === call) { + // Why: a call cancelled before it reached the child gets no acknowledgement, + // so waiting on one would kill a healthy service and stall the lane. + if (!call.sent) { + this.active.delete(call.lane) + clearAiVaultServiceCall(call) + this.pump() + return + } + this.child?.send({ type: 'cancel', id: call.request.id }) + armAiVaultServiceCancellationTimeout(call, () => + this.onFault(new Error('AI Vault service did not cancel within 2000ms.')) + ) + } + } + + private onFault(error: Error): void { + const child = this.child + if (!child) { + return + } + this.child = null + child.removeAllListeners() + child.kill() + if (this.readyWaiter) { + clearTimeout(this.readyWaiter.timer) + this.readyWaiter.reject(error) + this.readyWaiter = null + } + // Recorded before the pending calls are settled so retryStartOrReject can see + // whether a respawn is actually coming. + this.restartPolicy.recordFault(() => this.pump()) + const active = [...this.active.values()] + this.active.clear() + for (const call of active) { + this.retryStartOrReject(call, error) + } + this.invalidations.rejectAll(error) + } + + private scheduleIdleIfNeeded(): void { + this.idleRetirement.schedule( + this.active.size > 0 || this.queue.length > 0 || this.invalidations.size > 0 || !this.child, + this.options.idleTimeoutMs ?? AI_VAULT_SERVICE_IDLE_TIMEOUT_MS, + () => this.retireChild() + ) + } + + private retireChild(): void { + this.idleRetirement.clear() + const child = this.child + this.child = null + if (!child) { + return + } + retireAiVaultServiceChild(child) + } +} + +export type { AiVaultServiceProcessFactory } from './session-scanner-service-client-state' diff --git a/src/main/ai-vault/session-scanner-service-entry-path.test.ts b/src/main/ai-vault/session-scanner-service-entry-path.test.ts new file mode 100644 index 00000000000..7723a4f8fe7 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry-path.test.ts @@ -0,0 +1,58 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + resolveAiVaultServiceEntryPath, + resolveAiVaultServiceEntryPathWithoutApp +} from './session-scanner-service-entry-path' + +describe('resolveAiVaultServiceEntryPath', () => { + it('uses the adjacent electron-vite output when present', () => { + const outMain = join(process.cwd(), 'out', 'main') + const adjacent = join(outMain, 'session-scanner-service-entry.js') + + expect(resolveAiVaultServiceEntryPath(outMain, false, (path) => path === adjacent)).toBe( + adjacent + ) + }) + + it('uses the nested output from a project root', () => { + expect(resolveAiVaultServiceEntryPath(process.cwd(), false, () => false)).toBe( + join(process.cwd(), 'out', 'main', 'session-scanner-service-entry.js') + ) + }) + + it('uses app.asar.unpacked for packaged Electron', () => { + const appPath = join('C:', 'Orca', 'resources', 'app.asar') + + expect(resolveAiVaultServiceEntryPath(appPath, true)).toBe( + join( + 'C:', + 'Orca', + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'session-scanner-service-entry.js' + ) + ) + }) + + it('uses resourcesPath from packaged Electron-as-Node runtimes', () => { + const resourcesPath = join('Applications', 'Orca.app', 'Contents', 'Resources') + const entry = join( + resourcesPath, + 'app.asar.unpacked', + 'out', + 'main', + 'session-scanner-service-entry.js' + ) + + expect( + resolveAiVaultServiceEntryPathWithoutApp( + '/unrelated/cwd', + resourcesPath, + (path) => path === entry + ) + ).toBe(entry) + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-entry-path.ts b/src/main/ai-vault/session-scanner-service-entry-path.ts new file mode 100644 index 00000000000..5f334aa7c44 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry-path.ts @@ -0,0 +1,52 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +type ElectronAppPath = { getAppPath(): string; isPackaged: boolean } + +function loadElectronApp(): ElectronAppPath | null { + try { + return require('electron').app ?? null + } catch { + return null + } +} + +export function resolveAiVaultServiceEntryPath( + appPath: string, + isPackaged: boolean, + pathExists: (candidate: string) => boolean = existsSync +): string { + const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const adjacentEntry = join(basePath, 'session-scanner-service-entry.js') + if (!isPackaged && pathExists(adjacentEntry)) { + return adjacentEntry + } + return join(basePath, 'out', 'main', 'session-scanner-service-entry.js') +} + +export function resolveAiVaultServiceEntryPathWithoutApp( + cwd: string, + resourcesPath: string | undefined, + pathExists: (candidate: string) => boolean = existsSync +): string { + if (resourcesPath) { + const packagedEntry = join( + resourcesPath, + 'app.asar.unpacked', + 'out', + 'main', + 'session-scanner-service-entry.js' + ) + if (pathExists(packagedEntry)) { + return packagedEntry + } + } + return resolveAiVaultServiceEntryPath(cwd, false, pathExists) +} + +export function getAiVaultServiceEntryPath(): string { + const app = loadElectronApp() + return app + ? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged) + : resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath) +} diff --git a/src/main/ai-vault/session-scanner-service-entry.test.ts b/src/main/ai-vault/session-scanner-service-entry.test.ts new file mode 100644 index 00000000000..70dae0d764b --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry.test.ts @@ -0,0 +1,94 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from './session-scanner-service-protocol' + +const invalidateSessionParseCacheEntry = vi.hoisted(() => vi.fn()) +const scanAiVaultSessions = vi.hoisted(() => vi.fn()) + +// Only the invalidation hook is replaced; the title reader shares this module. +vi.mock('./session-scanner-parse-cache', async (importOriginal) => ({ + ...(await importOriginal()), + invalidateSessionParseCacheEntry +})) +vi.mock('./session-scanner', () => ({ scanAiVaultSessions })) +vi.mock('./session-parse-cache-persistence', () => ({ + flushSessionParseCachePersist: vi.fn(() => Promise.resolve()), + initSessionParseCachePersistence: vi.fn() +})) +vi.mock('./session-subagent-reader', () => ({ + listLocalAiVaultSubagentSessions: vi.fn(() => Promise.resolve({ sessions: [], issues: [] })) +})) + +const sent: { type: string; id?: number }[] = [] + +function emit(message: unknown): void { + process.emit('message', message as never, undefined as never) +} + +async function runScan(id: number): Promise { + emit({ type: 'request', id, operation: 'scan', options: {} }) + await vi.waitFor(() => expect(sent.some((message) => message.id === id)).toBe(true)) +} + +function invalidationsFor(path: string): number { + return invalidateSessionParseCacheEntry.mock.calls.filter((call) => call[0] === path).length +} + +describe('AI Vault service entry cache invalidation', () => { + beforeAll(async () => { + process.send = ((message: { type: string; id?: number }) => { + sent.push(message) + return true + }) as typeof process.send + await import('./session-scanner-service-entry') + emit({ type: 'init', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION }) + }) + + beforeEach(() => { + sent.length = 0 + invalidateSessionParseCacheEntry.mockClear() + scanAiVaultSessions.mockResolvedValue({ sessions: [], issues: [], scannedAt: '2026-08-10' }) + }) + + it('re-applies an invalidation once and then stops re-evicting the path', async () => { + emit({ type: 'invalidate', generation: 1, paths: ['/transcripts/a.jsonl'] }) + expect(invalidationsFor('/transcripts/a.jsonl')).toBe(1) + + // The request that overlapped the invalidation still gets the re-apply, so a + // read that started before it cannot leave pre-edit content cached. + await runScan(1) + expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2) + + // Every later request must not keep paying for a consumed invalidation. + await runScan(2) + expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2) + }) + + it('keeps re-applying while another request is still executing', async () => { + let releaseSlowScan: (() => void) | undefined + scanAiVaultSessions.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSlowScan = () => + resolve({ sessions: [], issues: [], scannedAt: '2026-08-10' } as never) + }) + ) + // 'subagents' runs on the interactive lane, so it overlaps the cache-lane + // scan; 'titles' would not, it shares the cache lane with scans. + emit({ type: 'request', id: 10, operation: 'scan', options: {} }) + await vi.waitFor(() => expect(releaseSlowScan).toBeDefined()) + + emit({ type: 'invalidate', generation: 2, paths: ['/transcripts/b.jsonl'] }) + emit({ type: 'request', id: 11, operation: 'subagents', request: {} }) + await vi.waitFor(() => expect(sent.some((message) => message.id === 11)).toBe(true)) + + // The scan is still reading, so the path stays armed rather than draining. + expect(invalidationsFor('/transcripts/b.jsonl')).toBe(2) + + releaseSlowScan?.() + await vi.waitFor(() => expect(sent.some((message) => message.id === 10)).toBe(true)) + expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3) + + await runScan(12) + expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3) + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-entry.ts b/src/main/ai-vault/session-scanner-service-entry.ts new file mode 100644 index 00000000000..74a5ea9c355 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry.ts @@ -0,0 +1,204 @@ +import type { AiVaultSessionTitle } from '../../shared/ai-vault-session-title' +import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read' +import { + flushSessionParseCachePersist, + initSessionParseCachePersistence +} from './session-parse-cache-persistence' +import { scanAiVaultSessions } from './session-scanner' +import { invalidateSessionParseCacheEntry } from './session-scanner-parse-cache' +import { + AI_VAULT_SERVICE_PROTOCOL_VERSION, + aiVaultServiceLane, + cacheServiceTitle, + isAiVaultServiceRequest, + type AiVaultServiceChildMessage, + type AiVaultServiceParentMessage, + type AiVaultServiceRequest, + type AiVaultServiceResultValue +} from './session-scanner-service-protocol' +import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader' +import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths' +import { listLocalAiVaultSubagentSessions } from './session-subagent-reader' + +if (!process.send) { + throw new Error('AI Vault service requires a parent IPC channel.') +} + +const controllers = new Map() +const cancelled = new Set() +const pending = new Set() +const titleIndex = new Map() +const invalidatedPaths = new Set() +let initialized = false +let shuttingDown = false +let cacheLane = Promise.resolve() +let interactiveLane = Promise.resolve() + +function send(message: AiVaultServiceChildMessage): void { + process.send?.(message) +} + +function titleKey(request: { agent: string; sessionId: string }): string { + return `${request.agent}\0${request.sessionId}` +} + +async function executeRequest(request: AiVaultServiceRequest): Promise { + const controller = new AbortController() + controllers.set(request.id, controller) + try { + if (cancelled.delete(request.id)) { + controller.abort() + } + if (request.operation === 'titles') { + const requests = await resolveHostReadableAiVaultTitleRequests( + request.requests, + controller.signal + ) + return { + operation: 'titles', + value: await readAiVaultSessionTitlesFromFiles(requests, { + signal: controller.signal, + cache: { + get: (entry) => titleIndex.get(titleKey(entry)) ?? null, + set: (title) => cacheServiceTitle(titleIndex, title) + } + }) + } + } + if (request.operation === 'subagents') { + return { + operation: 'subagents', + value: await listLocalAiVaultSubagentSessions(request.request) + } + } + if (request.operation === 'firstPrompt') { + return { + operation: 'firstPrompt', + value: await readAiVaultFirstUserPrompt(request.request) + } + } + const startedAt = performance.now() + const result = await scanAiVaultSessions({ ...request.options, signal: controller.signal }) + for (const session of result.sessions) { + if ((session.agent === 'claude' || session.agent === 'codex') && session.title.trim()) { + cacheServiceTitle(titleIndex, { + agent: session.agent, + sessionId: session.sessionId, + title: session.title.trim() + }) + } + } + return { + operation: 'scan', + value: { result, durationMs: performance.now() - startedAt } + } + } finally { + controllers.delete(request.id) + cancelled.delete(request.id) + for (const path of invalidatedPaths) { + invalidateSessionParseCacheEntry(path) + } + // Why: the re-apply only protects reads that overlapped the invalidation. + // Once nothing else is executing it has done its job, and holding the paths + // would re-evict them on every later request for the life of the process. + if (controllers.size === 0) { + invalidatedPaths.clear() + } + } +} + +async function handleRequest(request: AiVaultServiceRequest): Promise { + try { + const value = await executeRequest(request) + send({ type: 'result', id: request.id, ...value }) + } catch (error) { + send({ + type: 'error', + id: request.id, + message: error instanceof Error ? error.message : String(error), + retryable: true + }) + } finally { + pending.delete(request.id) + } +} + +function queueRequest(request: AiVaultServiceRequest): void { + if (pending.size >= 16) { + send({ + type: 'error', + id: request.id, + message: 'AI Vault service queue is full.', + retryable: true + }) + return + } + pending.add(request.id) + if (aiVaultServiceLane(request.operation) === 'interactive') { + interactiveLane = interactiveLane.then(() => handleRequest(request)) + return + } + cacheLane = cacheLane.then(() => handleRequest(request)) +} + +async function shutdown(): Promise { + if (shuttingDown) { + return + } + shuttingDown = true + for (const controller of controllers.values()) { + controller.abort() + } + await Promise.allSettled([cacheLane, interactiveLane]) + await flushSessionParseCachePersist() + process.disconnect?.() +} + +process.on('message', (raw: AiVaultServiceParentMessage) => { + if (raw?.type === 'init') { + if (initialized || raw.protocol !== AI_VAULT_SERVICE_PROTOCOL_VERSION) { + void shutdown() + return + } + initialized = true + if (raw.sessionParseCache) { + initSessionParseCachePersistence(raw.sessionParseCache) + } + send({ type: 'ready', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, pid: process.pid }) + return + } + if (!initialized || shuttingDown) { + return + } + if (raw?.type === 'cancel') { + cancelled.add(raw.id) + controllers.get(raw.id)?.abort() + return + } + if (raw?.type === 'invalidate') { + for (const path of raw.paths) { + invalidatedPaths.delete(path) + invalidatedPaths.add(path) + invalidateSessionParseCacheEntry(path) + } + while (invalidatedPaths.size > 4_096) { + const oldest = invalidatedPaths.values().next().value + if (oldest === undefined) { + break + } + invalidatedPaths.delete(oldest) + } + titleIndex.clear() + send({ type: 'invalidated', generation: raw.generation }) + return + } + if (raw?.type === 'shutdown') { + void shutdown() + return + } + if (isAiVaultServiceRequest(raw)) { + queueRequest(raw) + } +}) + +process.on('disconnect', () => void shutdown()) diff --git a/src/main/ai-vault/session-scanner-service-env.test.ts b/src/main/ai-vault/session-scanner-service-env.test.ts new file mode 100644 index 00000000000..5d06c0508e8 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-env.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { buildAiVaultServiceEnv, buildRelayAiVaultServiceEnv } from './session-scanner-service-env' + +describe('buildAiVaultServiceEnv', () => { + it('drops Node flag injection variables so the forked heap cap and loader stand', () => { + const env = buildAiVaultServiceEnv( + { + NODE_OPTIONS: '--max-old-space-size=8192 --require=/tmp/evil.js', + NODE_REPL_EXTERNAL_MODULE: '/tmp/evil.js', + NODE_PATH: '/tmp/evil-modules', + PATH: '/usr/bin' + }, + 'linux' + ) + + expect(env.NODE_OPTIONS).toBeUndefined() + expect(env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined() + expect(env.NODE_PATH).toBeUndefined() + expect(env.PATH).toBe('/usr/bin') + }) + + it('drops an unrecognised variable rather than carrying a shell-exported secret', () => { + const env = buildAiVaultServiceEnv( + { AWS_SECRET_ACCESS_KEY: 'shhh', HOME: '/home/dev' }, + 'linux' + ) + + expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined() + expect(env.HOME).toBe('/home/dev') + }) + + it('keeps the agent-home variables the scanner discovers sessions through', () => { + const env = buildAiVaultServiceEnv( + { + CODEX_HOME: '/home/dev/.codex', + COPILOT_HOME: '/home/dev/.copilot', + DEVIN_HOME: '/home/dev/.devin', + GROK_HOME: '/home/dev/.grok', + KIMI_CODE_HOME: '/home/dev/.kimi-code', + OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions', + OPENCLAW_STATE_DIR: '/home/dev/.openclaw', + PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions', + PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent', + PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions', + PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions' + }, + 'linux' + ) + + expect(env).toEqual({ + CODEX_HOME: '/home/dev/.codex', + COPILOT_HOME: '/home/dev/.copilot', + DEVIN_HOME: '/home/dev/.devin', + GROK_HOME: '/home/dev/.grok', + KIMI_CODE_HOME: '/home/dev/.kimi-code', + OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions', + OPENCLAW_STATE_DIR: '/home/dev/.openclaw', + PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions', + PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent', + PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions', + PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions', + ELECTRON_RUN_AS_NODE: '1' + }) + }) + + it('keeps the OpenCode data-directory and database overrides', () => { + const env = buildAiVaultServiceEnv( + { XDG_DATA_HOME: '/home/dev/data', OPENCODE_DB: 'opencode-alt.db' }, + 'linux' + ) + + expect(env.XDG_DATA_HOME).toBe('/home/dev/data') + expect(env.OPENCODE_DB).toBe('opencode-alt.db') + }) + + it('runs the forked Electron binary as plain Node', () => { + expect(buildAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBe('1') + }) + + it('ignores a POSIX variable that only matches an allowed name by case', () => { + expect(buildAiVaultServiceEnv({ codex_home: '/tmp/spoof' }, 'linux')).toEqual({ + ELECTRON_RUN_AS_NODE: '1' + }) + }) + + it('resolves a lowercased Windows variable the OS would still honour', () => { + const env = buildAiVaultServiceEnv({ codex_home: 'C:\\codex', Path: 'C:\\bin' }, 'win32') + + expect(env.CODEX_HOME).toBe('C:\\codex') + expect(env.PATH).toBe('C:\\bin') + }) + + it('spells SystemRoot the way Windows Node expects', () => { + expect(buildAiVaultServiceEnv({ SystemRoot: 'C:\\Windows' }, 'win32').SystemRoot).toBe( + 'C:\\Windows' + ) + }) + + it('does not mutate the caller environment', () => { + const baseEnv = { NODE_OPTIONS: '--inspect', HOME: '/home/dev' } + buildAiVaultServiceEnv(baseEnv, 'linux') + + expect(baseEnv.NODE_OPTIONS).toBe('--inspect') + }) +}) + +describe('buildRelayAiVaultServiceEnv', () => { + it('drops Node flag injection variables', () => { + const env = buildRelayAiVaultServiceEnv( + { NODE_OPTIONS: '--max-old-space-size=8192', NODE_PATH: '/tmp/evil', HOME: '/home/ada' }, + 'linux' + ) + + expect(env.NODE_OPTIONS).toBeUndefined() + expect(env.NODE_PATH).toBeUndefined() + expect(env.HOME).toBe('/home/ada') + }) + + // The sidecar takes remoteHome and hostPlatform from its init message, so an + // agent-home override on the remote host is not part of how it finds roots. + it('withholds the agent-home variables the desktop child needs', () => { + const env = buildRelayAiVaultServiceEnv( + { CODEX_HOME: '/remote/.codex', PATH: '/usr/bin' }, + 'linux' + ) + + expect(env.CODEX_HOME).toBeUndefined() + expect(env.PATH).toBe('/usr/bin') + }) + + it('stays plain Node rather than an Electron child', () => { + expect(buildRelayAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBeUndefined() + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts new file mode 100644 index 00000000000..1bfb75344ca --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -0,0 +1,98 @@ +/** + * Environments for the AI Vault service children. + * + * Deliberately allowlists, never `...process.env` (the plugin worker takes the + * same stance): the children are forked with a heap cap and no loader, and an + * ambient NODE_OPTIONS would raise the cap or `--require` code straight into + * them. Shell-exported secrets have no business in a transcript reader either. + */ + +// What Node and libuv need to start and resolve a home, temp dir and locale. +const RUNTIME_ENV_ALLOWLIST = [ + 'PATH', + 'HOME', + 'USERPROFILE', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TZ', + 'TMPDIR', + 'TEMP', + 'TMP', + // Why: Windows Node/libuv need these to resolve DLLs and the machine root. + 'SYSTEMROOT', + 'SYSTEMDRIVE', + 'WINDIR', + 'COMSPEC', + 'PATHEXT', + 'PROCESSOR_ARCHITECTURE', + 'NUMBER_OF_PROCESSORS' +] as const + +// Why: the desktop child resolves agent roots from its own environment, so +// dropping one hides every session of a user who relocated that agent's home. +const AGENT_ROOT_ENV_ALLOWLIST = [ + 'CODEX_HOME', + 'COPILOT_HOME', + 'DEVIN_HOME', + 'GROK_HOME', + 'KIMI_CODE_HOME', + 'OMP_CODING_AGENT_DIR', + 'OPENCLAW_STATE_DIR', + 'OPENCODE_DB', + 'PI_CODING_AGENT_DIR', + 'PRIME_AGENT_CODING_AGENT_DIR', + 'PRIME_AGENT_CODING_AGENT_SESSION_DIR', + 'PRIME_AGENT_SESSION_DIR', + // Why: OpenCode has no home variable — its store hangs off the XDG data dir, + // so this one is an agent root here rather than generic runtime state. + 'XDG_DATA_HOME' +] as const + +function pickAllowedEnv( + keys: readonly string[], + baseEnv: NodeJS.ProcessEnv, + platform: NodeJS.Platform +): NodeJS.ProcessEnv { + const windowsLookup = new Map() + if (platform === 'win32') { + // Why: Windows resolves env names case-insensitively, so a lowercased + // `codex_home` still reaches the child; folding on POSIX instead would + // promote an attacker-set `path` over the real one. + for (const [key, value] of Object.entries(baseEnv)) { + if (typeof value === 'string') { + windowsLookup.set(key.toUpperCase(), value) + } + } + } + const env: NodeJS.ProcessEnv = {} + for (const key of keys) { + const value = platform === 'win32' ? windowsLookup.get(key) : baseEnv[key] + if (value !== undefined) { + env[key === 'SYSTEMROOT' ? 'SystemRoot' : key] = value + } + } + return env +} + +/** Desktop: forked from the Electron binary, so it also needs run-as-node. */ +export function buildAiVaultServiceEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + const env = pickAllowedEnv( + [...RUNTIME_ENV_ALLOWLIST, ...AGENT_ROOT_ENV_ALLOWLIST], + baseEnv, + platform + ) + env.ELECTRON_RUN_AS_NODE = '1' + return env +} + +/** Relay: the sidecar takes every root from its init message, not the environment. */ +export function buildRelayAiVaultServiceEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + return pickAllowedEnv(RUNTIME_ENV_ALLOWLIST, baseEnv, platform) +} diff --git a/src/main/ai-vault/session-scanner-service-priority.ts b/src/main/ai-vault/session-scanner-service-priority.ts new file mode 100644 index 00000000000..e0a22b340e5 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-priority.ts @@ -0,0 +1,13 @@ +import { constants, setPriority } from 'node:os' + +export function lowerAiVaultServicePriority(pid: number | undefined): boolean { + if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0) { + return false + } + try { + setPriority(pid, constants.priority.PRIORITY_BELOW_NORMAL) + return true + } catch { + return false + } +} diff --git a/src/main/ai-vault/session-scanner-service-protocol.ts b/src/main/ai-vault/session-scanner-service-protocol.ts new file mode 100644 index 00000000000..f2842751eb1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-protocol.ts @@ -0,0 +1,118 @@ +import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import type { + AiVaultSessionTitle, + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../../shared/ai-vault-session-title' +import type { ReadAiVaultFirstUserPromptArgs } from './session-first-user-prompt-read' +import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence' +import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' + +export const AI_VAULT_SERVICE_PROTOCOL_VERSION = 1 + +export type AiVaultServiceLane = 'cache' | 'interactive' +export type AiVaultServiceOperation = 'scan' | 'titles' | 'subagents' | 'firstPrompt' + +export type AiVaultServiceSubagentRequest = { + agent: 'claude' | 'omp' + parentFilePath: string +} + +export type AiVaultServiceInit = { + type: 'init' + protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION + sessionParseCache: SessionParseCachePersistenceOptions | null +} + +export type AiVaultServiceRequestBody = + | { type: 'request'; operation: 'scan'; options: AiVaultWorkerScanOptions } + | { + type: 'request' + operation: 'titles' + requests: AiVaultSessionTitleRequest[] + } + | { + type: 'request' + operation: 'subagents' + request: AiVaultServiceSubagentRequest + } + | { + type: 'request' + operation: 'firstPrompt' + request: ReadAiVaultFirstUserPromptArgs + } + +export type AiVaultServiceRequest = AiVaultServiceRequestBody & { id: number } + +export type AiVaultServiceParentMessage = + | AiVaultServiceInit + | AiVaultServiceRequest + | { type: 'cancel'; id: number } + | { type: 'invalidate'; generation: number; paths: string[] } + | { type: 'shutdown' } + +export type AiVaultServiceResultValue = + | { operation: 'scan'; value: { result: AiVaultListResult; durationMs: number } } + | { operation: 'titles'; value: AiVaultSessionTitlesResult } + | { operation: 'subagents'; value: AiVaultSubagentListResult } + | { operation: 'firstPrompt'; value: { prompt: string | null } } + +export type AiVaultServiceChildMessage = + | { + type: 'ready' + protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION + pid: number + } + | ({ type: 'result'; id: number } & AiVaultServiceResultValue) + | { type: 'error'; id: number; message: string; retryable: boolean } + | { type: 'invalidated'; generation: number } + +export function aiVaultServiceLane(operation: AiVaultServiceOperation): AiVaultServiceLane { + return operation === 'subagents' || operation === 'firstPrompt' ? 'interactive' : 'cache' +} + +export function isAiVaultServiceRequest(value: unknown): value is AiVaultServiceRequest { + if (!value || typeof value !== 'object') { + return false + } + const message = value as Record + return ( + message.type === 'request' && + Number.isSafeInteger(message.id) && + (message.operation === 'scan' || + message.operation === 'titles' || + message.operation === 'subagents' || + message.operation === 'firstPrompt') + ) +} + +export function isAiVaultServiceChildMessage(value: unknown): value is AiVaultServiceChildMessage { + if (!value || typeof value !== 'object') { + return false + } + const message = value as Record + if (message.type === 'ready') { + return message.protocol === AI_VAULT_SERVICE_PROTOCOL_VERSION && Number.isInteger(message.pid) + } + if (message.type === 'invalidated') { + return Number.isSafeInteger(message.generation) + } + return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id) +} + +export function cacheServiceTitle( + titleIndex: Map, + title: AiVaultSessionTitle, + maxEntries = 4_096 +): void { + const key = `${title.agent}\0${title.sessionId}` + titleIndex.delete(key) + titleIndex.set(key, title) + while (titleIndex.size > maxEntries) { + const oldest = titleIndex.keys().next().value + if (oldest === undefined) { + break + } + titleIndex.delete(oldest) + } +} diff --git a/src/main/ai-vault/session-scanner-service-restart-policy.test.ts b/src/main/ai-vault/session-scanner-service-restart-policy.test.ts new file mode 100644 index 00000000000..98dfadf91e3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-restart-policy.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy' + +afterEach(() => vi.useRealTimers()) + +describe('AiVaultServiceRestartPolicy', () => { + it('keeps one pending restart when faults overlap', () => { + vi.useFakeTimers() + const policy = new AiVaultServiceRestartPolicy() + const restart = vi.fn() + + policy.recordFault(restart) + policy.recordFault(restart) + vi.advanceTimersByTime(10_000) + + expect(restart).toHaveBeenCalledTimes(1) + }) + + it('cancels the pending restart on dispose after overlapping faults', () => { + vi.useFakeTimers() + const policy = new AiVaultServiceRestartPolicy() + const restart = vi.fn() + + policy.recordFault(restart) + policy.recordFault(restart) + policy.dispose() + vi.advanceTimersByTime(10_000) + + expect(restart).not.toHaveBeenCalled() + expect(policy.restartScheduled).toBe(false) + }) + + it('opens the circuit after three faults inside the window', () => { + let now = 0 + const policy = new AiVaultServiceRestartPolicy(() => now) + + expect(policy.startError()).toBeNull() + for (let index = 0; index < 3; index += 1) { + policy.recordFault(() => undefined) + now += 1_000 + } + + expect(policy.startError()?.message).toContain('circuit is open') + now += 60_000 + expect(policy.startError()).toBeNull() + policy.dispose() + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-restart-policy.ts b/src/main/ai-vault/session-scanner-service-restart-policy.ts new file mode 100644 index 00000000000..605e6c9731b --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-restart-policy.ts @@ -0,0 +1,49 @@ +const AI_VAULT_SERVICE_FAULT_WINDOW_MS = 60_000 +const AI_VAULT_SERVICE_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const + +export class AiVaultServiceRestartPolicy { + private faults: number[] = [] + private circuitUntil = 0 + private timer: NodeJS.Timeout | null = null + + constructor(private readonly now: () => number = Date.now) {} + + get restartScheduled(): boolean { + return this.timer !== null + } + + startError(): Error | null { + return this.now() < this.circuitUntil + ? new Error('AI Vault service restart circuit is open.') + : null + } + + recordFault(restart: () => void): void { + const now = this.now() + this.faults = [ + ...this.faults.filter((time) => now - time < AI_VAULT_SERVICE_FAULT_WINDOW_MS), + now + ] + if (this.faults.length >= 3) { + this.circuitUntil = now + AI_VAULT_SERVICE_FAULT_WINDOW_MS + } + const delay = AI_VAULT_SERVICE_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)] + // Why: a second fault before the pending restart fires would otherwise strand + // the old timer, leaving a restart that dispose() can no longer cancel. + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = setTimeout(() => { + this.timer = null + restart() + }, delay) + this.timer.unref?.() + } + + dispose(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } +} diff --git a/src/main/ai-vault/session-scanner-service-spawn.test.ts b/src/main/ai-vault/session-scanner-service-spawn.test.ts new file mode 100644 index 00000000000..5c64a13ecb4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-spawn.test.ts @@ -0,0 +1,47 @@ +import type { ChildProcess } from 'node:child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const forkMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ existsSync: () => true })) + +const { spawnAiVaultServiceProcess } = await import('./session-scanner-service-spawn') + +function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } { + return forkMock.mock.calls.at(-1)?.[2] ?? {} +} + +describe('spawnAiVaultServiceProcess', () => { + beforeEach(() => { + forkMock.mockReset() + forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess) + }) + + it('keeps NODE_OPTIONS out of the child so the heap cap and loader stand', () => { + vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js') + spawnAiVaultServiceProcess() + const options = forkOptions() + + // Asserted first: omitting `env` entirely inherits everything, and would + // leave the NODE_OPTIONS assertion below passing for the wrong reason. + expect(options.env).toBeDefined() + expect(options.env?.NODE_OPTIONS).toBeUndefined() + expect(options.execArgv).toEqual(['--max-old-space-size=384']) + vi.unstubAllEnvs() + }) + + it('still passes through a relocated agent home', () => { + vi.stubEnv('CODEX_HOME', '/home/dev/elsewhere/.codex') + spawnAiVaultServiceProcess() + + expect(forkOptions().env?.CODEX_HOME).toBe('/home/dev/elsewhere/.codex') + vi.unstubAllEnvs() + }) + + it('runs the forked Electron binary as plain Node', () => { + spawnAiVaultServiceProcess() + + expect(forkOptions().env?.ELECTRON_RUN_AS_NODE).toBe('1') + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-spawn.ts b/src/main/ai-vault/session-scanner-service-spawn.ts new file mode 100644 index 00000000000..9fd20cfe57e --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-spawn.ts @@ -0,0 +1,91 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../../shared/ai-vault-session-title' +import { withSpan } from '../observability/tracer' +import type { + ReadAiVaultFirstUserPromptArgs, + ReadAiVaultFirstUserPromptResult +} from './session-first-user-prompt-read' +import { getSessionParseCachePersistenceOptions } from './session-parse-cache-persistence' +import { buildAiVaultServiceEnv } from './session-scanner-service-env' +import { AiVaultScannerServiceClient } from './session-scanner-service-client' +import { getAiVaultServiceEntryPath } from './session-scanner-service-entry-path' +import { lowerAiVaultServicePriority } from './session-scanner-service-priority' +import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol' +import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' + +export function spawnAiVaultServiceProcess(): ChildProcess { + const entryPath = getAiVaultServiceEntryPath() + if (!existsSync(entryPath)) { + throw new Error(`AI Vault service entry not found: ${entryPath}`) + } + const child = fork(entryPath, [], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + execArgv: ['--max-old-space-size=384'], + env: buildAiVaultServiceEnv(), + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + lowerAiVaultServicePriority(child.pid) + child.unref() + return child +} + +let sharedClient: AiVaultScannerServiceClient | null = null + +function getSharedClient(): AiVaultScannerServiceClient { + sharedClient ??= new AiVaultScannerServiceClient({ + processFactory: spawnAiVaultServiceProcess, + init: { sessionParseCache: getSessionParseCachePersistenceOptions() }, + onStderr: (text) => console.error('[ai-vault-service]', text.trimEnd()) + }) + return sharedClient +} + +export function scanAiVaultSessionsInService( + options: AiVaultWorkerScanOptions, + signal?: AbortSignal +): Promise { + return withSpan('aiVault.scan.service', async (span) => { + const value = await getSharedClient().request<{ + result: AiVaultListResult + durationMs: number + }>({ type: 'request', operation: 'scan', options }, signal) + span.setAttribute('serviceDurationMs', value.durationMs) + span.setAttribute('sessions', value.result.sessions.length) + return value.result + }) +} + +export function resolveAiVaultSessionTitlesInService( + requests: AiVaultSessionTitleRequest[], + signal?: AbortSignal +): Promise { + return getSharedClient().request({ type: 'request', operation: 'titles', requests }, signal) +} + +export function listAiVaultSubagentSessionsInService( + request: AiVaultServiceSubagentRequest, + signal?: AbortSignal +): Promise { + return getSharedClient().request({ type: 'request', operation: 'subagents', request }, signal) +} + +export function readAiVaultFirstUserPromptInService( + request: ReadAiVaultFirstUserPromptArgs, + signal?: AbortSignal +): Promise { + return getSharedClient().request({ type: 'request', operation: 'firstPrompt', request }, signal) +} + +export function invalidateAiVaultServiceCache(paths: string[]): Promise { + return sharedClient?.invalidate(paths) ?? Promise.resolve() +} + +export function resetAiVaultScannerServiceForTests(): void { + sharedClient?.dispose() + sharedClient = null +} diff --git a/src/main/ai-vault/session-scanner-service-test-child.ts b/src/main/ai-vault/session-scanner-service-test-child.ts new file mode 100644 index 00000000000..3fa1a84ec02 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-test-child.ts @@ -0,0 +1,52 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' + +export class AiVaultServiceTestChild extends EventEmitter { + readonly sent: unknown[] = [] + readonly stderr = new EventEmitter() + readonly pid: number + killed = false + unrefed = false + + constructor(pid = 12_345) { + super() + this.pid = pid + } + + send(message: unknown, callback?: (error: Error | null) => void): boolean { + this.sent.push(message) + callback?.(null) + return true + } + + kill(): boolean { + this.killed = true + return true + } + + unref(): this { + this.unrefed = true + return this + } + + asChildProcess(): ChildProcess { + return this as unknown as ChildProcess + } +} + +export function readyAiVaultServiceChild(child: AiVaultServiceTestChild): void { + child.emit('message', { type: 'ready', protocol: 1, pid: child.pid }) +} + +export function aiVaultServiceRequestId(child: AiVaultServiceTestChild, operation: string): number { + const request = child.sent.find( + (message) => + typeof message === 'object' && + message !== null && + (message as { operation?: string }).operation === operation + ) as { id?: number } | undefined + if (request?.id === undefined) { + throw new Error(`No ${operation} request was sent.`) + } + return request.id +} diff --git a/src/main/ai-vault/session-scanner-worker-client.ts b/src/main/ai-vault/session-scanner-worker-client.ts index 6b780c5086d..70e75c4f982 100644 --- a/src/main/ai-vault/session-scanner-worker-client.ts +++ b/src/main/ai-vault/session-scanner-worker-client.ts @@ -176,7 +176,7 @@ export class AiVaultScannerWorkerClient { return } const index = this.queue.indexOf(call) - if (index >= 0) { + if (index !== -1) { this.queue.splice(index, 1) this.clearCall(call) } diff --git a/src/main/ai-vault/session-subagent-reader.ts b/src/main/ai-vault/session-subagent-reader.ts new file mode 100644 index 00000000000..5921419c54a --- /dev/null +++ b/src/main/ai-vault/session-subagent-reader.ts @@ -0,0 +1,12 @@ +import type { AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import { listClaudeSubagentSessions } from './session-scanner-claude-subagents' +import { listOmpSubagentSessions } from './session-scanner-omp-subagent-listing' +import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol' + +export function listLocalAiVaultSubagentSessions( + request: AiVaultServiceSubagentRequest +): Promise { + return request.agent === 'claude' + ? listClaudeSubagentSessions({ parentFilePath: request.parentFilePath }) + : listOmpSubagentSessions({ parentFilePath: request.parentFilePath }) +} diff --git a/src/main/ai-vault/session-title-request-paths.ts b/src/main/ai-vault/session-title-request-paths.ts index 86e78ebc397..5e25e512d92 100644 --- a/src/main/ai-vault/session-title-request-paths.ts +++ b/src/main/ai-vault/session-title-request-paths.ts @@ -1,5 +1,6 @@ import type { AiVaultSessionTitleRequest } from '../../shared/ai-vault-session-title' import { toHostReadableTranscriptPath } from '../native-chat/host-readable-transcript-path' +import { wslTranscriptFsRefusal } from '../native-chat/wsl-transcript-fs-gate' export function resolveHostReadableAiVaultTitleRequests( requests: AiVaultSessionTitleRequest[], @@ -10,7 +11,15 @@ export function resolveHostReadableAiVaultTitleRequests( if (!request.transcriptPath || signal?.aborted) { return request } - const transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath) + let transcriptPath: string | null + try { + transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath) + } catch (error) { + // Why: one stalled distro's path must not fail the whole titles batch; + // degrade to the id-only shape like any unreadable path. + void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal + transcriptPath = null + } return transcriptPath && !signal?.aborted ? { ...request, transcriptPath } : { agent: request.agent, sessionId: request.sessionId } diff --git a/src/main/ai-vault/session-title-resolver.ts b/src/main/ai-vault/session-title-resolver.ts index db1881f5ee3..80518000e09 100644 --- a/src/main/ai-vault/session-title-resolver.ts +++ b/src/main/ai-vault/session-title-resolver.ts @@ -5,7 +5,7 @@ import { type AiVaultSessionTitleRequest, type AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' -import { resolveAiVaultSessionTitlesInWorker } from './session-scanner-worker-spawn' +import { resolveAiVaultSessionTitlesInBackground } from './session-scanner-background' const TRANSCRIPT_PATH_MAX_LENGTH = 32_768 @@ -42,5 +42,5 @@ export async function resolveLocalAiVaultSessionTitles( deduped.set(key, normalized) } } - return resolveAiVaultSessionTitlesInWorker([...deduped.values()], signal) + return resolveAiVaultSessionTitlesInBackground([...deduped.values()], signal) } diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index e83160a3549..535eb3a3682 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -25,6 +25,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, buildWindowsHookEnvironmentGuardLines, @@ -296,6 +297,18 @@ function removeInstalledConfig(config: HooksConfig): void { } export class AntigravityHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + if (process.platform === 'win32') { + for (const event of ANTIGRAVITY_EVENTS) { + await refreshManagedScriptIfPresent( + getWindowsWrapperScriptPath(event), + getWindowsWrapperScript(event.eventName) + ) + } + } + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/artifacts/artifact-cloud-recovery.test.ts b/src/main/artifacts/artifact-cloud-recovery.test.ts new file mode 100644 index 00000000000..f57b53c2b02 --- /dev/null +++ b/src/main/artifacts/artifact-cloud-recovery.test.ts @@ -0,0 +1,346 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: false }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +import { ArtifactCloudService } from './artifact-cloud-service' + +const createdPaths: string[] = [] +const apiUrl = 'http://localhost:3000' +const writeRequest = { + sourceKey: '/repo/report.html', + content: '

Recovery

', + contentType: 'text/html' as const, + fileName: 'report.html', + apiUrl, + authToken: 'token-a' +} + +afterEach(async () => { + vi.unstubAllGlobals() + await Promise.all( + createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe('ArtifactCloudService committed response loss recovery', () => { + it('reconciles one remotely revocable artifact after a committed create loses its response', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost') + expect(server.createMutations).toBe(1) + expect(server.artifactSlugs()).toEqual(['artifact-1']) + await expect(publishedLink(userDataPath)).resolves.toBeNull() + + await expect( + service(userDataPath).publish({ ...writeRequest, content: '

Changed after loss

' }) + ).resolves.toMatchObject({ + status: 'ok', + value: { item: { artifact: { slug: 'artifact-1' } } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactSlugs()).toEqual(['artifact-1']) + expect(server.artifactContent('artifact-1')).toBe('

Changed after loss

') + await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1') + }) + + it('replays the exact create when content is unchanged after response loss', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost') + await expect(service(userDataPath).publish(writeRequest)).resolves.toMatchObject({ + status: 'ok', + value: { item: { artifact: { slug: 'artifact-1' } } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactContent('artifact-1')).toBe(writeRequest.content) + }) + + it('updates a recovered share when its content changed after response loss', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost') + await expect( + service(userDataPath).share({ ...writeRequest, content: '

Changed share

' }) + ).resolves.toMatchObject({ + status: 'ok', + value: { artifact: { slug: 'artifact-1' } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactContent('artifact-1')).toBe('

Changed share

') + }) + + it('retains recovery until a changed-content update succeeds', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost') + server.rejectNextUpdateStatus = 503 + const changed = { ...writeRequest, content: '

Changed after update failure

' } + await expect(service(userDataPath).share(changed)).rejects.toMatchObject({ statusCode: 503 }) + await expect(service(userDataPath).share(changed)).resolves.toMatchObject({ + status: 'ok', + value: { artifact: { slug: 'artifact-1' } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactSlugs()).toEqual(['artifact-1']) + expect(server.artifactContent('artifact-1')).toBe(changed.content) + }) + + it('clears the durable mapping when a committed delete retry returns 404', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + vi.stubGlobal('fetch', server.fetch) + await service(userDataPath).publish(writeRequest) + + server.loseNextDeleteResponse = true + await expect( + service(userDataPath).unshare({ + sourceKey: writeRequest.sourceKey, + apiUrl, + authToken: 'token-a' + }) + ).rejects.toThrow('response lost') + expect(server.deleteMutations).toBe(1) + expect(server.artifactSlugs()).toEqual([]) + await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1') + + await expect( + service(userDataPath).unshare({ + sourceKey: writeRequest.sourceKey, + apiUrl, + authToken: 'token-a' + }) + ).resolves.toEqual({ status: 'ok', value: undefined }) + expect(server.deleteMutations).toBe(1) + expect(server.artifactSlugs()).toEqual([]) + await expect(publishedLink(userDataPath)).resolves.toBeNull() + }) + + it('keeps the durable mapping when a delete receives an unrelated 404', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + vi.stubGlobal('fetch', server.fetch) + await service(userDataPath).publish(writeRequest) + + server.rejectNextDeleteCode = 'not_found' + await expect( + service(userDataPath).unshare({ + sourceKey: writeRequest.sourceKey, + apiUrl, + authToken: 'token-a' + }) + ).rejects.toMatchObject({ statusCode: 404, errorCode: 'not_found' }) + expect(server.deleteMutations).toBe(0) + expect(server.artifactSlugs()).toEqual(['artifact-1']) + await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1') + }) + + it('drops an uncommitted validation failure so corrected content can create', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.rejectNextCreateStatus = 422 + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).publish(writeRequest)).rejects.toMatchObject({ + statusCode: 422 + }) + await expect( + service(userDataPath).publish({ ...writeRequest, content: '

Corrected

' }) + ).resolves.toMatchObject({ + status: 'ok', + value: { item: { artifact: { slug: 'artifact-1' } } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactContent('artifact-1')).toBe('

Corrected

') + }) + + it('keeps a replay intent when validation changes after the original commit', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost') + server.rejectNextCreateStatus = 422 + const changed = { ...writeRequest, content: '

Changed after validation

' } + await expect(service(userDataPath).publish(changed)).rejects.toMatchObject({ statusCode: 422 }) + await expect(service(userDataPath).publish(changed)).resolves.toMatchObject({ + status: 'ok', + value: { item: { artifact: { slug: 'artifact-1' } } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactContent('artifact-1')).toBe(changed.content) + }) +}) + +class ArtifactFaultServer { + readonly fetch = vi.fn(this.handle.bind(this)) + createMutations = 0 + deleteMutations = 0 + loseNextCreateResponse = false + loseNextDeleteResponse = false + rejectNextCreateStatus: number | null = null + rejectNextDeleteCode: string | null = null + rejectNextUpdateStatus: number | null = null + private readonly artifacts = new Map() + private readonly createsByKey = new Map() + + artifactSlugs(): string[] { + return [...this.artifacts.keys()].sort() + } + + artifactContent(slug: string): string | undefined { + const body = this.artifacts.get(slug) + return body ? (JSON.parse(body) as { content?: string }).content : undefined + } + + private async handle(input: string | URL | Request, init?: RequestInit): Promise { + const method = init?.method ?? 'GET' + if (method === 'POST') { + return this.create(init) + } + if (method === 'PUT') { + return this.update(String(input), init) + } + if (method === 'DELETE') { + return this.delete(String(input)) + } + throw new Error(`Unexpected artifact request: ${method} ${String(input)}`) + } + + private create(init?: RequestInit): Response { + if (this.rejectNextCreateStatus !== null) { + const status = this.rejectNextCreateStatus + this.rejectNextCreateStatus = null + return jsonResponse({ code: 'artifact_validation_failed' }, status) + } + const key = new Headers(init?.headers).get('idempotency-key') + if (new Headers(init?.headers).get('authorization') !== 'Bearer token-a') { + throw new Error('Missing artifact authorization') + } + if (!key) { + throw new Error('Missing idempotency key') + } + const body = String(init?.body) + const existing = this.createsByKey.get(key) + if (existing) { + if (existing.body !== body) { + throw new Error('Idempotency key reused with another request') + } + return jsonResponse(existing.response, 201) + } + + this.createMutations += 1 + const slug = `artifact-${this.createMutations}` + const response = createResponseBody(slug) + this.createsByKey.set(key, { body, response }) + this.artifacts.set(slug, body) + if (this.loseNextCreateResponse) { + this.loseNextCreateResponse = false + throw new TypeError('response lost after committed create') + } + return jsonResponse(response, 201) + } + + private delete(url: string): Response { + if (this.rejectNextDeleteCode !== null) { + const code = this.rejectNextDeleteCode + this.rejectNextDeleteCode = null + return jsonResponse({ code }, 404) + } + const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1)) + if (!this.artifacts.delete(slug)) { + return jsonResponse({ code: 'artifact_not_found' }, 404) + } + this.deleteMutations += 1 + if (this.loseNextDeleteResponse) { + this.loseNextDeleteResponse = false + throw new TypeError('response lost after committed delete') + } + return new Response(null, { status: 204 }) + } + + private update(url: string, init?: RequestInit): Response { + if (this.rejectNextUpdateStatus !== null) { + const status = this.rejectNextUpdateStatus + this.rejectNextUpdateStatus = null + return jsonResponse({ code: 'artifact_update_failed' }, status) + } + const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1)) + if (!this.artifacts.has(slug)) { + return jsonResponse({ code: 'artifact_not_found' }, 404) + } + const headers = new Headers(init?.headers) + if ( + headers.get('authorization') !== 'Bearer token-a' || + headers.get('x-orca-edit-token') !== `edit-${slug}` + ) { + return jsonResponse({ code: 'artifact_forbidden' }, 403) + } + this.artifacts.set(slug, String(init?.body)) + return jsonResponse(createResponseBody(slug), 200) + } +} + +async function createUserDataPath(): Promise { + const path = await mkdtemp(join(tmpdir(), 'orca-artifact-recovery-')) + createdPaths.push(path) + return path +} + +function service(userDataPath: string): ArtifactCloudService { + return new ArtifactCloudService(userDataPath, () => true) +} + +async function publishedLink(userDataPath: string): Promise { + const result = await service(userDataPath).getPublishedLink({ + sourceKey: writeRequest.sourceKey, + apiUrl, + authToken: 'token-a' + }) + return result.status === 'ok' ? (result.value?.shareUrl ?? null) : null +} + +function jsonResponse(body: object, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }) +} + +function createResponseBody(slug: string): object { + return { + artifact: { + version: 1, + slug, + title: null, + originalFileName: 'report.html', + sourceContentType: 'text/html', + renderedContentType: 'text/html', + createdAt: '2026-08-06T00:00:00.000Z', + updatedAt: '2026-08-06T00:00:00.000Z', + expiresAt: '2026-09-06T00:00:00.000Z', + byteSize: 17, + deletedAt: null + }, + shareUrl: `https://share.onorca.dev/a/${slug}`, + editToken: `edit-${slug}` + } +} diff --git a/src/main/artifacts/artifact-cloud-request.ts b/src/main/artifacts/artifact-cloud-request.ts index 6f1e0bb0f7b..4ad4633dac9 100644 --- a/src/main/artifacts/artifact-cloud-request.ts +++ b/src/main/artifacts/artifact-cloud-request.ts @@ -1,7 +1,14 @@ import type { ArtifactWriteRequest } from '../../shared/artifacts' import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' -export function artifactWriteBody(request: ArtifactWriteRequest): Record { +export type ArtifactWriteBody = { + content: string + contentType: ArtifactWriteRequest['contentType'] + fileName: string + title?: string +} + +export function artifactWriteBody(request: ArtifactWriteRequest): ArtifactWriteBody { return { content: request.content, contentType: request.contentType, diff --git a/src/main/artifacts/artifact-cloud-service.ts b/src/main/artifacts/artifact-cloud-service.ts index 6abb0525e9c..83b9836f940 100644 --- a/src/main/artifacts/artifact-cloud-service.ts +++ b/src/main/artifacts/artifact-cloud-service.ts @@ -12,6 +12,7 @@ import type { import { assertArtifactSharingAllowed } from '../../shared/artifact-sharing-gate' import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' +import { prepareArtifactCloudUse } from '../orca-profiles/profile-artifact-cloud-cleanup' import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh' import { allowsArtifactCloudAuthOverride, @@ -28,6 +29,7 @@ import { import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store' import { artifactRequest, artifactWriteBody } from './artifact-cloud-request' import { ArtifactPublisher } from './artifact-publisher' +import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' type ArtifactAuthContext = { profileId: string @@ -35,6 +37,28 @@ type ArtifactAuthContext = { assertCurrent: () => void } +async function deleteArtifactRequest( + apiUrl: string, + token: string, + path: string, + editToken?: string +): Promise { + try { + await artifactRequest(apiUrl, token, path, { + method: 'DELETE', + ...(editToken ? { editToken } : {}) + }) + } catch (error) { + if ( + !(error instanceof OrcaCloudRequestError) || + error.statusCode !== 404 || + error.errorCode !== 'artifact_not_found' + ) { + throw error + } + } +} + function tokenFingerprint(token: string): string { return createHash('sha256').update(token).digest('hex') } @@ -227,10 +251,8 @@ export class ArtifactCloudService { } return this.publisher.runForSlug(record.slug, auth, async () => { auth.assertCurrent() - await artifactRequest(apiUrl, token, `/${record.slug}`, { - method: 'DELETE', - editToken: record.editToken - }) + await deleteArtifactRequest(apiUrl, token, `/${record.slug}`, record.editToken) + auth.assertCurrent() removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { sourceKey: request.sourceKey, slug: record.slug @@ -244,9 +266,8 @@ export class ArtifactCloudService { return this.withAuth(options, (token, apiUrl, auth) => this.publisher.runForSlug(id, auth, async () => { auth.assertCurrent() - await artifactRequest(apiUrl, token, `/${encodeURIComponent(id)}`, { - method: 'DELETE' - }) + await deleteArtifactRequest(apiUrl, token, `/${encodeURIComponent(id)}`) + auth.assertCurrent() removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id }) }) ) @@ -258,6 +279,7 @@ export class ArtifactCloudService { ): Promise> { const apiUrl = resolveArtifactCloudApiUrl(options.apiUrl) const active = ensureActiveOrcaProfile(this.userDataPath) + prepareArtifactCloudUse(active.profile, this.userDataPath) if (options.authToken?.trim()) { if (!allowsArtifactCloudAuthOverride()) { throw new Error( diff --git a/src/main/artifacts/artifact-create-intent-store.test.ts b/src/main/artifacts/artifact-create-intent-store.test.ts new file mode 100644 index 00000000000..dfcbe003df7 --- /dev/null +++ b/src/main/artifacts/artifact-create-intent-store.test.ts @@ -0,0 +1,319 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm, stat, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ARTIFACT_CLI_MAX_RPC_BYTES, artifactWriteRequestByteLength } from '../../shared/artifacts' +import { + MAX_ARTIFACT_CREATE_INTENT_BYTES, + MAX_PENDING_ARTIFACT_CREATES, + clearArtifactCreateIntents, + getArtifactCreateIntent, + getOrCreateArtifactCreateIntent, + removeArtifactCreateIntent +} from './artifact-create-intent-store' +import type { ArtifactShareScope } from './artifact-share-record-store' + +vi.mock('node:child_process', () => ({ execFile: vi.fn(), execFileSync: vi.fn() })) + +const createdPaths: string[] = [] +const scope: ArtifactShareScope = { + cloudUserId: 'user-a', + cloudProfileId: 'cloud-a', + cloudOrganizationId: 'org-a', + apiOrigin: 'https://share.onorca.dev' +} +const body = { + content: '

Original

', + contentType: 'text/html' as const, + fileName: 'report.html', + title: 'Original' +} + +afterEach(async () => { + await Promise.all( + createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe('artifact create intent store', () => { + it('retains the first key and exact request until the matching create completes', async () => { + const userDataPath = await createUserDataPath() + const sourceKey = String.raw`C:\repo\report.html` + const first = getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + sourceKey, + scope, + 'key-a', + body + ) + const retry = getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + sourceKey, + scope, + 'key-b', + { ...body, content: '

Changed

' } + ) + + expect(first).toEqual(retry) + expect(retry).toMatchObject({ idempotencyKey: 'key-a', body }) + removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-b') + expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).not.toBeNull() + removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a') + expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).toBeNull() + }) + + it.each([ + ['user', { cloudUserId: 'user-b' }], + ['profile', { cloudProfileId: 'cloud-b' }], + ['organization', { cloudOrganizationId: 'org-b' }], + ['API origin', { apiOrigin: 'http://localhost:3000' }] + ])('isolates recovery intent by %s', async (_name, changedScope) => { + const userDataPath = await createUserDataPath() + const sourceKey = '/repo/report.html' + getOrCreateArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a', body) + + expect( + getArtifactCreateIntent('local-profile', userDataPath, sourceKey, { + ...scope, + ...changedScope + }) + ).toBeNull() + }) + + it('bounds unresolved payload storage without dropping an existing intent', async () => { + const userDataPath = await createUserDataPath() + for (let index = 0; index < MAX_PENDING_ARTIFACT_CREATES; index += 1) { + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + `/repo/report-${index}.html`, + scope, + `key-${index}`, + body + ) + } + + expect(() => + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/overflow.html', + scope, + 'overflow-key', + body + ) + ).toThrow(/waiting for recovery/) + expect( + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report-0.html', + scope, + 'replacement-key', + { ...body, content: 'replacement' } + ).idempotencyKey + ).toBe('key-0') + }) + + it('clears pending content at the profile lifecycle boundary', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + + clearArtifactCreateIntents('local-profile', userDataPath) + + expect( + getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope) + ).toBeNull() + expect(await readdir(directory)).toEqual([]) + }) + + it('removes crash-left temporary writes before admitting another intent', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a') + await writeFile(join(directory, 'crash-left.tmp'), 'partial') + + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/other.html', + scope, + 'key-b', + body + ) + + expect((await readdir(directory)).some((name) => name.endsWith('.tmp'))).toBe(false) + }) + + it('hardens one Windows journal directory without per-file PowerShell launches', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.mocked(execFileSync).mockImplementation((file) => + String(file).endsWith('whoami.exe') ? '"USER","S-1-5-21-1000"' : '' + ) + try { + const userDataPath = await createUserDataPath() + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/other.html', + scope, + 'key-b', + body + ) + + const powershellCalls = vi + .mocked(execFileSync) + .mock.calls.filter(([file]) => String(file).endsWith('powershell.exe')) + expect(powershellCalls).toHaveLength(1) + expect((powershellCalls[0]![1] as string[]).at(-1)).toBe('1') + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + vi.mocked(execFileSync).mockReset() + } + }) + + it('refuses to overwrite an unreadable matching intent', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + const [fileName] = await readdir(directory) + await writeFile(join(directory, fileName), '{broken-json') + + expect(() => + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-b', + body + ) + ).toThrow(/could not be read safely/) + }) + + it('removes an unreadable intent after its mutation completes', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + const [fileName] = await readdir(directory) + await writeFile(join(directory, fileName), '{broken-json') + + expect(() => + removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a') + ).not.toThrow() + expect(await readdir(directory)).toEqual([]) + }) + + it('rejects a persisted content type outside the artifact allowlist', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + const [fileName] = await readdir(directory) + const path = join(directory, fileName) + const intent = JSON.parse(await readFile(path, 'utf8')) as { body: { contentType: string } } + intent.body.contentType = 'application/octet-stream' + await writeFile(path, JSON.stringify(intent)) + + expect(() => + getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope) + ).toThrow(/unsupported format/) + }) + + it('persists a valid artifact request near the RPC limit', async () => { + const userDataPath = await createUserDataPath() + const nearLimitBody = { ...body, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES - 200) } + expect( + artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...nearLimitBody }) + ).toBeLessThanOrEqual(ARTIFACT_CLI_MAX_RPC_BYTES) + + expect(() => + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + nearLimitBody + ) + ).not.toThrow() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + const [fileName] = await readdir(directory) + expect((await stat(join(directory, fileName))).size).toBeGreaterThan(ARTIFACT_CLI_MAX_RPC_BYTES) + }) + + it('rejects an oversized recovery record before reading it', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + const [fileName] = await readdir(directory) + await truncate(join(directory, fileName), MAX_ARTIFACT_CREATE_INTENT_BYTES + 1) + + expect(() => + getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope) + ).toThrow(/exceeds the supported size/) + }) +}) + +async function createUserDataPath(): Promise { + const path = await mkdtemp(join(tmpdir(), 'orca-artifact-create-intents-')) + createdPaths.push(path) + return path +} diff --git a/src/main/artifacts/artifact-create-intent-store.ts b/src/main/artifacts/artifact-create-intent-store.ts new file mode 100644 index 00000000000..d544d04ceec --- /dev/null +++ b/src/main/artifacts/artifact-create-intent-store.ts @@ -0,0 +1,255 @@ +import { createHash, randomBytes } from 'node:crypto' +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts' +import { + bestEffortFsyncDirectorySync, + fsyncFileSync, + hardenSecurePath +} from '../../shared/secure-file' +import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths' +import type { ArtifactWriteBody } from './artifact-cloud-request' +import type { ArtifactShareScope } from './artifact-share-record-store' + +export const MAX_PENDING_ARTIFACT_CREATES = 32 +export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_CLI_MAX_RPC_BYTES + 128 * 1024 + +const MAX_HARDENED_INTENT_DIRECTORIES = 64 +const hardenedIntentDirectories = new Set() + +export type ArtifactCreateIntent = { + version: 1 + sourceKey: string + scope: ArtifactShareScope + idempotencyKey: string + body: ArtifactWriteBody +} + +function intentDirectory(profileId: string, userDataPath: string): string { + return join(getOrcaProfileDirectory(profileId, userDataPath), 'artifact-create-intents') +} + +function ensureIntentDirectory(profileId: string, userDataPath: string): string { + const directory = intentDirectory(profileId, userDataPath) + mkdirSync(directory, { recursive: true, mode: 0o700 }) + if (!hardenedIntentDirectories.has(directory)) { + hardenSecurePath(directory, { + isDirectory: true, + platform: process.platform, + sync: true + }) + if (hardenedIntentDirectories.size >= MAX_HARDENED_INTENT_DIRECTORIES) { + const oldest = hardenedIntentDirectories.values().next().value + if (oldest !== undefined) { + hardenedIntentDirectories.delete(oldest) + } + } + hardenedIntentDirectories.add(directory) + } + return directory +} + +function removeTemporaryIntents(directory: string): void { + for (const name of readdirSync(directory)) { + if (name.endsWith('.tmp')) { + rmSync(join(directory, name), { force: true }) + } + } +} + +function writeIntent(path: string, directory: string, serializedIntent: string): void { + // Child files inherit the once-hardened directory ACL, avoiding per-create PowerShell launches. + const temporaryPath = `${path}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp` + try { + writeFileSync(temporaryPath, serializedIntent, { + encoding: 'utf8', + mode: 0o600 + }) + fsyncFileSync(temporaryPath) + renameSync(temporaryPath, path) + bestEffortFsyncDirectorySync(directory) + } catch (error) { + rmSync(temporaryPath, { force: true }) + throw error + } +} + +function intentPath( + profileId: string, + userDataPath: string, + sourceKey: string, + scope: ArtifactShareScope +): string { + const identity = JSON.stringify([ + sourceKey, + scope.cloudUserId, + scope.cloudProfileId, + scope.cloudOrganizationId, + scope.apiOrigin + ]) + const fileName = `${createHash('sha256').update(identity).digest('hex')}.json` + return join(intentDirectory(profileId, userDataPath), fileName) +} + +function scopeMatches(left: ArtifactShareScope, right: ArtifactShareScope): boolean { + return ( + left.cloudUserId === right.cloudUserId && + left.cloudProfileId === right.cloudProfileId && + left.cloudOrganizationId === right.cloudOrganizationId && + left.apiOrigin === right.apiOrigin + ) +} + +function isWriteBody(value: unknown): value is ArtifactWriteBody { + if (!value || typeof value !== 'object') { + return false + } + const body = value as Partial + return ( + typeof body.content === 'string' && + (body.contentType === 'text/html' || body.contentType === 'text/markdown') && + typeof body.fileName === 'string' && + (body.title === undefined || typeof body.title === 'string') + ) +} + +function isScope(value: unknown): value is ArtifactShareScope { + if (!value || typeof value !== 'object') { + return false + } + const scope = value as Partial + return [ + scope.cloudUserId, + scope.cloudProfileId, + scope.cloudOrganizationId, + scope.apiOrigin + ].every((field) => typeof field === 'string') +} + +function readIntent(path: string): ArtifactCreateIntent { + let size: number + try { + size = statSync(path).size + } catch (error) { + throw new Error('Artifact create recovery record could not be read safely.', { cause: error }) + } + if (size > MAX_ARTIFACT_CREATE_INTENT_BYTES) { + throw new Error('Artifact create recovery record exceeds the supported size.') + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error('Artifact create recovery record could not be read safely.', { cause: error }) + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('Artifact create recovery record has an unsupported format.') + } + const intent = parsed as Partial + if ( + intent.version !== 1 || + typeof intent.sourceKey !== 'string' || + typeof intent.idempotencyKey !== 'string' || + !intent.idempotencyKey || + !isScope(intent.scope) || + !isWriteBody(intent.body) + ) { + throw new Error('Artifact create recovery record has an unsupported format.') + } + return intent as ArtifactCreateIntent +} + +export function getArtifactCreateIntent( + profileId: string, + userDataPath: string, + sourceKey: string, + scope: ArtifactShareScope +): ArtifactCreateIntent | null { + const path = intentPath(profileId, userDataPath, sourceKey, scope) + if (!existsSync(path)) { + return null + } + const intent = readIntent(path) + if (intent.sourceKey !== sourceKey || !scopeMatches(intent.scope, scope)) { + throw new Error('Artifact create recovery record does not match its storage identity.') + } + return intent +} + +export function getOrCreateArtifactCreateIntent( + profileId: string, + userDataPath: string, + sourceKey: string, + scope: ArtifactShareScope, + idempotencyKey: string, + body: ArtifactWriteBody +): ArtifactCreateIntent { + const existing = getArtifactCreateIntent(profileId, userDataPath, sourceKey, scope) + if (existing) { + return existing + } + const directory = ensureIntentDirectory(profileId, userDataPath) + removeTemporaryIntents(directory) + const pendingCount = readdirSync(directory).filter((name) => name.endsWith('.json')).length + if (pendingCount >= MAX_PENDING_ARTIFACT_CREATES) { + throw new Error('Too many artifact creates are waiting for recovery. Retry an earlier share.') + } + const intent: ArtifactCreateIntent = { + version: 1, + sourceKey, + scope, + idempotencyKey, + body + } + const serializedIntent = JSON.stringify(intent, null, 2) + if (Buffer.byteLength(serializedIntent, 'utf8') > MAX_ARTIFACT_CREATE_INTENT_BYTES) { + throw new Error('Artifact create recovery record exceeds the supported size.') + } + writeIntent(intentPath(profileId, userDataPath, sourceKey, scope), directory, serializedIntent) + return intent +} + +export function removeArtifactCreateIntent( + profileId: string, + userDataPath: string, + sourceKey: string, + scope: ArtifactShareScope, + expectedIdempotencyKey: string +): void { + const path = intentPath(profileId, userDataPath, sourceKey, scope) + if (!existsSync(path)) { + return + } + let matches = true + try { + matches = readIntent(path).idempotencyKey === expectedIdempotencyKey + } catch { + // An unreadable record cannot be replayed, so a completed mutation may discard it safely. + } + if (matches) { + rmSync(path, { force: true }) + bestEffortFsyncDirectorySync(intentDirectory(profileId, userDataPath)) + } +} + +export function clearArtifactCreateIntents(profileId: string, userDataPath: string): void { + const directory = intentDirectory(profileId, userDataPath) + if (!existsSync(directory)) { + return + } + for (const name of readdirSync(directory)) { + if (name.endsWith('.json') || name.endsWith('.tmp')) { + rmSync(join(directory, name), { force: true }) + } + } + bestEffortFsyncDirectorySync(directory) +} diff --git a/src/main/artifacts/artifact-publisher.ts b/src/main/artifacts/artifact-publisher.ts index 8f03c4e5b68..d70958e6b6f 100644 --- a/src/main/artifacts/artifact-publisher.ts +++ b/src/main/artifacts/artifact-publisher.ts @@ -4,7 +4,17 @@ import type { ArtifactWriteRequest } from '../../shared/artifacts' import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' -import { artifactRequest, artifactWriteBody } from './artifact-cloud-request' +import { + artifactRequest, + type ArtifactWriteBody, + artifactWriteBody +} from './artifact-cloud-request' +import { + type ArtifactCreateIntent, + getArtifactCreateIntent, + getOrCreateArtifactCreateIntent, + removeArtifactCreateIntent +} from './artifact-create-intent-store' import { type ArtifactShareScope, getArtifactShareRecord, @@ -15,6 +25,30 @@ import { type ArtifactCreateResponse = ArtifactListItem & { editToken: string } +type ArtifactCreateOutcome = { + editToken: string + intent: ArtifactCreateIntent + result: ArtifactPublishResult +} + +function artifactWriteBodiesMatch(left: ArtifactWriteBody, right: ArtifactWriteBody): boolean { + return ( + left.content === right.content && + left.contentType === right.contentType && + left.fileName === right.fileName && + left.title === right.title + ) +} + +function discardsCreateIntent(error: unknown): boolean { + return ( + error instanceof OrcaCloudRequestError && + error.statusCode >= 400 && + error.statusCode < 500 && + ![408, 409, 425, 429].includes(error.statusCode) + ) +} + type ArtifactPublishAuthContext = { profileId: string scope: ArtifactShareScope @@ -51,7 +85,23 @@ export class ArtifactPublisher { ): Promise { return this.runForSource(request.sourceKey, auth, async () => { auth.assertCurrent() - return (await this.create(request, token, apiUrl, auth, idempotencyKey)).item + const pending = getArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope + ) + const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending) + if (pending && !artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) { + const item = await this.updateExisting(request, token, apiUrl, auth, { + slug: created.result.item.artifact.slug, + editToken: created.editToken + }) + this.removeCreateIntent(request, auth, created.intent) + return item + } + this.removeCreateIntent(request, auth, created.intent) + return created.result.item }) } @@ -64,6 +114,25 @@ export class ArtifactPublisher { ): Promise { return this.runForSource(request.sourceKey, auth, async () => { auth.assertCurrent() + const pending = getArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope + ) + if (pending) { + const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending) + if (artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) { + this.removeCreateIntent(request, auth, created.intent) + return created.result + } + const item = await this.updateExisting(request, token, apiUrl, auth, { + slug: created.result.item.artifact.slug, + editToken: created.editToken + }) + this.removeCreateIntent(request, auth, created.intent) + return { change: 'created', item } + } const record = getArtifactShareRecord( auth.profileId, this.userDataPath, @@ -72,24 +141,8 @@ export class ArtifactPublisher { ) if (record) { try { - return await this.runForSlug(record.slug, auth, async () => { - auth.assertCurrent() - const item = await artifactRequest(apiUrl, token, `/${record.slug}`, { - method: 'PUT', - editToken: record.editToken, - body: artifactWriteBody(request) - }) - auth.assertCurrent() - refreshArtifactShareRecordExpiration( - auth.profileId, - this.userDataPath, - request.sourceKey, - auth.scope, - record, - item.artifact.expiresAt - ) - return { change: 'updated', item } - }) + const item = await this.updateExisting(request, token, apiUrl, auth, record) + return { change: 'updated', item } } catch (error) { if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 404) { throw error @@ -101,7 +154,9 @@ export class ArtifactPublisher { }) } } - return this.create(request, token, apiUrl, auth, idempotencyKey) + const created = await this.create(request, token, apiUrl, auth, idempotencyKey, null) + this.removeCreateIntent(request, auth, created.intent) + return created.result }) } @@ -121,18 +176,72 @@ export class ArtifactPublisher { return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation) } + private updateExisting( + request: ArtifactWriteRequest, + token: string, + apiUrl: string, + auth: ArtifactPublishAuthContext, + record: { slug: string; editToken: string } + ): Promise { + return this.runForSlug(record.slug, auth, async () => { + auth.assertCurrent() + const item = await artifactRequest(apiUrl, token, `/${record.slug}`, { + method: 'PUT', + editToken: record.editToken, + body: artifactWriteBody(request) + }) + auth.assertCurrent() + refreshArtifactShareRecordExpiration( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope, + record, + item.artifact.expiresAt + ) + return item + }) + } + private async create( request: ArtifactWriteRequest, token: string, apiUrl: string, auth: ArtifactPublishAuthContext, - idempotencyKey: string - ): Promise { - const response = await artifactRequest(apiUrl, token, '', { - method: 'POST', - body: artifactWriteBody(request), - idempotencyKey - }) + idempotencyKey: string, + pending: ArtifactCreateIntent | null + ): Promise { + const replaying = pending !== null + const intent = + pending ?? + getOrCreateArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope, + idempotencyKey, + artifactWriteBody(request) + ) + let response: ArtifactCreateResponse + try { + response = await artifactRequest(apiUrl, token, '', { + method: 'POST', + body: intent.body, + idempotencyKey: intent.idempotencyKey + }) + } catch (error) { + // A replay may outlive its server receipt, so never abandon a possibly committed artifact. + if (!replaying && discardsCreateIntent(error)) { + removeArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope, + intent.idempotencyKey + ) + } + throw error + } auth.assertCurrent() saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, { slug: response.artifact.slug, @@ -142,11 +251,29 @@ export class ArtifactPublisher { ...auth.scope }) return { - change: 'created', - item: { artifact: response.artifact, shareUrl: response.shareUrl } + editToken: response.editToken, + intent, + result: { + change: 'created', + item: { artifact: response.artifact, shareUrl: response.shareUrl } + } } } + private removeCreateIntent( + request: ArtifactWriteRequest, + auth: ArtifactPublishAuthContext, + intent: ArtifactCreateIntent + ): void { + removeArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope, + intent.idempotencyKey + ) + } + private async runSerialized(key: string, operation: () => Promise): Promise { const previous = this.queues.get(key) ?? Promise.resolve() let release = (): void => {} diff --git a/src/main/artifacts/artifact-recovery-directory-fsync.test.ts b/src/main/artifacts/artifact-recovery-directory-fsync.test.ts new file mode 100644 index 00000000000..c815d91560d --- /dev/null +++ b/src/main/artifacts/artifact-recovery-directory-fsync.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +const fsyncMockState = vi.hoisted(() => ({ + directoryDescriptor: -1, + directoryErrorCode: 'EINVAL' +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + closeSync: (descriptor: number) => { + if (descriptor !== fsyncMockState.directoryDescriptor) { + actual.closeSync(descriptor) + } + }, + fsyncSync: (descriptor: number) => { + if (descriptor === fsyncMockState.directoryDescriptor) { + throw Object.assign(new Error('directory fsync failed'), { + code: fsyncMockState.directoryErrorCode + }) + } + return actual.fsyncSync(descriptor) + }, + openSync: (path: string, flags: string | number) => + actual.statSync(path).isDirectory() + ? fsyncMockState.directoryDescriptor + : actual.openSync(path, flags) + } +}) + +import { bestEffortFsyncDirectorySync, writeDurableSecureJsonFile } from '../../shared/secure-file' +import { + clearArtifactCreateIntents, + getOrCreateArtifactCreateIntent +} from './artifact-create-intent-store' + +const createdPaths: string[] = [] + +afterEach(() => { + fsyncMockState.directoryErrorCode = 'EINVAL' + for (const path of createdPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +it('skips directory fsync on Windows and propagates I/O failures elsewhere', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-eio-')) + createdPaths.push(directory) + fsyncMockState.directoryErrorCode = 'EIO' + + const fsyncDirectory = (): void => bestEffortFsyncDirectorySync(directory) + if (process.platform === 'win32') { + expect(fsyncDirectory).not.toThrow() + } else { + expect(fsyncDirectory).toThrow('directory fsync failed') + } +}) + +it('keeps durable artifact records usable when directory fsync is unsupported', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-')) + createdPaths.push(userDataPath) + const recordPath = join(userDataPath, 'artifact-shares.json') + + expect(() => writeDurableSecureJsonFile(recordPath, { ok: true })).not.toThrow() + expect(JSON.parse(readFileSync(recordPath, 'utf8'))).toEqual({ ok: true }) + + expect(() => + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + { + cloudUserId: 'user-a', + cloudProfileId: 'profile-a', + cloudOrganizationId: 'org-a', + apiOrigin: 'https://share.onorca.dev' + }, + 'key-a', + { content: 'hello', contentType: 'text/markdown', fileName: 'report.md' } + ) + ).not.toThrow() + expect(() => clearArtifactCreateIntents('local-profile', userDataPath)).not.toThrow() +}) diff --git a/src/main/artifacts/artifact-share-record-store.ts b/src/main/artifacts/artifact-share-record-store.ts index 174856eaee4..a16ec19333c 100644 --- a/src/main/artifacts/artifact-share-record-store.ts +++ b/src/main/artifacts/artifact-share-record-store.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { writeSecureJsonFile } from '../../shared/secure-file' +import { writeDurableSecureJsonFile, writeSecureJsonFile } from '../../shared/secure-file' import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths' export type ArtifactShareScope = { @@ -183,7 +183,7 @@ export function saveArtifactShareRecord( const records = readRecords(profileId, userDataPath) records.shares[sourceKey] = { ...record, savedAt: Date.now() } records.shares = pruneRecords(records.shares, Date.now()).shares - writeSecureJsonFile(recordPath(profileId, userDataPath), records) + writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records) } export function refreshArtifactShareRecordExpiration( @@ -228,7 +228,7 @@ export function removeArtifactShareRecords( delete records.shares[sourceKey] } } - writeSecureJsonFile(recordPath(profileId, userDataPath), records) + writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records) } export function clearArtifactShareRecords(profileId: string, userDataPath: string): void { @@ -238,7 +238,7 @@ export function clearArtifactShareRecords(profileId: string, userDataPath: strin } catch { // Clearing must recover sign-out from an unreadable token index. } - writeSecureJsonFile(recordPath(profileId, userDataPath), { + writeDurableSecureJsonFile(recordPath(profileId, userDataPath), { version: 2, lifecycleGeneration: lifecycleGeneration + 1, lifecycleNonce: randomUUID(), diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index db1e054f7e8..581b7f998d1 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -470,7 +470,7 @@ function findMatchingSessionRunIndex( (sessionRun, index) => !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey ) - if (exactMatchIndex >= 0) { + if (exactMatchIndex !== -1) { return exactMatchIndex } @@ -798,7 +798,7 @@ function readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { } } -function readHermesSessionDbRunById(jobId: string, runId: string): unknown | null { +function readHermesSessionDbRunById(jobId: string, runId: string): unknown { if (!existsSync(HERMES_STATE_DB)) { return null } diff --git a/src/main/bitbucket/bitbucket-auth-config.ts b/src/main/bitbucket/bitbucket-auth-config.ts new file mode 100644 index 00000000000..83a4c7cf49d --- /dev/null +++ b/src/main/bitbucket/bitbucket-auth-config.ts @@ -0,0 +1,42 @@ +import { Buffer } from 'node:buffer' + +export const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' + +// Two interchangeable Bitbucket Cloud auth shapes: a single access token (Bearer) +// or an email + API token pair (HTTP Basic). Either satisfies `hasAuth`, and both +// may come from env vars or the in-app credential store. +export type BitbucketAuthConfig = { + baseUrl: string + accessToken: string | null + email: string | null + apiToken: string | null +} + +export function envValue(name: string): string | null { + const value = process.env[name]?.trim() ?? '' + return value.length > 0 ? value : null +} + +export function getEnvAuthConfig(): BitbucketAuthConfig { + return { + baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? DEFAULT_API_BASE_URL, + accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN'), + email: envValue('ORCA_BITBUCKET_EMAIL'), + apiToken: envValue('ORCA_BITBUCKET_API_TOKEN') + } +} + +export function hasAuth(config: BitbucketAuthConfig): boolean { + return Boolean(config.accessToken || (config.email && config.apiToken)) +} + +export function authHeaders(config: BitbucketAuthConfig): Record { + if (config.accessToken) { + return { Authorization: `Bearer ${config.accessToken}` } + } + if (config.email && config.apiToken) { + const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64') + return { Authorization: `Basic ${encoded}` } + } + return {} +} diff --git a/src/main/bitbucket/client.test.ts b/src/main/bitbucket/client.test.ts index dd55bfad6d2..c3fc53de289 100644 --- a/src/main/bitbucket/client.test.ts +++ b/src/main/bitbucket/client.test.ts @@ -124,6 +124,127 @@ describe('Bitbucket client', () => { ).resolves.toMatchObject({ number: 42 }) }) + it('hides a MERGED PR matched only by feature-branch name so a new PR can be created', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.includes('/statuses/build')) { + return Response.json({ values: [] }) + } + return Response.json({ + values: [ + { ...bitbucketPr(7), state: 'MERGED', source: { branch: { name: 'feature/login' } } } + ] + }) + }) + vi.stubGlobal('fetch', fetchMock) + + // Why: a merged branch match is history. Returning it made eligibility + // report "a pull request already exists" and blocked the branch's next PR. + await expect( + getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login') + ).resolves.toBeNull() + }) + + it('keeps a DECLINED PR on a feature branch visible', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.includes('/statuses/build')) { + return Response.json({ values: [] }) + } + return Response.json({ + values: [ + { ...bitbucketPr(9), state: 'DECLINED', source: { branch: { name: 'feature/login' } } } + ] + }) + }) + vi.stubGlobal('fetch', fetchMock) + + // Why: only merged matches are hidden. Hiding declined too made a declined + // PR permanently invisible off the default branch, unlike every other provider. + await expect( + getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login') + ).resolves.toMatchObject({ number: 9, state: 'closed' }) + }) + + it('returns null instead of querying anonymously when no credential resolves', async () => { + delete process.env.ORCA_BITBUCKET_EMAIL + delete process.env.ORCA_BITBUCKET_API_TOKEN + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + // Why: an unauthenticated query 404s on private repos, which would read as + // "no pull request" and offer Create for a branch that already has one. + await expect( + getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login') + ).resolves.toBeNull() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fetches an explicitly linked DECLINED PR before the branch index', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/pullrequests/7')) { + return Response.json({ ...bitbucketPr(7), state: 'DECLINED' }) + } + if (url.includes('/statuses/build')) { + return Response.json({ values: [] }) + } + return new Response('branch index unavailable', { status: 503 }) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect( + getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 7) + ).resolves.toMatchObject({ number: 7, state: 'closed' }) + expect(fetchMock.mock.calls.map(([url]) => url)).not.toContainEqual( + expect.stringContaining('/pullrequests?') + ) + }) + + it('falls back to the branch index when a linked PR number is stale', async () => { + const branchPR = bitbucketPr(7) + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/pullrequests/99')) { + return new Response('not found', { status: 404 }) + } + if (url.includes('/statuses/build')) { + return Response.json({ values: [] }) + } + return Response.json({ + values: [{ ...branchPR, source: { ...branchPR.source, branch: { name: 'feature/login' } } }] + }) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect( + getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 99) + ).resolves.toMatchObject({ number: 7, state: 'open' }) + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + expect.stringContaining('/pullrequests/99'), + expect.stringContaining('/pullrequests?'), + expect.stringContaining('/statuses/build') + ]) + }) + + it('does not report an unreachable host as an auth failure (STA-3944)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('network down') + }) + ) + await expect(getBitbucketAuthStatus()).resolves.toMatchObject({ + configured: true, + authenticated: true + }) + + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 401 })) + ) + await expect(getBitbucketAuthStatus()).resolves.toMatchObject({ + configured: true, + authenticated: false + }) + }) + it('fetches a branch pull request and commit build status', async () => { const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { if (url.includes('/statuses/build')) { diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index e5163bd525a..deb856a66d1 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -1,4 +1,3 @@ -import { Buffer } from 'node:buffer' import type { CheckStatus } from '../../shared/types' import { deriveBitbucketBuildStatus, @@ -8,25 +7,26 @@ import { type RawBitbucketBuildStatus, type RawBitbucketPullRequest } from './pull-request-mappers' -import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref' +import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { authHeaders, getEnvAuthConfig, hasAuth } from './bitbucket-auth-config' +import { accountNameFromUser, fetchBitbucketUserResult } from './user-request' +import { + getStoredBitbucketCredentialError, + getStoredBitbucketMetadata, + hasStoredBitbucketCredential, + loadStoredBitbucketSecret +} from './credential-store' +import { resolveBitbucketAuthConfig, storedAuthConfig } from './resolve-auth' -const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' const REQUEST_TIMEOUT_MS = 5000 const ALL_PULL_REQUEST_STATES = ['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED'] as const -type BitbucketAuthConfig = { - baseUrl: string - accessToken: string | null - email: string | null - apiToken: string | null -} - export type BitbucketAuthStatus = { configured: boolean authenticated: boolean @@ -38,42 +38,16 @@ type RequestOptions = { timeoutMs?: number } -function envValue(name: string): string | null { - const value = process.env[name]?.trim() ?? '' - return value.length > 0 ? value : null -} - -function getAuthConfig(): BitbucketAuthConfig { - return { - baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? DEFAULT_API_BASE_URL, - accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN'), - email: envValue('ORCA_BITBUCKET_EMAIL'), - apiToken: envValue('ORCA_BITBUCKET_API_TOKEN') - } -} - -function hasAuth(config: BitbucketAuthConfig): boolean { - return Boolean(config.accessToken || (config.email && config.apiToken)) -} - -function authHeaders(config: BitbucketAuthConfig): Record { - if (config.accessToken) { - return { Authorization: `Bearer ${config.accessToken}` } - } - if (config.email && config.apiToken) { - const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64') - return { Authorization: `Basic ${encoded}` } - } - return {} -} - function isStringArray(value: string | readonly string[]): value is readonly string[] { return Array.isArray(value) } -function apiUrl(path: string, searchParams?: RequestOptions['searchParams']): string { - const config = getAuthConfig() - const base = config.baseUrl.replace(/\/+$/, '') +function apiUrl( + baseUrl: string, + path: string, + searchParams?: RequestOptions['searchParams'] +): string { + const base = baseUrl.replace(/\/+$/, '') const url = new URL(`${base}${path}`) if (searchParams) { for (const [key, value] of Object.entries(searchParams)) { @@ -95,11 +69,24 @@ async function requestJson( // Why: the existing-review lookup behind Create must distinguish a real // transport/auth failure from an accepted "no PR". When true, a failed request // throws instead of collapsing to null so callers never report false not_found. - throwOnFailure = false + throwOnFailure = false, + // Why: a linked PR number can be stale (deleted PR, wrong repo). A 404 there + // must fall through to the branch lookup rather than throw and hide the + // branch's real review, so only that caller opts into it. + notFoundIsNull = false ): Promise { - const config = getAuthConfig() + const config = resolveBitbucketAuthConfig() + // Why: a denied keychain prompt leaves no usable credential. Issuing the + // request anyway gets a 404 on private repos, which reads as "no pull + // request" and offers Create for a branch that already has one. + if (!hasAuth(config)) { + if (throwOnFailure) { + throw new Error('Bitbucket request failed: no usable credential') + } + return null + } try { - const response = await fetch(apiUrl(path, options.searchParams), { + const response = await fetch(apiUrl(config.baseUrl, path, options.searchParams), { headers: { Accept: 'application/json', ...authHeaders(config) @@ -108,6 +95,9 @@ async function requestJson( }) if (!response.ok) { await cancelUnreadResponseBody(response) + if (response.status === 404 && notFoundIsNull) { + return null + } if (throwOnFailure) { throw new Error(`Bitbucket request failed: HTTP ${response.status}`) } @@ -157,21 +147,40 @@ async function normalizePullRequest( return mapBitbucketPullRequest(raw, status) } +// Never decrypts. Env credentials are checked live; a stored credential is +// revalidated only when its secret already sits in memory from an earlier API +// call, and otherwise trusted from plaintext metadata — decrypting here would +// prompt for keychain access every time Settings opens. export async function getBitbucketAuthStatus(): Promise { - const config = getAuthConfig() - if (!hasAuth(config)) { - return { configured: false, authenticated: false, account: null } + const env = getEnvAuthConfig() + if (hasAuth(env)) { + const result = await fetchBitbucketUserResult(env) + return { + configured: true, + // Why (STA-3944): only a rejection means the credential is bad. An + // unreachable host must not render as "Auth failed" and send the user + // off to regenerate a token that still works. + authenticated: result.ok || result.reason === 'unreachable', + account: result.ok ? accountNameFromUser(result.user) : null + } } - const user = await requestJson<{ - username?: string | null - display_name?: string | null - account_id?: string | null - }>('/user', { timeoutMs: 4000 }) - return { - configured: true, - authenticated: user !== null, - account: user?.username ?? user?.display_name ?? user?.account_id ?? null + const metadata = getStoredBitbucketMetadata() + if (metadata && hasStoredBitbucketCredential()) { + if (getStoredBitbucketCredentialError()) { + return { configured: true, authenticated: false, account: metadata.account } + } + const cached = loadStoredBitbucketSecret() + if (!cached) { + return { configured: true, authenticated: true, account: metadata.account } + } + const result = await fetchBitbucketUserResult(storedAuthConfig(metadata, cached)) + return { + configured: true, + authenticated: result.ok || result.reason === 'unreachable', + account: (result.ok ? accountNameFromUser(result.user) : null) ?? metadata.account + } } + return { configured: false, authenticated: false, account: null } } export async function getBitbucketPullRequest( @@ -216,6 +225,18 @@ export async function getBitbucketPullRequestForBranch( return null } + if (typeof linkedPRNumber === 'number') { + const raw = await requestJson( + `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`, + {}, + throwOnFailure, + true + ) + if (raw) { + return normalizePullRequest(repo, raw) + } + } + if (branchName) { const query = [ `source.branch.name = "${escapeBitbucketQueryString(branchName)}"`, @@ -235,10 +256,16 @@ export async function getBitbucketPullRequestForBranch( ) const raw = list?.values?.[0] if (raw) { - // Why (#9171): discard a non-open implicit branch match on the repo - // default branch and fall through to the linked-number fallback below. + const state = mapBitbucketPullRequestState(raw.state) + // Why: a merged PR we only matched by branch name is history, not review + // context (GitHub's isMergedImplicitPR rule). Keeping it reported "a pull + // request already exists" and blocked the branch's next PR. Scoped to + // merged so a declined PR stays visible off the default branch, matching + // every other provider; the linked lookup above already returned early + // for an explicitly linked review. + const isMergedImplicitMatch = state === 'merged' const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ - state: mapBitbucketPullRequestState(raw.state), + state, reviewNumber: raw.id ?? null, linkedReviewNumber: linkedPRNumber, branchName, @@ -246,21 +273,13 @@ export async function getBitbucketPullRequestForBranch( connectionId, localGitOptions: getHostedReviewLocalGitOptions(options) }) - if (!hideOnDefaultBranch) { + if (!isMergedImplicitMatch && !hideOnDefaultBranch) { return normalizePullRequest(repo, raw) } } } - if (typeof linkedPRNumber !== 'number') { - return null - } - const raw = await requestJson( - `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`, - {}, - throwOnFailure - ) - return raw ? normalizePullRequest(repo, raw) : null + return null } /** diff --git a/src/main/bitbucket/credential-connection.test.ts b/src/main/bitbucket/credential-connection.test.ts new file mode 100644 index 00000000000..0026e6901cb --- /dev/null +++ b/src/main/bitbucket/credential-connection.test.ts @@ -0,0 +1,187 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const OLD_ENV = process.env +const OLD_FETCH = globalThis.fetch +let tempHome = '' + +async function loadModule() { + vi.resetModules() + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString('utf-8') + } + })) + vi.doMock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => tempHome } + }) + return import('./credential-connection') +} + +beforeEach(() => { + process.env = { ...OLD_ENV } + for (const key of [ + 'ORCA_BITBUCKET_ACCESS_TOKEN', + 'ORCA_BITBUCKET_EMAIL', + 'ORCA_BITBUCKET_API_TOKEN', + 'ORCA_BITBUCKET_API_BASE_URL' + ]) { + delete process.env[key] + } + tempHome = mkdtempSync(join(tmpdir(), 'orca-bitbucket-conn-')) +}) + +afterEach(() => { + process.env = OLD_ENV + globalThis.fetch = OLD_FETCH +}) + +describe('Bitbucket credential connection', () => { + it('verifies credentials before saving and reports a stored connection', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn(async () => + Response.json({ username: 'ada' }) + ) as unknown as typeof fetch + + await expect( + conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com', + apiToken: 'tok' + }) + ).resolves.toEqual({ ok: true, account: 'ada' }) + + expect(conn.getBitbucketConnectionStatus()).toEqual({ + configured: true, + source: 'stored', + account: 'ada', + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null + }) + }) + + it('sends Basic auth built from the entered email and API token', async () => { + const conn = await loadModule() + const fetchSpy = vi.fn(async () => Response.json({ username: 'ada' })) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await conn.connectBitbucket({ authMode: 'basic', email: 'ada@example.com', apiToken: 'tok' }) + + const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.bitbucket.org/2.0/user') + expect((init.headers as Record).Authorization).toBe( + `Basic ${Buffer.from('ada@example.com:tok').toString('base64')}` + ) + }) + + it('rejects credentials that fail the /user check without saving them', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn( + async () => new Response(null, { status: 401 }) + ) as unknown as typeof fetch + + const result = await conn.connectBitbucket({ + authMode: 'token', + accessToken: 'bad' + }) + expect(result.ok).toBe(false) + expect(conn.getBitbucketConnectionStatus().source).toBe('none') + }) + + it('separates a rejected credential from an unreachable host (STA-3944)', async () => { + const conn = await loadModule() + + globalThis.fetch = vi.fn( + async () => new Response(null, { status: 401 }) + ) as unknown as typeof fetch + const rejected = await conn.connectBitbucket({ authMode: 'token', accessToken: 'bad' }) + expect(!rejected.ok && rejected.error).toMatch(/rejected these credentials/i) + + // Why: a timeout or 5xx says nothing about the token; calling it invalid + // sends the user off to regenerate a credential that still works. + for (const failure of [ + async () => { + throw new Error('network down') + }, + async () => new Response(null, { status: 503 }) + ]) { + globalThis.fetch = vi.fn(failure) as unknown as typeof fetch + const unreachable = await conn.connectBitbucket({ authMode: 'token', accessToken: 'good' }) + expect(!unreachable.ok && unreachable.error).toMatch(/could not reach bitbucket/i) + } + expect(conn.getBitbucketConnectionStatus().source).toBe('none') + }) + + it('rejects an incomplete basic-auth credential before making a request', async () => { + const conn = await loadModule() + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const result = await conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com' + }) + expect(result.ok).toBe(false) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('reports environment variables as the source and takes precedence over stored creds', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn(async () => + Response.json({ username: 'ada' }) + ) as unknown as typeof fetch + await conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com', + apiToken: 'tok' + }) + + process.env.ORCA_BITBUCKET_ACCESS_TOKEN = 'env-token' + expect(conn.getBitbucketConnectionStatus()).toMatchObject({ + configured: true, + source: 'environment', + authMode: 'token' + }) + }) + + it('lets an env API base URL override the stored one without env credentials', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn(async () => + Response.json({ username: 'ada' }) + ) as unknown as typeof fetch + await conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com', + apiToken: 'tok', + baseUrl: 'https://stored.example.com/2.0' + }) + + // Only the base URL is in the env, so `hasAuth(env)` is false — precedence + // is per-setting, not all-or-nothing. + process.env.ORCA_BITBUCKET_API_BASE_URL = 'https://env.example.com/2.0' + const { resolveBitbucketAuthConfig } = await import('./resolve-auth') + expect(resolveBitbucketAuthConfig().baseUrl).toBe('https://env.example.com/2.0') + }) + + it('clears the stored connection on disconnect', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn(async () => + Response.json({ username: 'ada' }) + ) as unknown as typeof fetch + await conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com', + apiToken: 'tok' + }) + + conn.disconnectBitbucket() + expect(conn.getBitbucketConnectionStatus().source).toBe('none') + }) +}) diff --git a/src/main/bitbucket/credential-connection.ts b/src/main/bitbucket/credential-connection.ts new file mode 100644 index 00000000000..c36980e8e79 --- /dev/null +++ b/src/main/bitbucket/credential-connection.ts @@ -0,0 +1,115 @@ +import { + DEFAULT_API_BASE_URL, + envValue, + getEnvAuthConfig, + hasAuth, + type BitbucketAuthConfig +} from './bitbucket-auth-config' +import { accountNameFromUser, fetchBitbucketUserResult } from './user-request' +import { + clearStoredBitbucketCredential, + getStoredBitbucketMetadata, + hasStoredBitbucketCredential, + saveBitbucketCredential +} from './credential-store' +import type { + BitbucketConnectArgs, + BitbucketConnectResult, + BitbucketConnectionStatus +} from '../../shared/bitbucket-credentials' + +export type { BitbucketConnectArgs, BitbucketConnectResult, BitbucketConnectionStatus } + +const VERIFY_TIMEOUT_MS = 6000 + +function normalize(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? '' + return trimmed.length > 0 ? trimmed : null +} + +function buildCandidateConfig(input: BitbucketConnectArgs): BitbucketAuthConfig { + return { + baseUrl: normalize(input.baseUrl) ?? DEFAULT_API_BASE_URL, + accessToken: input.authMode === 'token' ? normalize(input.accessToken) : null, + email: input.authMode === 'basic' ? normalize(input.email) : null, + apiToken: input.authMode === 'basic' ? normalize(input.apiToken) : null + } +} + +// Verifying against `/user` before persisting keeps the stored "connected +// account" honest and lets the dialog reject a dead token inline. +export async function connectBitbucket( + input: BitbucketConnectArgs +): Promise { + const config = buildCandidateConfig(input) + if (!hasAuth(config)) { + return { + ok: false, + error: + input.authMode === 'token' + ? 'Enter an access token.' + : 'Enter both an email and an API token.' + } + } + const result = await fetchBitbucketUserResult(config, VERIFY_TIMEOUT_MS) + if (!result.ok) { + return { + ok: false, + // Why (STA-3944): telling someone their token is invalid when the network + // is down sends them to regenerate a credential that was fine. + error: + result.reason === 'rejected' + ? 'Bitbucket rejected these credentials. Check the email and token, then try again.' + : 'Could not reach Bitbucket. Check your connection or the API base URL, then try again.' + } + } + const account = accountNameFromUser(result.user) + saveBitbucketCredential({ + authMode: input.authMode, + email: config.email, + baseUrl: normalize(input.baseUrl), + account, + accessToken: config.accessToken, + apiToken: config.apiToken + }) + return { ok: true, account } +} + +export function disconnectBitbucket(): void { + clearStoredBitbucketCredential() +} + +// Reads env vars and plaintext metadata only — never decrypts — so the Settings +// card can call it on every open without a keychain prompt. +export function getBitbucketConnectionStatus(): BitbucketConnectionStatus { + const env = getEnvAuthConfig() + if (hasAuth(env)) { + return { + configured: true, + source: 'environment', + account: null, + authMode: env.accessToken ? 'token' : 'basic', + email: env.email, + baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') + } + } + if (hasStoredBitbucketCredential()) { + const metadata = getStoredBitbucketMetadata() + return { + configured: true, + source: 'stored', + account: metadata?.account ?? null, + authMode: metadata?.authMode ?? null, + email: metadata?.email ?? null, + baseUrl: metadata?.baseUrl ?? null + } + } + return { + configured: false, + source: 'none', + account: null, + authMode: null, + email: null, + baseUrl: null + } +} diff --git a/src/main/bitbucket/credential-store.test.ts b/src/main/bitbucket/credential-store.test.ts new file mode 100644 index 00000000000..4edd11b0444 --- /dev/null +++ b/src/main/bitbucket/credential-store.test.ts @@ -0,0 +1,367 @@ +import { existsSync, mkdtempSync, statSync } from 'node:fs' +import type * as Fs from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +let tempHome = '' +const decryptStringMock = vi.fn((value: Buffer) => value.toString('utf-8')) + +async function loadStore( + options: { + unlinkError?: NodeJS.ErrnoException + writeError?: Error + shortWrites?: boolean + } = {} +) { + vi.resetModules() + // Why: doMock registrations outlive resetModules, so an injected failure from + // one case would leak into every later one in this file. + vi.doUnmock('node:fs') + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: decryptStringMock + } + })) + vi.doMock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => tempHome } + }) + if (options.shortWrites) { + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + // Why: write(2) may return a short count; publishing without looping + // would rename a truncated credential into place. + writeSync: (fd: number, data: Buffer, offset = 0, length = data.length) => + actual.writeSync(fd, data, offset, Math.min(1, length)) + } + }) + } + if (options.writeError) { + const error = options.writeError + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + writeSync: () => { + throw error + } + } + }) + } + if (options.unlinkError) { + // Why mocked: chmod-based failure injection is not portable — Windows has no + // POSIX modes and a root/elevated runner can unlink through a 0500 directory. + const error = options.unlinkError + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + unlinkSync: () => { + throw error + } + } + }) + } + return import('./credential-store') +} + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'orca-bitbucket-store-')) + decryptStringMock.mockClear() +}) + +describe('Bitbucket credential store', () => { + it('persists plaintext metadata and an encrypted secret, then reads them back', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' + }) + + expect(store.hasStoredBitbucketCredential()).toBe(true) + expect(store.getStoredBitbucketMetadata()).toMatchObject({ + authMode: 'basic', + email: 'ada@example.com', + account: 'ada' + }) + expect(store.loadStoredBitbucketSecret()).toMatchObject({ + accessToken: null, + apiToken: 'secret-token', + // Why (STA-3941): auth fields live in the envelope so a torn write cannot + // pair a new secret with a stale email. + authMode: 'basic', + email: 'ada@example.com' + }) + }) + + it('writes both credential files 0600', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'token', + email: null, + baseUrl: null, + account: 'dev', + accessToken: 'access-secret', + apiToken: null + }) + + for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) { + expect(statSync(join(tempHome, '.orca', file)).mode & 0o777).toBe(0o600) + } + }) + + it('re-tightens permissions when overwriting an existing credential', async () => { + const store = await loadStore() + const save = (account: string): void => + store.saveBitbucketCredential({ + authMode: 'token', + email: null, + baseUrl: null, + account, + accessToken: 'access-secret', + apiToken: null + }) + save('first') + // Why: writeFileSync's mode is ignored for an existing file, so a loosened + // credential would stay world-readable across a reconnect. + const { chmodSync } = await import('node:fs') + for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) { + chmodSync(join(tempHome, '.orca', file), 0o644) + } + save('second') + + for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) { + expect(statSync(join(tempHome, '.orca', file)).mode & 0o777).toBe(0o600) + } + }) + + it('does not decrypt for metadata/status reads — only on a forced secret load', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'token', + email: null, + baseUrl: 'https://api.bitbucket.org/2.0', + account: 'dev', + accessToken: 'access-secret', + apiToken: null + }) + + // Simulate a fresh session: caches cleared, files still on disk. + store._resetBitbucketCredentialCache() + + expect(store.getStoredBitbucketMetadata()?.account).toBe('dev') + expect(store.hasStoredBitbucketCredential()).toBe(true) + expect(decryptStringMock).not.toHaveBeenCalled() + + // Without force, the secret stays unread. + expect(store.loadStoredBitbucketSecret()).toBeNull() + expect(decryptStringMock).not.toHaveBeenCalled() + + // Forcing the load decrypts exactly once, then caches. + expect(store.loadStoredBitbucketSecret({ force: true })).toMatchObject({ + accessToken: 'access-secret', + apiToken: null, + authMode: 'token' + }) + expect(decryptStringMock).toHaveBeenCalledTimes(1) + expect(store.loadStoredBitbucketSecret()).not.toBeNull() + expect(decryptStringMock).toHaveBeenCalledTimes(1) + }) + + it('rejects non-string fields from hand-edited metadata and secret files', async () => { + const store = await loadStore() + const { writeFileSync } = await import('node:fs') + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' + }) + + writeFileSync( + join(tempHome, '.orca', 'bitbucket-credential.json'), + JSON.stringify({ version: 1, authMode: 'basic', email: { evil: true }, account: 42 }) + ) + writeFileSync( + join(tempHome, '.orca', 'bitbucket-credential.enc'), + JSON.stringify({ accessToken: ['nope'], apiToken: 7 }) + ) + store._resetBitbucketCredentialCache() + + expect(store.getStoredBitbucketMetadata()).toMatchObject({ email: null, account: null }) + expect(store.loadStoredBitbucketSecret({ force: true })).toMatchObject({ + accessToken: null, + apiToken: null + }) + }) + + it('keeps the previous credential intact when the secret write fails (STA-3941)', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'first-token' + }) + const { readFileSync } = await import('node:fs') + const before = readFileSync(join(tempHome, '.orca', 'bitbucket-credential.enc')) + + // Why: a direct write truncates in place, so a failure mid-write used to + // destroy the only working credential. The temp+rename path cannot. + const failing = await loadStore({ writeError: new Error('disk full') }) + expect(() => + failing.saveBitbucketCredential({ + authMode: 'basic', + email: 'grace@example.com', + baseUrl: null, + account: 'grace', + accessToken: null, + apiToken: 'second-token' + }) + ).toThrow(/disk full/) + + expect(readFileSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toEqual(before) + expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc.tmp'))).toBe(false) + }) + + it('authenticates from the envelope when metadata is stale (STA-3941)', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'grace@example.com', + baseUrl: null, + account: 'grace', + accessToken: null, + apiToken: 'second-token' + }) + + // Simulate an interrupt between publishing the secret and the metadata: + // metadata still describes the previous connection. + const { writeFileSync } = await import('node:fs') + writeFileSync( + join(tempHome, '.orca', 'bitbucket-credential.json'), + JSON.stringify({ + version: 1, + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + updatedAt: '' + }) + ) + store._resetBitbucketCredentialCache() + + const { resolveBitbucketAuthConfig } = await import('./resolve-auth') + // Auth follows the envelope, so the pair stays usable; only the displayed + // account is stale until the next status refresh. + expect(resolveBitbucketAuthConfig()).toMatchObject({ + email: 'grace@example.com', + apiToken: 'second-token' + }) + }) + + it('still authenticates a credential saved before the envelope carried auth fields', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'legacy-token' + }) + const { writeFileSync } = await import('node:fs') + // Legacy envelopes held only the two tokens. + writeFileSync( + join(tempHome, '.orca', 'bitbucket-credential.enc'), + JSON.stringify({ accessToken: null, apiToken: 'legacy-token' }) + ) + store._resetBitbucketCredentialCache() + + const { resolveBitbucketAuthConfig } = await import('./resolve-auth') + expect(resolveBitbucketAuthConfig()).toMatchObject({ + email: 'ada@example.com', + apiToken: 'legacy-token' + }) + }) + + it('writes the whole credential even when the filesystem short-writes (STA-3941)', async () => { + const store = await loadStore({ shortWrites: true }) + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'a-token-long-enough-to-need-several-writes' + }) + store._resetBitbucketCredentialCache() + + expect(store.loadStoredBitbucketSecret({ force: true })).toMatchObject({ + apiToken: 'a-token-long-enough-to-need-several-writes', + email: 'ada@example.com' + }) + expect(store.getStoredBitbucketMetadata()?.account).toBe('ada') + }) + + it('clears both files and in-memory state on disconnect', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' + }) + + store.clearStoredBitbucketCredential() + + expect(store.hasStoredBitbucketCredential()).toBe(false) + expect(store.getStoredBitbucketMetadata()).toBeNull() + expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toBe(false) + expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.json'))).toBe(false) + }) + + it('surfaces a non-ENOENT delete failure instead of silently keeping the files', async () => { + const denied: NodeJS.ErrnoException = Object.assign(new Error('permission denied'), { + code: 'EACCES' + }) + const store = await loadStore({ unlinkError: denied }) + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' + }) + + // Clearing memory while the files survive would resurrect the credential on + // the next launch, so the failure has to reach the caller. + expect(() => store.clearStoredBitbucketCredential()).toThrow(/permission denied/) + expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toBe(true) + }) + + it('ignores a missing file on disconnect', async () => { + const missing: NodeJS.ErrnoException = Object.assign(new Error('no such file'), { + code: 'ENOENT' + }) + const store = await loadStore({ unlinkError: missing }) + expect(() => store.clearStoredBitbucketCredential()).not.toThrow() + }) +}) diff --git a/src/main/bitbucket/credential-store.ts b/src/main/bitbucket/credential-store.ts new file mode 100644 index 00000000000..4bf4544ae7c --- /dev/null +++ b/src/main/bitbucket/credential-store.ts @@ -0,0 +1,216 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + CredentialDecryptionError, + credentialFileHasContent, + readStoredCredentialToken, + writeCredentialFileAtomic, + writeEncryptedCredential +} from '../integration-credential-file' +import type { BitbucketAuthMode } from '../../shared/bitbucket-credentials' + +// Why: the secret stays encrypted via safeStorage while this metadata stays +// plaintext, so status reads render the connected account without decrypting — +// otherwise every Settings open would trigger an OS keychain prompt. +export type BitbucketStoredMetadata = { + version: 1 + authMode: BitbucketAuthMode + email: string | null + baseUrl: string | null + account: string | null + updatedAt: string +} + +// Why (STA-3941): the envelope carries everything auth needs, so a torn write +// between the two files can only leave stale display metadata — never a new +// secret paired with an old email that cannot authenticate. Fields are optional +// because credentials saved before this change hold only the two tokens. +export type BitbucketStoredSecret = { + accessToken: string | null + apiToken: string | null + authMode?: BitbucketAuthMode + email?: string | null + baseUrl?: string | null +} + +export type BitbucketCredentialSaveInput = { + authMode: BitbucketAuthMode + email: string | null + baseUrl: string | null + account: string | null + accessToken: string | null + apiToken: string | null +} + +let cachedMetadata: BitbucketStoredMetadata | null = null +let metadataLoadedFromDisk = false +let cachedSecret: BitbucketStoredSecret | null = null +let credentialError: string | null = null + +function getOrcaDir(): string { + return join(homedir(), '.orca') +} + +function getMetadataPath(): string { + return join(getOrcaDir(), 'bitbucket-credential.json') +} + +function getSecretPath(): string { + return join(getOrcaDir(), 'bitbucket-credential.enc') +} + +function ensureOrcaDir(): void { + const dir = getOrcaDir() + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} + +// Why: hand-edited or truncated JSON must not put a non-string into an auth +// header, so every field is narrowed rather than trusted. +function asOptionalString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function readMetadataFromDisk(): BitbucketStoredMetadata | null { + const path = getMetadataPath() + if (!existsSync(path)) { + return null + } + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial + if (parsed.authMode !== 'token' && parsed.authMode !== 'basic') { + return null + } + return { + version: 1, + authMode: parsed.authMode, + email: asOptionalString(parsed.email), + baseUrl: asOptionalString(parsed.baseUrl), + account: asOptionalString(parsed.account), + updatedAt: asOptionalString(parsed.updatedAt) ?? '' + } + } catch { + return null + } +} + +export function getStoredBitbucketMetadata(): BitbucketStoredMetadata | null { + if (!metadataLoadedFromDisk) { + cachedMetadata = readMetadataFromDisk() + metadataLoadedFromDisk = true + } + return cachedMetadata +} + +// Cheap "is a credential saved?" check: metadata present and a non-empty secret +// file. Never decrypts, so it is safe on every status poll. +export function hasStoredBitbucketCredential(): boolean { + return getStoredBitbucketMetadata() !== null && credentialFileHasContent(getSecretPath()) +} + +export function getStoredBitbucketCredentialError(): string | null { + return credentialError +} + +// Cache-first; only touches the keychain when `force` is set and the secret is +// not already in memory (mirrors Linear's `loadToken({ force })`). Throws +// CredentialDecryptionError when ciphertext cannot be decrypted. +export function loadStoredBitbucketSecret( + options: { force?: boolean } = {} +): BitbucketStoredSecret | null { + if (cachedSecret !== null) { + return cachedSecret + } + if (!options.force) { + return null + } + const path = getSecretPath() + if (!existsSync(path)) { + return null + } + try { + const token = readStoredCredentialToken('Bitbucket', readFileSync(path)) + if (!token) { + return null + } + const parsed = JSON.parse(token) as Partial + cachedSecret = { + accessToken: asOptionalString(parsed.accessToken), + apiToken: asOptionalString(parsed.apiToken), + ...(parsed.authMode === 'token' || parsed.authMode === 'basic' + ? { authMode: parsed.authMode } + : {}), + email: asOptionalString(parsed.email), + baseUrl: asOptionalString(parsed.baseUrl) + } + credentialError = null + return cachedSecret + } catch (error) { + if (error instanceof CredentialDecryptionError) { + credentialError = error.message + throw error + } + return null + } +} + +export function saveBitbucketCredential(input: BitbucketCredentialSaveInput): void { + ensureOrcaDir() + const secret: BitbucketStoredSecret = { + accessToken: input.accessToken, + apiToken: input.apiToken, + authMode: input.authMode, + email: input.email, + baseUrl: input.baseUrl + } + writeEncryptedCredential('Bitbucket', getSecretPath(), JSON.stringify(secret)) + const metadata: BitbucketStoredMetadata = { + version: 1, + authMode: input.authMode, + email: input.email, + baseUrl: input.baseUrl, + account: input.account, + updatedAt: new Date().toISOString() + } + writeCredentialFileAtomic( + getMetadataPath(), + Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8') + ) + cachedMetadata = metadata + metadataLoadedFromDisk = true + cachedSecret = secret + credentialError = null +} + +// Why: swallowing a non-ENOENT unlink failure would clear memory while the +// files survive, so the credential silently returns on the next launch. +export function clearStoredBitbucketCredential(): void { + try { + for (const path of [getSecretPath(), getMetadataPath()]) { + try { + unlinkSync(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + } + } finally { + // Why: on a partial delete the caches must still be dropped, or the session + // keeps authenticating with a credential the user just disconnected. + cachedMetadata = null + metadataLoadedFromDisk = true + cachedSecret = null + credentialError = null + } +} + +/** @internal - tests need a clean in-memory cache between cases. */ +export function _resetBitbucketCredentialCache(): void { + cachedMetadata = null + metadataLoadedFromDisk = false + cachedSecret = null + credentialError = null +} diff --git a/src/main/bitbucket/pull-request-creation.test.ts b/src/main/bitbucket/pull-request-creation.test.ts new file mode 100644 index 00000000000..ab06200e22e --- /dev/null +++ b/src/main/bitbucket/pull-request-creation.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createBitbucketPullRequest, + isBitbucketReviewCreationAuthenticated +} from './pull-request-creation' +import { _resetBitbucketRepoRefCache } from './repository-ref' + +const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: () => 0 +})) + +vi.mock('../source-control/pull-request-template', () => ({ + readHostedPullRequestTemplate: vi.fn(async () => 'Template body') +})) + +const OLD_ENV = process.env +const OLD_FETCH = globalThis.fetch + +const CREATE_INPUT = { + provider: 'bitbucket', + base: 'main', + head: 'feature/login', + title: 'Add login' +} as const + +function createdPullRequestResponse(): Response { + return Response.json({ + id: 42, + title: 'Add login', + state: 'OPEN', + updated_on: '2026-08-11T00:00:00Z', + links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/42' } }, + source: { branch: { name: 'feature/login' } }, + destination: { branch: { name: 'main' } } + }) +} + +describe('Bitbucket pull request creation', () => { + beforeEach(() => { + process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' } + gitExecFileAsyncMock.mockReset() + getSshGitProviderMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://bitbucket.org/team/repo.git\n', + stderr: '' + }) + _resetBitbucketRepoRefCache() + }) + + afterEach(() => { + process.env = OLD_ENV + globalThis.fetch = OLD_FETCH + _resetBitbucketRepoRefCache() + }) + + it('posts the Bitbucket source/destination create body to the repository endpoint', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)) + expect(url.origin).toBe('https://api.bitbucket.org') + expect(url.pathname).toBe('/2.0/repositories/team/repo/pullrequests') + const requestInit = init! + expect(requestInit.method).toBe('POST') + expect((requestInit.headers as Record).Authorization).toBe('Bearer bb-token') + expect(JSON.parse(String(requestInit.body))).toEqual({ + title: 'Add login', + description: '', + source: { branch: { name: 'feature/login' } }, + destination: { branch: { name: 'main' } } + }) + return createdPullRequestResponse() + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await expect(createBitbucketPullRequest('/repo', CREATE_INPUT)).resolves.toEqual({ + ok: true, + number: 42, + url: 'https://bitbucket.org/team/repo/pull-requests/42' + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('uses the stored credential when no environment variable is set', async () => { + delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN + const fetchMock = vi.fn(async () => createdPullRequestResponse()) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const result = await createBitbucketPullRequest('/repo', CREATE_INPUT) + + // No env var and no stored credential: fail closed rather than POST anonymously. + expect(result).toMatchObject({ ok: false, code: 'auth_required' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('ignores a draft request rather than dead-ending a hidden persisted default', async () => { + const fetchMock = vi.fn(async () => createdPullRequestResponse()) + globalThis.fetch = fetchMock as unknown as typeof fetch + + // Why: the composer hides the Draft toggle for Bitbucket, so a `true` here + // is an unreachable persisted default the user cannot clear. + await expect( + createBitbucketPullRequest('/repo', { ...CREATE_INPUT, draft: true }) + ).resolves.toMatchObject({ ok: true, number: 42 }) + expect(JSON.parse(String((fetchMock.mock.calls[0] as never[])[1]['body']))).not.toHaveProperty( + 'draft' + ) + }) + + it('reports authenticated only when a credential resolves', async () => { + expect(isBitbucketReviewCreationAuthenticated()).toBe(true) + delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN + expect(isBitbucketReviewCreationAuthenticated()).toBe(false) + }) + + it('maps a duplicate-branch rejection to already_exists with the existing review', async () => { + let call = 0 + const fetchMock = vi.fn(async () => { + call += 1 + if (call === 1) { + return Response.json( + { error: { message: 'There is already a pull request for this branch.' } }, + { status: 400 } + ) + } + return Response.json({ + values: [ + { + id: 7, + title: 'Add login', + state: 'OPEN', + updated_on: '2026-08-11T00:00:00Z', + links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/7' } }, + source: { branch: { name: 'feature/login' } }, + destination: { branch: { name: 'main' } } + } + ] + }) + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + expect(await createBitbucketPullRequest('/repo', CREATE_INPUT)).toMatchObject({ + ok: false, + code: 'already_exists', + existingReview: { number: 7, url: 'https://bitbucket.org/team/repo/pull-requests/7' } + }) + }) + + it('maps a 401 to auth_required pointing at both credential paths', async () => { + globalThis.fetch = vi.fn(async () => + Response.json({ error: { message: 'Unauthorized' } }, { status: 401 }) + ) as unknown as typeof fetch + + const result = await createBitbucketPullRequest('/repo', CREATE_INPUT) + + expect(result).toMatchObject({ ok: false, code: 'auth_required' }) + expect(!result.ok && result.error).toContain('Settings') + }) + + it('refuses a non-Bitbucket remote', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://github.com/team/repo.git\n', + stderr: '' + }) + globalThis.fetch = vi.fn() as unknown as typeof fetch + + await expect(createBitbucketPullRequest('/repo', CREATE_INPUT)).resolves.toMatchObject({ + ok: false, + code: 'unsupported_provider' + }) + }) +}) diff --git a/src/main/bitbucket/pull-request-creation.ts b/src/main/bitbucket/pull-request-creation.ts new file mode 100644 index 00000000000..3d5e11f8392 --- /dev/null +++ b/src/main/bitbucket/pull-request-creation.ts @@ -0,0 +1,226 @@ +import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' +import { + normalizeHostedReviewBaseRef, + normalizeHostedReviewHeadRef +} from '../../shared/hosted-review-refs' +import { + HostedReviewApiRequestError, + requestHostedReviewJson +} from '../source-control/hosted-review-api-request' +import { readHostedPullRequestTemplate } from '../source-control/pull-request-template' +import { authHeaders, getEnvAuthConfig, hasAuth } from './bitbucket-auth-config' +import { resolveBitbucketAuthConfig } from './resolve-auth' +import { hasStoredBitbucketCredential } from './credential-store' +import { getBitbucketPullRequestForBranch } from './client' +import { mapBitbucketPullRequest, type RawBitbucketPullRequest } from './pull-request-mappers' +import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref' +import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' +import { getHostedReviewLocalGitOptions } from '../source-control/hosted-review-git-options' + +const CREATE_REQUEST_TIMEOUT_MS = 60_000 + +// Why: eligibility is evaluated proactively for the sidebar, so this must not +// decrypt — forcing the secret open here would pop an OS keychain prompt just +// from opening a worktree. Presence is enough; the create call itself still +// fails closed if the credential turns out to be unusable. +export function isBitbucketReviewCreationAuthenticated(): boolean { + return hasAuth(getEnvAuthConfig()) || hasStoredBitbucketCredential() +} + +function encodedRepoPath(repo: BitbucketRepoRef): string { + return `${encodeURIComponent(repo.workspace)}/${encodeURIComponent(repo.repoSlug)}` +} + +function apiErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function classifyCreateError(error: unknown): CreateHostedReviewResult { + const message = apiErrorMessage(error) + if (message) { + console.warn('createBitbucketPullRequest failed:', message) + } + const lower = message.toLowerCase() + const status = error instanceof HostedReviewApiRequestError ? error.status : null + if (status === 401 || status === 403 || lower.includes('unauthorized')) { + return { + ok: false, + code: 'auth_required', + error: + 'Create PR failed: Bitbucket is not authenticated. Next step: connect Bitbucket in Settings > Integrations, or set ORCA_BITBUCKET_* in this environment.' + } + } + // Bitbucket answers a duplicate source branch with 400 plus this phrasing + // rather than a 409, so the text is the only reliable signal. + if (lower.includes('already exists') || lower.includes('already a pull request')) { + return { + ok: false, + code: 'already_exists', + error: 'A pull request already exists for this branch.' + } + } + if (error instanceof HostedReviewApiRequestError && error.timedOut) { + return { + ok: false, + code: 'unknown_completion', + error: 'PR creation may have completed. Refreshing branch review state...' + } + } + if (status === 400 || status === 422) { + return { + ok: false, + code: 'validation', + error: + 'Create PR failed: Bitbucket rejected the pull request. Check the base branch and branch state, then try again.' + } + } + if (status === 404) { + return { + ok: false, + code: 'validation', + error: + 'Create PR failed: Bitbucket could not find the repository. Check that the credential has write access to it.' + } + } + return { + ok: false, + code: 'unknown', + error: 'Create PR failed: Bitbucket could not create the pull request. Try again in a moment.' + } +} + +async function findExistingPullRequest( + repoPath: string, + head: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise<{ number: number; url: string } | null> { + const existing = await getBitbucketPullRequestForBranch( + repoPath, + head, + null, + connectionId, + options + ) + return existing ? { number: existing.number, url: existing.url } : null +} + +export async function createBitbucketPullRequest( + repoPath: string, + input: CreateHostedReviewInput, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + if (input.provider !== 'bitbucket') { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating reviews for this provider is not supported yet.' + } + } + + const config = resolveBitbucketAuthConfig() + if (!hasAuth(config)) { + return { + ok: false, + code: 'auth_required', + error: + 'Create PR failed: Bitbucket is not connected. Next step: connect Bitbucket in Settings > Integrations.' + } + } + + const repo = await getBitbucketRepoRef( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) + if (!repo) { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating pull requests requires a Bitbucket remote.' + } + } + + const base = normalizeHostedReviewBaseRef(input.base) + const head = input.head ? normalizeHostedReviewHeadRef(input.head) : '' + const title = input.title.trim() + if (!base || !head || !title) { + return { + ok: false, + code: 'validation', + error: 'Create PR failed: base branch, head branch, and title are required.' + } + } + if (head.toLowerCase() === base.toLowerCase()) { + return { + ok: false, + code: 'validation', + error: 'Create PR failed: choose a different base branch before creating a pull request.' + } + } + // Why: Bitbucket Cloud has no draft pull requests and the composer hides the + // toggle, so `draft` here can only be an unreachable persisted default — + // rejecting it would dead-end the user with no control to clear. + const description = + input.useTemplate && !input.body?.trim() + ? await readHostedPullRequestTemplate(repoPath, connectionId) + : (input.body ?? '') + const requestBody = { + title, + description, + source: { branch: { name: head } }, + destination: { branch: { name: base } } + } + + try { + const raw = await requestHostedReviewJson( + new URL( + `${config.baseUrl.replace(/\/+$/, '')}/repositories/${encodedRepoPath(repo)}/pullrequests` + ), + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...authHeaders(config) + }, + body: JSON.stringify(requestBody) + }, + CREATE_REQUEST_TIMEOUT_MS + ) + const created = mapBitbucketPullRequest(raw, 'neutral') + if (created) { + return { ok: true, number: created.number, url: created.url } + } + const found = await findExistingPullRequest(repoPath, head, connectionId, options).catch( + () => null + ) + return found + ? { ok: true, ...found } + : { + ok: false, + code: 'unknown_completion', + error: 'PR creation may have completed. Refreshing branch review state...' + } + } catch (error) { + const classified = classifyCreateError(error) + if ( + !classified.ok && + (classified.code === 'already_exists' || classified.code === 'unknown_completion') + ) { + const existing = await findExistingPullRequest(repoPath, head, connectionId, options).catch( + () => null + ) + if (existing) { + return { + ok: false, + code: 'already_exists', + error: 'A pull request already exists for this branch.', + existingReview: existing + } + } + } + return classified + } +} diff --git a/src/main/bitbucket/resolve-auth.ts b/src/main/bitbucket/resolve-auth.ts new file mode 100644 index 00000000000..4a90f22780e --- /dev/null +++ b/src/main/bitbucket/resolve-auth.ts @@ -0,0 +1,53 @@ +import { + DEFAULT_API_BASE_URL, + envValue, + getEnvAuthConfig, + hasAuth, + type BitbucketAuthConfig +} from './bitbucket-auth-config' +import { + getStoredBitbucketMetadata, + loadStoredBitbucketSecret, + type BitbucketStoredMetadata, + type BitbucketStoredSecret +} from './credential-store' + +/** + * Why (STA-3941): the encrypted envelope is authoritative for auth. Plaintext + * metadata is only consulted for credentials written before the envelope + * carried these fields, so a torn write between the two files degrades to stale + * display data rather than a secret paired with the wrong email. + */ +export function storedAuthConfig( + metadata: BitbucketStoredMetadata | null, + secret: BitbucketStoredSecret +): BitbucketAuthConfig { + const authMode = secret.authMode ?? metadata?.authMode ?? null + const email = secret.authMode ? (secret.email ?? null) : (metadata?.email ?? null) + const baseUrl = secret.authMode ? (secret.baseUrl ?? null) : (metadata?.baseUrl ?? null) + return { + // Why: an explicit ORCA_BITBUCKET_API_BASE_URL still wins even when the + // credential itself is stored — env precedence is per-setting, not all-or-nothing. + baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? baseUrl ?? DEFAULT_API_BASE_URL, + accessToken: authMode === 'token' ? secret.accessToken : null, + email: authMode === 'basic' ? email : null, + apiToken: authMode === 'basic' ? secret.apiToken : null + } +} + +// Env vars win over in-app credentials so existing headless/SSH setups keep +// working unchanged. The stored secret is decrypted lazily and only here, on a +// real API call — never on a status read. +export function resolveBitbucketAuthConfig(): BitbucketAuthConfig { + const env = getEnvAuthConfig() + if (hasAuth(env)) { + return env + } + try { + const secret = loadStoredBitbucketSecret({ force: true }) + return secret ? storedAuthConfig(getStoredBitbucketMetadata(), secret) : env + } catch { + // Decryption denied or unavailable: fall through as unauthenticated. + return env + } +} diff --git a/src/main/bitbucket/status-no-decrypt.test.ts b/src/main/bitbucket/status-no-decrypt.test.ts new file mode 100644 index 00000000000..946f1aea4aa --- /dev/null +++ b/src/main/bitbucket/status-no-decrypt.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: opening Settings renders the Bitbucket card, which runs the preflight +// status (getBitbucketAuthStatus) and the card's status IPC +// (getBitbucketConnectionStatus). Neither may decrypt the stored secret, or the +// OS would prompt to unlock the keychain on every Settings open. These tests +// simulate a cold session (files on disk, memory cache empty). + +const decryptSpy = vi.fn((value: Buffer) => value.toString('utf-8')) +const OLD_ENV = process.env +const OLD_FETCH = globalThis.fetch +let tempHome = '' + +vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) + +async function loadModules() { + vi.resetModules() + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: decryptSpy + } + })) + vi.doMock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => tempHome } + }) + // Stub repo detection so the PR fetch reaches the auth-resolution path. + vi.doMock('./repository-ref', () => ({ + getBitbucketRepoRef: async () => ({ workspace: 'acme', repoSlug: 'app' }) + })) + const store = await import('./credential-store') + const client = await import('./client') + const connection = await import('./credential-connection') + return { store, client, connection } +} + +beforeEach(() => { + process.env = { ...OLD_ENV } + for (const key of [ + 'ORCA_BITBUCKET_ACCESS_TOKEN', + 'ORCA_BITBUCKET_EMAIL', + 'ORCA_BITBUCKET_API_TOKEN', + 'ORCA_BITBUCKET_API_BASE_URL' + ]) { + delete process.env[key] + } + tempHome = mkdtempSync(join(tmpdir(), 'orca-bb-nodecrypt-')) + decryptSpy.mockClear() +}) + +afterEach(() => { + process.env = OLD_ENV + globalThis.fetch = OLD_FETCH +}) + +const STORED_BASIC_CREDENTIAL = { + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' +} as const + +describe('Bitbucket status reads never decrypt the stored secret', () => { + it('renders connected state from plaintext metadata without touching the keychain', async () => { + const { store, client, connection } = await loadModules() + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL) + + // Simulate relaunch: secret is on disk but the memory cache is cold, so a + // decrypt here would surface to the user as a keychain prompt. + store._resetBitbucketCredentialCache() + decryptSpy.mockClear() + + const auth = await client.getBitbucketAuthStatus() + const status = connection.getBitbucketConnectionStatus() + + expect(auth).toEqual({ configured: true, authenticated: true, account: 'ada' }) + expect(status).toMatchObject({ source: 'stored', account: 'ada', authMode: 'basic' }) + expect(decryptSpy).not.toHaveBeenCalled() + // A stored credential is trusted from metadata rather than revalidated. + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('stays cold across repeated status reads, then decrypts once for a real API call', async () => { + const { store, client, connection } = await loadModules() + store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL) + store._resetBitbucketCredentialCache() + decryptSpy.mockClear() + + for (let i = 0; i < 3; i += 1) { + await client.getBitbucketAuthStatus() + connection.getBitbucketConnectionStatus() + } + expect(decryptSpy).not.toHaveBeenCalled() + + globalThis.fetch = vi.fn(async () => Response.json({ values: [] })) as unknown as typeof fetch + await client.getBitbucketPullRequest('/repo', 1, null) + expect(decryptSpy).toHaveBeenCalledTimes(1) + }) + + it('revalidates a stored credential once its secret is already warm in memory', async () => { + const { store, client } = await loadModules() + store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL) + decryptSpy.mockClear() + + // Saving leaves the secret cached, so status can check /user for free. + const fetchSpy = vi.fn(async () => new Response(null, { status: 401 })) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await expect(client.getBitbucketAuthStatus()).resolves.toEqual({ + configured: true, + authenticated: false, + account: 'ada' + }) + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(decryptSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/bitbucket/user-request.ts b/src/main/bitbucket/user-request.ts new file mode 100644 index 00000000000..bc9d344e5ca --- /dev/null +++ b/src/main/bitbucket/user-request.ts @@ -0,0 +1,65 @@ +import { authHeaders, type BitbucketAuthConfig } from './bitbucket-auth-config' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' + +const USER_REQUEST_TIMEOUT_MS = 4000 + +export type RawBitbucketUser = { + username?: string | null + display_name?: string | null + account_id?: string | null +} + +/** + * Why (STA-3944): a timeout, DNS failure, 5xx, or unparseable body says nothing + * about the credential. Collapsing those to the same miss as a 401 told users + * their token was invalid when the network was simply unreachable. + */ +export type BitbucketUserResult = + | { ok: true; user: RawBitbucketUser } + | { ok: false; reason: 'rejected' | 'unreachable' } + +export function accountNameFromUser(user: RawBitbucketUser | null): string | null { + return user?.username ?? user?.display_name ?? user?.account_id ?? null +} + +// Shared by live env-var status checks and by connect-time verification, so a +// credential is proven against `/user` before it is ever persisted. +export async function fetchBitbucketUserResult( + config: BitbucketAuthConfig, + timeoutMs: number = USER_REQUEST_TIMEOUT_MS +): Promise { + let response: Response + try { + const base = config.baseUrl.replace(/\/+$/, '') + response = await fetch(`${base}/user`, { + headers: { + Accept: 'application/json', + ...authHeaders(config) + }, + signal: AbortSignal.timeout(timeoutMs) + }) + } catch { + return { ok: false, reason: 'unreachable' } + } + if (!response.ok) { + await cancelUnreadResponseBody(response) + // Only the credential-bearing statuses are the credential's fault; 5xx and + // the rest are the server or the path in between. + const rejected = response.status === 401 || response.status === 403 + return { ok: false, reason: rejected ? 'rejected' : 'unreachable' } + } + try { + return { ok: true, user: (await response.json()) as RawBitbucketUser } + } catch { + return { ok: false, reason: 'unreachable' } + } +} + +/** Convenience for callers that only need the account, not the failure reason. */ +export async function fetchBitbucketUser( + config: BitbucketAuthConfig, + timeoutMs: number = USER_REQUEST_TIMEOUT_MS +): Promise { + const result = await fetchBitbucketUserResult(config, timeoutMs) + return result.ok ? result.user : null +} diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index b9f5a9bd92d..125949f3a50 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -74,8 +74,8 @@ type ExecFileCallback = (error: unknown, stdout?: string, stderr?: string) => vo } function mockBrowserManager( - tabs: Map = new Map([['tab-1', 100]]), - worktrees: Map = new Map(), + tabs = new Map([['tab-1', 100]]), + worktrees = new Map(), overrides: Partial = {} ): BrowserManager { return { diff --git a/src/main/browser/browser-cookie-import-google-exclusion.test.ts b/src/main/browser/browser-cookie-import-google-exclusion.test.ts new file mode 100644 index 00000000000..d1e13737b1d --- /dev/null +++ b/src/main/browser/browser-cookie-import-google-exclusion.test.ts @@ -0,0 +1,320 @@ +/** + * STA-3811: an import must never write and never remove a google.com-family cookie, on any + * path. Removing 'google.com' from NON_TRANSPLANTABLE_DOMAINS flips every test here red. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + appGetPathMock, + clearPendingCookieImportMock, + execFileSyncMock, + sessionFromPartitionMock, + setPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + clearPendingCookieImportMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + setPendingCookieImportMock: vi.fn() +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + dialog: { showOpenDialog: vi.fn() }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +function existingCookie(domain: string, name: string) { + return { domain, name, path: '/', secure: true, sameSite: 'unspecified', value: 'live' } +} + +const INCOMING = [ + { domain: '.google.com', name: 'SAPISID', value: 'transplanted', secure: true }, + { domain: 'accounts.google.com', name: '__Secure-1PSID', value: 'transplanted', secure: true }, + { domain: '.youtube.com', name: 'LOGIN_INFO', value: 'transplanted', secure: true }, + { domain: '.linear.app', name: 'session', value: 'new', secure: true } +] + +describe('file import excludes the Google cookie family', () => { + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + + beforeEach(() => { + vi.clearAllMocks() + tmpDir = mkdtempSync(join(tmpdir(), 'orca-google-exclusion-file-')) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReturnValue({ + cookies: { get: cookiesGetMock, remove: cookiesRemoveMock, set: cookiesSetMock } + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeCookies(cookies: unknown[]): string { + const filePath = join(tmpDir, 'cookies.json') + writeFileSync(filePath, JSON.stringify(cookies)) + return filePath + } + + it('writes no Google cookie and removes none, whatever is already in the jar', async () => { + cookiesGetMock.mockResolvedValue([ + existingCookie('.google.com', 'SID'), + existingCookie('accounts.google.com', '__Secure-1PSID'), + existingCookie('.linear.app', 'old-linear') + ]) + + const result = await importCookiesFromFile(writeCookies(INCOMING), 'persist:test') + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 4, + importedCookies: 2, + skippedCookies: 2, + googleCookiesSkipped: 2, + domains: ['linear.app', 'youtube.com'] + }) + expect(cookiesRemoveMock.mock.calls).toEqual([['https://linear.app/', 'old-linear']]) + expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual([ + '.youtube.com', + '.linear.app' + ]) + }) + + it('leaves Google cookies from an older import in place too', async () => { + cookiesGetMock.mockResolvedValue([existingCookie('.google.com', 'SAPISID')]) + + const result = await importCookiesFromFile( + writeCookies([{ domain: '.google.com', name: 'SAPISID', value: 'newer', secure: true }]), + 'persist:test' + ) + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 1, + importedCookies: 0, + skippedCookies: 1, + googleCookiesSkipped: 1, + domains: [] + }) + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) +}) + +describe('native Chromium import excludes the Google cookie family', () => { + let clearStorageDataMock: ReturnType + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + let platformSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + tmpDir = mkdtempSync(join(tmpdir(), 'orca-google-exclusion-native-')) + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + execFileSyncMock.mockImplementation(() => { + throw new Error('OS browser version lookup unavailable') + }) + clearStorageDataMock = vi.fn().mockResolvedValue(undefined) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReturnValue({ + cookies: { + flushStore: vi.fn().mockResolvedValue(undefined), + get: cookiesGetMock, + remove: cookiesRemoveMock, + set: cookiesSetMock + }, + clearStorageData: clearStorageDataMock + }) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + }) + + afterEach(() => { + platformSpy.mockRestore() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function seedSource(rows: { domain: string; name: string; value: string }[]): string { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, rows).close() + return sourceCookiesPath + } + + function seedTarget(rows: { domain: string; name: string; value: string }[]): void { + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(targetCookiesPath, rows).close() + } + + it('never clears or reconstructs live Google cookies before importing', async () => { + const sourceCookiesPath = seedSource([ + { domain: '.google.com', name: 'SID', value: 'transplanted-sid' }, + { domain: '.example.com', name: 'session', value: 'new' } + ]) + seedTarget([{ domain: '.google.com', name: 'SID', value: 'live-sid' }]) + cookiesGetMock.mockResolvedValue([ + existingCookie('.google.com', 'SID'), + existingCookie('.example.com', 'stale') + ]) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 2, + importedCookies: 1, + skippedCookies: 1, + googleCookiesSkipped: 1, + domains: ['example.com'] + }) + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).toHaveBeenCalledOnce() + expect(cookiesRemoveMock).toHaveBeenCalledWith('https://example.com/', 'stale') + expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual(['.example.com']) + }) + + it('reports an excluded Google row even when its encrypted value is invalid', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { + domain: '.google.com', + name: 'SID', + value: '', + encryptedValue: Buffer.from('v10-invalid') + }, + { domain: '.example.com', name: 'session', value: 'new' } + ]).close() + seedTarget([]) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 2, + importedCookies: 1, + skippedCookies: 1, + googleCookiesSkipped: 1 + }) + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['session']) + }) + + it('does not request an encryption key for excluded Google rows', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { + domain: '.google.com', + name: 'SID', + value: '', + encryptedValue: Buffer.from('v10-invalid') + } + ]).close() + seedTarget([]) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok && result.summary).toEqual({ + totalCookies: 1, + importedCookies: 0, + skippedCookies: 1, + googleCookiesSkipped: 1, + domains: [] + }) + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('keeps the live Google rows in the staged restart-fallback database', async () => { + const sourceCookiesPath = seedSource([ + { domain: '.google.com', name: 'SID', value: 'transplanted-sid' }, + { domain: '.example.com', name: 'session', value: 'new' } + ]) + seedTarget([ + { domain: '.google.com', name: 'SID', value: 'live-sid' }, + { domain: '.example.com', name: 'stale', value: 'stale' } + ]) + // Why: a rejected set() forces the staged DB to be kept for cold-restart replay. + cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + expect(setPendingCookieImportMock).toHaveBeenCalledTimes(1) + const [partition, stagedPath] = setPendingCookieImportMock.mock.calls[0] + expect(partition).toBe('persist:test') + expect(readStagedRows(stagedPath)).toEqual([ + { host_key: '.example.com', name: 'session', value: 'new' }, + { host_key: '.google.com', name: 'SID', value: 'live-sid' } + ]) + }) + + it('fails the import without reconstructing Google cookies when selective removal rejects', async () => { + const sourceCookiesPath = seedSource([ + { domain: '.example.com', name: 'session', value: 'new' } + ]) + seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }]) + cookiesGetMock.mockResolvedValue([ + existingCookie('.google.com', 'SID'), + existingCookie('.example.com', 'removed-first'), + existingCookie('.other.test', 'stale') + ]) + cookiesRemoveMock.mockImplementation(async (_url: string, name: string) => { + if (name === 'stale') { + throw new Error('cookie store unavailable') + } + }) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result).toMatchObject({ ok: false }) + expect(result.ok || result.reason).toContain('Could not clear existing cookies') + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock.mock.calls.map(([, name]) => name)).toEqual(['removed-first', 'stale']) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['removed-first']) + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + }) +}) + +// Why: freshly imported rows store the decrypted value as a BLOB; read both forms as text. +function readStagedRows(stagedPath: string): { host_key: string; name: string; value: string }[] { + const stagedDb = new DatabaseSync(stagedPath, { readOnly: true }) + const rows = stagedDb + .prepare('SELECT host_key, name, value FROM cookies ORDER BY host_key, name') + .all() as { host_key: string; name: string; value: string | Uint8Array }[] + stagedDb.close() + return rows.map((row) => ({ + ...row, + value: typeof row.value === 'string' ? row.value : Buffer.from(row.value).toString('latin1') + })) +} + +function chromeBrowser(cookiesPath: string) { + return { + family: 'chrome' as const, + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} diff --git a/src/main/browser/browser-cookie-import-partition.electron.test.ts b/src/main/browser/browser-cookie-import-partition.electron.test.ts new file mode 100644 index 00000000000..494ebbd9e2b --- /dev/null +++ b/src/main/browser/browser-cookie-import-partition.electron.test.ts @@ -0,0 +1,194 @@ +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 } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' + +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +type FixtureResult = { + before: Record + after: Record + electronCookieKeys: string[] + importResult: Record + remainingNames: string[] +} + +function buildFixtureMain( + importPath: string, + resultPath: string, + sourceCookiesPath: string +): string { + return ` +const { app, BrowserWindow, session } = require('electron') +const { writeFileSync } = require('node:fs') +const { importCookiesFromBrowser } = require(${JSON.stringify(importPath)}) +const resultPath = ${JSON.stringify(resultPath)} +let currentStep = 'starting' +const mark = (step) => { + currentStep = step + writeFileSync(resultPath, JSON.stringify({ step })) +} + +async function run() { + const timeout = setTimeout(() => { + writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep })) + app.exit(1) + }, 15000) + await app.whenReady() + mark('ready') + const partition = 'persist:partition-cookie-test' + const targetSession = session.fromPartition(partition) + const window = new BrowserWindow({ show: false, webPreferences: { partition } }) + mark('window created') + await window.loadURL('data:text/html,cookie fixture') + mark('window loaded') + const debug = window.webContents.debugger + debug.attach('1.3') + mark('debugger attached') + await debug.sendCommand('Network.enable') + mark('network enabled') + await debug.sendCommand('Network.setCookie', { + url: 'https://accounts.google.com/', + name: 'partitioned-google', + value: 'live-value', + secure: true, + httpOnly: true, + sameSite: 'None', + partitionKey: { topLevelSite: 'https://example.com', hasCrossSiteAncestor: true } + }) + mark('partitioned cookie set') + await targetSession.cookies.set({ + url: 'https://stale.example/', + name: 'stale', + value: 'remove-me', + secure: true + }) + mark('ordinary cookie set') + + const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies + const before = beforeCookies.find((cookie) => cookie.name === 'partitioned-google') + const electronCookie = (await targetSession.cookies.get({ name: 'partitioned-google' }))[0] + if (!before || !electronCookie) throw new Error('Partitioned fixture cookie was not created') + mark('cookies read') + + const importResult = await importCookiesFromBrowser({ + family: 'chrome', + label: 'Chromium fixture', + cookiesPath: ${JSON.stringify(sourceCookiesPath)}, + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + }, partition) + if (!importResult.ok) throw new Error('Native import failed: ' + importResult.reason) + mark('native import complete') + + const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies + const after = afterCookies.find((cookie) => cookie.name === 'partitioned-google') + if (!after) throw new Error('Partitioned Google cookie was removed') + clearTimeout(timeout) + writeFileSync(resultPath, JSON.stringify({ + before, + after, + electronCookieKeys: Object.keys(electronCookie).sort(), + importResult, + remainingNames: afterCookies.map((cookie) => cookie.name).sort() + })) + debug.detach() + window.destroy() + app.exit(0) +} + +run().catch((error) => { + writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) })) + app.exit(1) +}) +` +} + +async function runFixture(): Promise { + const root = mkdtempSync(join(tmpdir(), 'orca-partition-cookie-')) + fixtureRoots.push(root) + const importPath = join(root, 'browser-cookie-import.cjs') + const registryStubPath = join(root, 'browser-session-registry.cjs') + const resultPath = join(root, 'result.json') + const sourceCookiesPath = join(root, 'source', 'Network', 'Cookies') + const fixturePath = join(root, 'main.cjs') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.example.com', name: 'imported', value: 'new-value' } + ]).close() + writeFileSync( + registryStubPath, + 'exports.browserSessionRegistry = { clearPendingCookieImport() {}, setPendingCookieImport() {} }\n' + ) + await buildVite({ + configFile: false, + logLevel: 'silent', + plugins: [ + { + name: 'stub-browser-session-registry', + resolveId(source, importer) { + return source === './browser-session-registry' && + importer?.endsWith('browser-cookie-import.ts') + ? registryStubPath + : null + } + } + ], + build: { + emptyOutDir: false, + lib: { + entry: join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'), + formats: ['cjs'], + fileName: () => 'browser-cookie-import.cjs' + }, + outDir: root, + target: 'node20', + rollupOptions: { external: ['electron', /^node:/] } + } + }) + writeFileSync(fixturePath, buildFixtureMain(importPath, resultPath, sourceCookiesPath)) + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`] + const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary + const args = + process.platform === 'linux' + ? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox'] + : electronArgs + const run = spawnSync(executable, args, { + encoding: 'utf8', + env, + timeout: 60_000 + }) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect(run.error).toBeUndefined() + expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0) + return JSON.parse(fixtureResult) as FixtureResult +} + +describe('native Chromium excluded partition cookie under Electron', () => { + it('survives selective clearing unchanged without a lossy Electron reconstruction', async () => { + const result = await runFixture() + + expect(result.before.partitionKey).toEqual({ + topLevelSite: 'https://example.com', + hasCrossSiteAncestor: true + }) + expect(result.electronCookieKeys).not.toContain('partitionKey') + expect(result.after).toEqual(result.before) + expect(result.importResult).toMatchObject({ + ok: true, + summary: { importedCookies: 1, domains: ['example.com'] } + }) + expect(result.remainingNames).toEqual(['imported', 'partitioned-google']) + }, 90_000) +}) diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts index 31ca329bf8f..96d023fd2f5 100644 --- a/src/main/browser/browser-cookie-import-policy.test.ts +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from 'vitest' +import { DatabaseSync } from 'node:sqlite' import type { Cookie } from 'electron' import { isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, + removeAllCookiesExcept, replaceCookiesForImportedDomains } from './browser-cookie-import-policy' @@ -147,3 +151,166 @@ describe('replaceCookiesForImportedDomains', () => { }) }) }) + +describe('isNonTransplantableCookieDomain', () => { + it('covers the whole google.com registrable family', () => { + expect(isNonTransplantableCookieDomain('google.com')).toBe(true) + expect(isNonTransplantableCookieDomain('.google.com')).toBe(true) + expect(isNonTransplantableCookieDomain('accounts.google.com')).toBe(true) + expect(isNonTransplantableCookieDomain('MAIL.Google.Com')).toBe(true) + }) + + it('does not match lookalikes or unrelated sites', () => { + expect(isNonTransplantableCookieDomain('withgoogle.com')).toBe(false) + expect(isNonTransplantableCookieDomain('google.com.evil.example')).toBe(false) + expect(isNonTransplantableCookieDomain('notgoogle.com')).toBe(false) + expect(isNonTransplantableCookieDomain('linear.app')).toBe(false) + expect(isNonTransplantableCookieDomain('')).toBe(false) + }) + + // Why: youtube.com re-issues its cookies from a transplanted session, so excluding it would + // drop imports users asked for. Locking it in keeps a future "just add it too" edit honest. + it('deliberately leaves youtube.com transplantable', () => { + expect(isNonTransplantableCookieDomain('.youtube.com')).toBe(false) + expect(isNonTransplantableCookieDomain('accounts.youtube.com')).toBe(false) + }) +}) + +describe('NON_TRANSPLANTABLE_HOST_KEY_SQL', () => { + it('selects the google.com family and nothing that merely looks like it', () => { + const db = new DatabaseSync(':memory:') + db.exec('CREATE TABLE cookies (host_key TEXT)') + for (const hostKey of [ + 'google.com', + '.google.com', + 'accounts.google.com', + 'withgoogle.com', + 'google.com.evil.example', + '.youtube.com', + '.linear.app' + ]) { + db.prepare('INSERT INTO cookies (host_key) VALUES (?)').run(hostKey) + } + + const matched = db + .prepare( + `SELECT host_key FROM cookies WHERE ${NON_TRANSPLANTABLE_HOST_KEY_SQL} ORDER BY host_key` + ) + .all() as { host_key: string }[] + db.close() + + expect(matched.map((row) => row.host_key)).toEqual([ + '.google.com', + 'accounts.google.com', + 'google.com' + ]) + }) +}) + +describe('removeAllCookiesExcept', () => { + it('never removes or reconstructs excluded Google cookies', async () => { + const get = vi + .fn() + .mockResolvedValue([ + cookie('.google.com', 'SID'), + cookie('accounts.google.com', 'ACCOUNT'), + cookie('.example.com', 'session'), + cookie('other.test', 'tracker', '/scoped') + ]) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + await removeAllCookiesExcept({ get, remove, set }, (existingCookie) => + isNonTransplantableCookieDomain(existingCookie.domain ?? '') + ) + + expect(remove.mock.calls).toEqual([ + ['https://example.com/', 'session'], + ['https://other.test/scoped', 'tracker'] + ]) + expect(set).not.toHaveBeenCalled() + }) + + it('restores removed non-Google cookies when another removal fails', async () => { + const get = vi + .fn() + .mockResolvedValue([ + cookie('.google.com', 'SID'), + cookie('.example.com', 'first', '/one'), + cookie('.example.com', 'second', '/two'), + cookie('.example.com', 'third', '/three') + ]) + const remove = vi.fn().mockImplementation(async (_url: string, name: string) => { + if (name === 'second') { + throw new Error('store unavailable') + } + }) + const set = vi.fn().mockResolvedValue(undefined) + + await expect( + removeAllCookiesExcept({ get, remove, set }, (existingCookie) => + isNonTransplantableCookieDomain(existingCookie.domain ?? '') + ) + ).rejects.toThrow('Could not clear existing cookies') + expect(remove).toHaveBeenCalledTimes(3) + expect(set.mock.calls.map(([details]) => details.name).sort()).toEqual(['first', 'third']) + }) + + it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => { + const get = vi + .fn() + .mockResolvedValue( + Array.from({ length: 12 }, (_, index) => cookie('.example.com', `${index}`)) + ) + let releaseRemovals: (() => void) | undefined + const removalsReleased = new Promise((resolve) => { + releaseRemovals = resolve + }) + let active = 0 + let maxActive = 0 + const remove = vi.fn().mockImplementation(async () => { + active++ + maxActive = Math.max(maxActive, active) + await removalsReleased + active-- + }) + const set = vi.fn().mockResolvedValue(undefined) + + const clearing = removeAllCookiesExcept({ get, remove, set }, () => false) + await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8)) + expect(maxActive).toBe(8) + releaseRemovals?.() + await clearing + + expect(remove).toHaveBeenCalledTimes(12) + expect(set).not.toHaveBeenCalled() + }) + + it('serializes cookies that share Electron removal coordinates', async () => { + const get = vi + .fn() + .mockResolvedValue([ + cookie('.example.com', 'session'), + { ...cookie('example.com', 'session'), hostOnly: true } + ]) + let releaseFirst: (() => void) | undefined + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve + }) + const remove = vi + .fn() + .mockImplementationOnce(() => firstReleased) + .mockResolvedValueOnce(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const clearing = removeAllCookiesExcept({ get, remove, set }, () => false) + await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) + releaseFirst?.() + await clearing + + expect(remove.mock.calls).toEqual([ + ['https://example.com/', 'session'], + ['https://example.com/', 'session'] + ]) + }) +}) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index 216542f08ea..4fa7f44ddd9 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -1,5 +1,6 @@ import type { Cookie, Cookies } from 'electron' import { parse as parseDomain } from 'psl' +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([ 'SIDCC', @@ -53,6 +54,36 @@ export function normalizeCookieImportDomain(domain: string): string | null { return normalized } +// Why (STA-3811): registrable families whose sessions are device-bound server-side, so a +// transplanted cookie is rejected (or flagged and expired within ~1h) no matter how faithfully +// it is copied. Signing in directly inside Orca is the only path that produces a working +// session, so an import must never write these cookies and never remove them either — the +// live session is always more valuable than anything an import could put in its place. +// Adding a site is one entry here. +// youtube.com is deliberately NOT listed: YouTube accepts a transplanted session and re-issues +// its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports +// users actually asked for. +const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const +const COOKIE_CLEAR_CONCURRENCY = 8 + +export function isNonTransplantableCookieDomain(domain: string): boolean { + const normalized = normalizeCookieDomain(domain) + if (!normalized) { + return false + } + return NON_TRANSPLANTABLE_DOMAINS.some( + (root) => normalized === root || normalized.endsWith(`.${root}`) + ) +} + +// Why: Chromium stores host_key lowercase as 'google.com', '.google.com' or 'sub.google.com'; +// the LIKE pattern covers the leading-dot row and cannot match lookalikes ('withgoogle.com'). +export const NON_TRANSPLANTABLE_HOST_KEY_SQL = NON_TRANSPLANTABLE_DOMAINS.map( + (root) => `host_key = '${root}' OR host_key LIKE '%.${root}'` +).join(' OR ') + +// Why: subsumed by the domain exclusion above for google.com — kept because it is the general +// rule for rotation-only cookies and applies to any family added without a full exclusion. export function isGoogleSourceBoundCookie(name: string, domain: string): boolean { if (!GOOGLE_SOURCE_BOUND_COOKIE_NAMES.has(name)) { return false @@ -165,6 +196,54 @@ export async function restoreImportedDomainCookies( } } +// Why: Electron cannot round-trip partition identity, so excluded cookies must never be removed. +export async function removeAllCookiesExcept( + store: Pick, + isExcluded: (cookie: Cookie) => boolean +): Promise { + const existingCookies = await store.get({}) + const removableGroups = new Map() + for (const cookie of existingCookies) { + if (isExcluded(cookie)) { + continue + } + const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + const url = domain ? cookieRemovalUrl(cookie, domain) : null + if (!url) { + continue + } + const key = JSON.stringify([url, cookie.name]) + const group = removableGroups.get(key) ?? [] + group.push({ cookie, url }) + removableGroups.set(key, group) + } + + const removedCookies: Cookie[] = [] + const results = await mapSettledWithConcurrency( + [...removableGroups.values()], + COOKIE_CLEAR_CONCURRENCY, + async (group) => { + // Why: identical removal coordinates must stay ordered instead of racing. + for (const { cookie, url } of group) { + await store.remove(url, cookie.name) + removedCookies.push(cookie) + } + } + ) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length === 0) { + return + } + try { + await restoreImportedDomainCookies(store, removedCookies) + } catch (restoreError) { + throw new AggregateError([...failures, restoreError], 'Cookie clearing and rollback failed') + } + throw new AggregateError(failures, 'Could not clear existing cookies') +} + export async function replaceCookiesForImportedDomains( store: Pick, importedDomains: readonly string[] diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts index 74c2ec432f1..8b5c07bb057 100644 --- a/src/main/browser/browser-cookie-import-replacement.test.ts +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -59,10 +59,11 @@ describe('validated cookie replacement', () => { return filePath } - it('filters Google source-bound cookies before replacing imported domain scopes', async () => { + it('keeps Google out of the replace scope so its cookies are never removed', async () => { cookiesGetMock.mockResolvedValue([ cookie('.google.com', 'old-google'), cookie('.accounts.google.com', 'old-accounts', '/signin'), + cookie('.example.com', 'old-example'), cookie('.unrelated.com', 'keep'), cookie('.google.com.evil.example', 'keep-suffix-confusion') ]) @@ -76,15 +77,13 @@ describe('validated cookie replacement', () => { expect(result.ok && result.summary).toMatchObject({ totalCookies: 3, - importedCookies: 2, - skippedCookies: 1, - domains: ['example.com', 'google.com'] + importedCookies: 1, + skippedCookies: 2, + googleCookiesSkipped: 2, + domains: ['example.com'] }) - expect(cookiesRemoveMock.mock.calls).toEqual([ - ['https://google.com/', 'old-google'], - ['https://accounts.google.com/signin', 'old-accounts'] - ]) - expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'SIDCC']) + expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'old-example']]) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SIDCC']) expect(Math.max(...cookiesRemoveMock.mock.invocationCallOrder)).toBeLessThan( Math.min(...cookiesSetMock.mock.invocationCallOrder) ) @@ -101,6 +100,7 @@ describe('validated cookie replacement', () => { totalCookies: 1, importedCookies: 0, skippedCookies: 1, + googleCookiesSkipped: 1, domains: [] }) expect(cookiesGetMock).not.toHaveBeenCalled() @@ -185,6 +185,7 @@ describe('native Chromium integrity-cookie accounting', () => { sessionFromPartitionMock.mockReset().mockReturnValue({ cookies: { flushStore: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), set: cookiesSetMock }, @@ -196,7 +197,7 @@ describe('native Chromium integrity-cookie accounting', () => { rmSync(tmpDir, { recursive: true, force: true }) }) - it('includes domain-scoped integrity cookies in skippedCookies', async () => { + it('counts every excluded Google cookie in skippedCookies', async () => { const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') createChromiumCookieTestDatabase(sourceCookiesPath, [ @@ -214,11 +215,12 @@ describe('native Chromium integrity-cookie accounting', () => { ) expect(result.ok && result.summary).toMatchObject({ totalCookies: 3, - importedCookies: 2, - skippedCookies: 1, - domains: ['example.com', 'google.com'] + importedCookies: 1, + skippedCookies: 2, + googleCookiesSkipped: 2, + domains: ['example.com'] }) - expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'AEC']) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['AEC']) } finally { platformSpy.mockRestore() } @@ -247,6 +249,7 @@ describe('native Chromium integrity-cookie accounting', () => { totalCookies: 3, importedCookies: 0, skippedCookies: 3, + googleCookiesSkipped: 2, domains: [] }) expect(clearStorageDataMock).not.toHaveBeenCalled() diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index aefdd27fbf5..d2d4ffd4836 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -445,6 +445,7 @@ describe('importCookiesFromBrowser Chromium', () => { sessionFromPartitionMock.mockReset() sessionFromPartitionMock.mockReturnValue({ cookies: { + get: vi.fn().mockResolvedValue([]), set: cookiesSetMock, remove: cookiesRemoveMock, flushStore: cookiesFlushStoreMock @@ -510,7 +511,7 @@ describe('importCookiesFromBrowser Chromium', () => { ['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix)) ).toEqual(sourceFilesBefore) expect(cookiesRemoveMock).not.toHaveBeenCalled() - expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] }) + expect(clearStorageDataMock).not.toHaveBeenCalled() // Why: STA-3514 — imports must never impersonate the source browser; the // session keeps the engine UA the registry set at startup. expect(setUserAgentMock).not.toHaveBeenCalled() diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 66a3afdb3cf..2a7bed19950 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -76,8 +76,11 @@ import type { import { browserSessionRegistry } from './browser-session-registry' import { isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, normalizeCookieImportDomain, + removeAllCookiesExcept, replaceCookiesForImportedDomains, restoreImportedDomainCookies, type CookieImportMode @@ -571,13 +574,20 @@ async function importValidatedCookies( } return valid }) - const importableCookies = validDomainCookies.filter( + const sourceBoundFiltered = validDomainCookies.filter( (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) ) - const integritySkipped = validDomainCookies.length - importableCookies.length + // Why: dropping these before the replace scope is computed is what keeps the existing + // Google session intact — replaceCookiesForImportedDomains only clears domains we import. + const importableCookies = sourceBoundFiltered.filter( + (cookie) => !isNonTransplantableCookieDomain(cookie.domain) + ) + const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length + const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length + const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped const invalidDomainSkipped = cookies.length - validDomainCookies.length diag( - `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped of ${totalInput} total, partition="${targetPartition}"` + `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` ) const targetSession = session.fromPartition(targetPartition) let importedCount = 0 @@ -684,6 +694,7 @@ async function importValidatedCookies( totalCookies: totalInput, importedCookies: importedCount, skippedCookies: skipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), domains: [...domainSet].sort() } @@ -902,7 +913,7 @@ export function buildChromiumCookieInsertParams( return decryptedValue } - const sourceHasColumn = Object.prototype.hasOwnProperty.call(sourceRow, column.name) + const sourceHasColumn = Object.hasOwn(sourceRow, column.name) const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null if (sourceValue !== null) { return sourceValue @@ -1548,7 +1559,10 @@ export async function importCookiesFromBrowser( const targetCols: string[] = targetColumnInfo.map((r) => r.name) colList = targetCols.join(', ') placeholders = targetCols.map(() => '?').join(', ') - stagingDb.exec('DELETE FROM cookies') + // Why: the staged DB replaces the whole live DB at cold start, so it is a clear step + // like any other — keep the live non-transplantable rows in it rather than replaying + // a wipe the in-memory path was not allowed to perform. + stagingDb.exec(`DELETE FROM cookies WHERE NOT (${NON_TRANSPLANTABLE_HOST_KEY_SQL})`) } catch (err) { diag(` staging database unusable, restart fallback disabled: ${String(err)}`) stagingAvailable = false @@ -1578,7 +1592,12 @@ export async function importCookiesFromBrowser( const needsSourceKey = sourceRows.some((sourceRow) => { const encRaw = sourceRow.encrypted_value - return encRaw instanceof Uint8Array && encRaw.length > 0 + if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) { + return false + } + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)) }) const sourceKey = needsSourceKey ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) @@ -1596,6 +1615,7 @@ export async function importCookiesFromBrowser( let imported = 0 let skipped = 0 let integritySkipped = 0 + let nonTransplantableSkipped = 0 let memoryLoaded = 0 let memoryFailed = 0 const domainSet = new Set() @@ -1640,6 +1660,20 @@ export async function importCookiesFromBrowser( } for (const sourceRow of sourceRows) { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + + if (isGoogleSourceBoundCookie(name, domain)) { + integritySkipped++ + continue + } + + // Why: transplanting these replaces a working sign-in with a session the site rejects. + if (isNonTransplantableCookieDomain(domain)) { + nonTransplantableSkipped++ + continue + } + const encRaw = sourceRow.encrypted_value // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null @@ -1661,14 +1695,6 @@ export async function importCookiesFromBrowser( decryptedValue = Buffer.alloc(0) } - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - - if (isGoogleSourceBoundCookie(name, domain)) { - integritySkipped++ - continue - } - let validDomain = sourceDomainValidity.get(domain) if (validDomain === undefined) { validDomain = normalizeCookieImportDomain(domain) !== null @@ -1718,7 +1744,10 @@ export async function importCookiesFromBrowser( // the optional staging DB is unavailable. imported++ } - diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) + diag( + ` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies` + ) + const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped if (decryptedCookies.length === 0) { closeStagingDb() @@ -1729,7 +1758,8 @@ export async function importCookiesFromBrowser( summary: { totalCookies: sourceRows.length, importedCookies: 0, - skippedCookies: skipped + integritySkipped, + skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), domains: [] } } @@ -1747,8 +1777,12 @@ export async function importCookiesFromBrowser( diag(` staging skipped: ${imported} cookies will load in-memory only`) } - // Why: clear stale cookies first; mixing them with the imported set makes sites like Google reject the session. - await targetSession.clearStorageData({ storages: ['cookies'] }) + // Why: clear stale cookies first; mixing them with the imported set makes sites reject the + // session. Non-transplantable families are exempt — nothing was imported for them, and their + // live session is the only one that works. + await removeAllCookiesExcept(targetSession.cookies, (cookie) => + isNonTransplantableCookieDomain(cookie.domain ?? '') + ) diag( ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` ) @@ -1816,7 +1850,8 @@ export async function importCookiesFromBrowser( const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, importedCookies: imported, - skippedCookies: skipped + integritySkipped, + skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), domains: [...domainSet].sort(), ...(warning ? { warning } : {}) } diff --git a/src/main/browser/browser-google-auth-ua.ts b/src/main/browser/browser-google-auth-ua.ts index 4392e6351c2..16ed14eb80f 100644 --- a/src/main/browser/browser-google-auth-ua.ts +++ b/src/main/browser/browser-google-auth-ua.ts @@ -49,3 +49,11 @@ export function setUserAgentHeader(headers: Record, value: strin const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') headers[existing ?? 'User-Agent'] = value } + +// Why: the outgoing header carries the WebContents UA override, so reading it +// (case-insensitively) tells us whether a request originated from the Firefox +// auth document even when its destination host isn't an auth host. +export function currentUserAgent(headers: Record): string | undefined { + const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') + return existing ? headers[existing] : undefined +} diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index f5f9bb7a7b2..f27a861a45c 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -24,7 +24,7 @@ function seedMeta(fsState: FsState, meta: unknown): void { function installModuleMocks( fsState: FsState, - copyFailures: Set = new Set() + copyFailures = new Set() ): { sessionFromPartitionMock: ReturnType setupClientHintsOverrideMock: ReturnType diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 44e1b6b8e92..9f32e4f379d 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -28,6 +28,7 @@ vi.mock('./browser-manager', () => ({ })) import { browserSessionRegistry } from './browser-session-registry' +import { googleAuthUserAgent } from './browser-google-auth-ua' import { setupClientHintsOverride } from './browser-session-ua' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { @@ -440,6 +441,81 @@ describe('BrowserSessionRegistry', () => { expect(modified['sec-ch-ua-platform']).toBeUndefined() }) + it('strips client hints on a cross-host request that carries the Firefox auth UA', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + setupClientHintsOverride( + mockSess, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + ) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + // Subresource/XHR to a non-auth Google host while the auth document is on + // screen: the WebContents Firefox UA leaks onto the request header. + listener( + { + url: 'https://play.google.com/log', + requestHeaders: { + 'User-Agent': googleAuthUserAgent(), + 'sec-ch-ua': 'old', + 'sec-ch-ua-full-version-list': 'old', + 'sec-ch-ua-platform': '"macOS"', + 'sec-ch-ua-mobile': '?0' + } + }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + // UA stays Firefox and every client hint is dropped — one consistent identity. + expect(modified['User-Agent']).toBe(googleAuthUserAgent()) + expect(modified['sec-ch-ua']).toBeUndefined() + expect(modified['sec-ch-ua-full-version-list']).toBeUndefined() + expect(modified['sec-ch-ua-platform']).toBeUndefined() + expect(modified['sec-ch-ua-mobile']).toBeUndefined() + }) + + it('keeps the clean Chrome identity on cross-host requests that carry the Chrome UA', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const chromeUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + setupClientHintsOverride(mockSess, chromeUa) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + // Regression guard: non-Google sites (Cloudflare) must keep Chrome hints. + listener( + { + url: 'https://example.com/api', + requestHeaders: { 'User-Agent': chromeUa, 'sec-ch-ua': 'old' } + }, + callback + ) + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') + }) + + it('does not strip hints for the Firefox UA when googleAuthOverride is disabled', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const chromeUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + setupClientHintsOverride(mockSess, chromeUa, { googleAuthOverride: false }) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { + url: 'https://play.google.com/log', + requestHeaders: { 'User-Agent': googleAuthUserAgent(), 'sec-ch-ua': 'old' } + }, + callback + ) + // Imported-native profiles never install the Firefox switch, so the strip + // branch stays inert and hints are aligned to Chrome instead. + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') + }) + it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => { const onBeforeSendHeaders = vi.fn() const mockSess = { webRequest: { onBeforeSendHeaders } } as never diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 092b79995d8..96c55cf5ac9 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -1,6 +1,7 @@ import type { Session } from 'electron' import { + currentUserAgent, googleAuthUserAgent, isGoogleAuthUrl, setUserAgentHeader, @@ -48,6 +49,18 @@ export function setupClientHintsOverride( callback({ requestHeaders: headers }) return } + if (options.googleAuthOverride !== false && currentUserAgent(headers) === firefoxUa) { + // Why: while the auth document is on screen the WebContents UA is Firefox, + // so its cross-host subresource/XHR requests (gstatic, play.google.com, the + // sign-in challenge endpoints) reach here carrying the Firefox UA yet still + // bearing Chromium client hints. Rewriting those to Chrome pairs a Firefox + // UA with Chrome hints — a sharper cross-host identity tell than either + // alone, which can stall Google's password-submit challenge. Real Firefox + // sends no client hints, so strip them to keep one identity for the flow. + stripClientHints(headers) + callback({ requestHeaders: headers }) + return + } if (chromeHints) { for (const key of Object.keys(headers)) { const lower = key.toLowerCase() diff --git a/src/main/browser/browser-text-insertion.ts b/src/main/browser/browser-text-insertion.ts index 4d89b3a06fa..6c04fd52ae5 100644 --- a/src/main/browser/browser-text-insertion.ts +++ b/src/main/browser/browser-text-insertion.ts @@ -1,22 +1,10 @@ import { measureClipboardTextByteLength } from '../../shared/clipboard-text' import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits' import type { CdpCommandSender } from './snapshot-engine' export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024 -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function splitBrowserTextInsertionChunks( text: string, maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES @@ -38,24 +26,11 @@ export function* iterateBrowserTextInsertionChunks( return } - let currentStart = 0 - let currentBytes = 0 - let index = 0 - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextIndex = index + codeUnitLength - const characterBytes = getUtf8ByteLengthForCodePoint(codePoint) - if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) { - yield text.slice(currentStart, index) - currentStart = index - currentBytes = 0 - } - currentBytes += characterBytes - index = nextIndex - } - if (currentStart < text.length) { - yield text.slice(currentStart) + let startIndex = 0 + while (startIndex < text.length) { + const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax) + yield text.slice(startIndex, endIndex) + startIndex = endIndex } } @@ -68,8 +43,7 @@ export async function insertTextThroughCdp( let chunk = chunks.next() while (!chunk.done) { await sender('Input.insertText', { text: chunk.value }) - // Why: browser automation text can be paste-sized; yielding keeps the main - // process responsive between bounded CDP payloads. + // Keep paste-sized insertion from monopolizing the main process. chunk = chunks.next() if (options?.yieldBetweenChunks !== false && !chunk.done) { await yieldToEventLoop() diff --git a/src/main/browser/browser-webauthn-access.ts b/src/main/browser/browser-webauthn-access.ts index 4f04065e36c..60da5b1e297 100644 --- a/src/main/browser/browser-webauthn-access.ts +++ b/src/main/browser/browser-webauthn-access.ts @@ -15,7 +15,7 @@ function isSecureBrowserOrigin(rawOrigin: string | undefined): boolean { } } -function isFidoHidDevice(device: Electron.HIDDevice | unknown): device is Electron.HIDDevice { +function isFidoHidDevice(device: unknown): device is Electron.HIDDevice { if (!device || typeof device !== 'object') { return false } diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index b3b1fae9401..b3b836d07b0 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -212,7 +212,7 @@ function createMockGuest( removeListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { const handlers = debuggerListeners.get(event) ?? [] const idx = handlers.indexOf(handler) - if (idx >= 0) { + if (idx !== -1) { handlers.splice(idx, 1) } }), diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 751ee0c0dae..3609399ac33 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -1,20 +1,12 @@ -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClaudeUsagePersistedState } from './types' import type * as Scanner from './scanner' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -23,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', async (importOriginal) => ({ ...(await importOriginal()), scanClaudeUsageFiles: vi.fn() @@ -54,12 +23,15 @@ vi.mock('./scanner', async (importOriginal) => ({ import { ClaudeUsageStore, initClaudeUsagePath } from './store' import { scanClaudeUsageFiles } from './scanner' -function createStoreWithState(state: Partial): ClaudeUsageStore { - const store = new ClaudeUsageStore({ +function createBackingStore(): ConstructorParameters[0] { + return { getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + } +} + +function createStoreWithState(state: Partial): ClaudeUsageStore { + const store = new ClaudeUsageStore(createBackingStore()) ;(store as unknown as { state: ClaudeUsagePersistedState }).state = { schemaVersion: 1, @@ -86,11 +58,6 @@ describe('ClaudeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-')) getPathMock.mockReturnValue(tempUserData) initClaudeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanClaudeUsageFiles).mockReset() vi.mocked(scanClaudeUsageFiles).mockResolvedValue({ processedFiles: [], @@ -106,6 +73,17 @@ describe('ClaudeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) + it('defaults a null legacy opt-in while invalidating the cache', () => { + writeFileSync( + join(tempUserData, 'orca-claude-usage.json'), + JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } }) + ) + + const store = new ClaudeUsageStore(createBackingStore()) + + expect(store.getScanState().enabled).toBe(false) + }) + it('reports no data for Orca scope when only non-Orca usage exists', async () => { const store = createStoreWithState({ sessions: [ @@ -617,64 +595,7 @@ describe('ClaudeUsageStore', () => { expect(refreshMock).toHaveBeenCalledWith(false) }) - it('persists setEnabled via async durable write without leaving tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: false, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - - await store.setEnabled(true) - - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - const persisted = JSON.parse( - readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8') - ) - expect(persisted.scanState.enabled).toBe(true) - // Pretty-print preserved for human inspection of the analytics cache. - expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: ClaudeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('persists a successful refresh with one full-cache write', async () => { + it('adapts Claude scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -687,25 +608,7 @@ describe('ClaudeUsageStore', () => { await store.refresh(true) - // Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - ).toMatchObject({ - lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) + expect(scanClaudeUsageFiles).toHaveBeenCalledWith([], []) + expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 180f7660b6b..66085504089 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */ +/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' -import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, ClaudeUsageDailyPoint, ClaudeUsageRange, - ClaudeUsageScanState, ClaudeUsageScope, ClaudeUsageSessionRow, ClaudeUsageSnapshot, @@ -16,16 +13,15 @@ import type { } from '../../shared/claude-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count // after fork reclaim (#8006). const SCHEMA_VERSION = 5 -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() @@ -130,6 +126,21 @@ function getDefaultState(): ClaudeUsagePersistedState { } } +function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState { + if (state.schemaVersion === SCHEMA_VERSION) { + return state + } + // Scanner changes invalidate totals, but preserving enabled keeps existing tracking on. + const defaults = getDefaultState() + return { + ...defaults, + scanState: { + ...defaults.scanState, + enabled: state.scanState.enabled ?? defaults.scanState.enabled + } + } +} + export function initClaudeUsagePath(): void { _claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json') } @@ -288,120 +299,22 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: ClaudeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class ClaudeUsageStore { - private state: ClaudeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): ClaudeUsagePersistedState { - try { - const usageFile = getClaudeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState - if (parsed.schemaVersion !== SCHEMA_VERSION) { - // Why: scanner semantics affect persisted totals, so old Claude caches - // must be rebuilt after parser/source changes instead of reused briefly. - // Preserve scanState.enabled so existing users keep tracking on across - // schema bumps; the next refresh will repopulate the analytics. - const defaults = getDefaultState() - return { - ...defaults, - scanState: { - ...defaults.scanState, - enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled - } - } - } - return { - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - } - } catch (error) { - // Why: Claude usage is a local analytics feature, not primary workspace - // state. A corrupt cache should degrade to a fresh rebuild instead of - // preventing Orca from booting, but we leave the file on disk for debugging. - console.error('[claude-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): ClaudeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyClaudeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class ClaudeUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + ClaudeUsagePersistedState, + 'hasAnyClaudeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[claude-usage]', + resolveCacheFile: getClaudeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyClaudeData', + jsonIndent: 2, + scan: scanClaudeUsageFiles + }) } getSnapshot( @@ -419,63 +332,6 @@ export class ClaudeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - // Why: assign scanPromise before any await so concurrent refresh shares one scan. - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await scanClaudeUsageFiles( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -843,7 +699,7 @@ export class ClaudeUsageStore { } private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -856,12 +712,12 @@ export class ClaudeUsageStore { } private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { // Why: daily aggregates use local calendar days, so session filtering has // to use the same conversion or the sessions table/counts can disagree // with the chart around UTC day boundaries. - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -883,10 +739,4 @@ export class ClaudeUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 0d390c3a9e3..b3fa02debc5 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -17,18 +17,34 @@ vi.mock('electron', () => ({ import type { SFTPWrapper } from 'ssh2' import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' import { ClaudeHookService } from './hook-service' -import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' +import { getWindowsManagedLifecycleHook, OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' const STATUSLINE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-statusline.cmd' : 'claude-statusline.sh' const OPENCLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh' -const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ const isClaudeManagedCommand = createManagedCommandMatcher(CLAUDE_SCRIPT_FILE_NAME) const isOpenClaudeManagedCommand = createManagedCommandMatcher(OPENCLAUDE_SCRIPT_FILE_NAME) +type TestHook = { command: string; args?: string[] } + +function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined) => boolean) { + return matcher(hook.command) || hook.args?.some(matcher) === true +} + +describe('getWindowsManagedLifecycleHook', () => { + it('resolves the managed script from the runtime Windows profile', () => { + const scriptPath = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd' + const hook = getWindowsManagedLifecycleHook(scriptPath) + + expect(hook.args?.[0]).toBe('--headless') + expect(hook.args?.[1]).toMatch(/\\System32\\cmd\.exe$/i) + expect(hook.args?.at(-1)).toBe('%USERPROFILE%\\.orca\\agent-hooks\\claude-hook.cmd') + expect(hook.args).not.toContain(scriptPath) + }) +}) + type FakeFs = { files: Map dirs: Set @@ -162,18 +178,27 @@ describe('ClaudeHookService.install', () => { AWS_REGION: 'us-west-2' } }) - const legacyCommands = legacy.hooks.Stop.flatMap( - (definition: { hooks: { command: string }[] }) => - definition.hooks.map((hook) => hook.command) + const legacyHooks = legacy.hooks.Stop.flatMap( + (definition: { hooks: TestHook[] }) => definition.hooks ) - expect(legacyCommands).toContain('/usr/local/bin/user-hook') - expect(legacyCommands.some((command: string) => isClaudeManagedCommand(command))).toBe(true) + expect(legacyHooks.map((hook: TestHook) => hook.command)).toContain( + '/usr/local/bin/user-hook' + ) + const managedHook = legacyHooks.find((hook: TestHook) => + hasManagedCommand(hook, isClaudeManagedCommand) + ) + expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/')) expect( - legacyCommands.some((command: string) => - command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') + legacyHooks.some((hook: TestHook) => hasManagedCommand(hook, isClaudeManagedCommand)) + ).toBe(true) + expect( + legacyHooks.some((hook: TestHook) => + hook.command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') ) ).toBe(false) - expect(isClaudeManagedCommand(legacy.hooks.StopFailure[0].hooks[0].command)).toBe(true) + expect(hasManagedCommand(legacy.hooks.StopFailure[0].hooks[0], isClaudeManagedCommand)).toBe( + true + ) expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).toContain('DEVIN_PROJECT_DIR') @@ -194,7 +219,13 @@ describe('ClaudeHookService.install', () => { readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') ) as { statusLine?: { type: string; command: string } } expect(settings.statusLine?.type).toBe('command') - expect(settings.statusLine?.command).toContain('claude-statusline') + expect(settings.statusLine?.command).toContain( + '"$HOME/.orca/agent-hooks/claude-statusline.cmd"' + ) + expect(settings.statusLine?.command).toContain( + '"$HOME/.orca/agent-hooks/claude-statusline.sh"' + ) + expect(settings.statusLine?.command).not.toContain(tmpHome.replaceAll('\\', '/')) const script = readFileSync( join(tmpHome, '.orca', 'agent-hooks', STATUSLINE_SCRIPT_FILE_NAME), @@ -307,12 +338,8 @@ describe('ClaudeHookService.install', () => { } }) - // Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path - // with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed - // command must use an encoded launcher so Git Bash/cmd.exe never splits or - // expands the raw path before invoking the managed .cmd. it.skipIf(process.platform !== 'win32')( - 'wraps the managed hook command to survive spaces in the profile path (#6078)', + 'runs portable managed hooks through headless exec form', () => { const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces ')) vi.stubEnv('HOME', tmpHome) @@ -322,11 +349,26 @@ describe('ClaudeHookService.install', () => { const settings = JSON.parse( readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') - ) as { hooks: Record } + ) as { hooks: Record } + + const system32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') + const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME) + const runtimeScriptPath = join( + '%USERPROFILE%', + '.orca', + 'agent-hooks', + CLAUDE_SCRIPT_FILE_NAME + ) for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) { - const command = settings.hooks[eventName]?.[0]?.hooks?.[0]?.command - expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER) + const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] + expect(hook).toEqual({ + type: 'command', + command: join(system32, 'conhost.exe'), + args: ['--headless', join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath], + timeout: 10 + }) + expect(hook.args).not.toContain(scriptPath) } } finally { vi.unstubAllEnvs() @@ -335,9 +377,6 @@ describe('ClaudeHookService.install', () => { } ) - // Why: the launcher must stay PowerShell-encoded for Git Bash, but the hook - // POST inside the .cmd should use curl.exe so each hook spawns one - // interpreter, not two. Posting via a second PowerShell was the slow path. it.skipIf(process.platform !== 'win32')( 'posts from the managed .cmd via curl.exe, not a second PowerShell', () => { @@ -372,11 +411,7 @@ describe('ClaudeHookService.installRemote', () => { const settings = fs.files.get('/home/dev/.claude/settings.json') expect(settings).toBeTruthy() const parsed = JSON.parse(settings!) - // Why: every load-bearing event must be present and point at the - // remote-shaped script path with the guarded launcher applied. Drift in - // any of these is a real bug — Claude - // Code rejects unknown shapes silently and the agent-hooks pipeline - // goes dark. + // Why: Claude silently rejects drifted hook shapes, so assert every load-bearing event. for (const event of [ // Why: SessionStart is the only signal a resumed/idle session ever emits; // without it the sidebar row waits for the first prompt (STA-3386). @@ -394,8 +429,8 @@ describe('ClaudeHookService.installRemote', () => { ]) { expect(parsed.hooks[event]).toBeTruthy() const cmd = parsed.hooks[event][0].hooks[0].command as string - expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh') - expect(cmd).toMatch(/^if \[ -f /) + expect(cmd).toContain('"$HOME/.orca/agent-hooks/claude-hook.sh"') + expect(cmd).not.toContain('/home/dev/.orca/agent-hooks/claude-hook.sh') } // Managed script body const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh') @@ -487,9 +522,9 @@ describe('OpenClaudeHookService-compatible install', () => { for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const command = parsed.hooks[event][0].hooks[0].command as string expect(isOpenClaudeManagedCommand(command)).toBe(true) - if (process.platform !== 'win32') { - expect(command).toMatch(/^if \[ -f /) - } + expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.cmd"') + expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"') + expect(command).not.toContain(tmpHome.replaceAll('\\', '/')) } expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') @@ -518,7 +553,8 @@ describe('OpenClaudeHookService-compatible install', () => { }) const parsed = JSON.parse(fs.files.get('/home/dev/.openclaude/settings.json')!) const command = parsed.hooks.StopFailure[0].hooks[0].command as string - expect(command).toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh') + expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"') + expect(command).not.toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh') expect(fs.files.get('/home/dev/.orca/agent-hooks/openclaude-hook.sh')).toContain('/hook/claude') }) }) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index ba2dfac1351..47269b1e17d 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -2,6 +2,7 @@ import { existsSync, rmSync, writeFileSync } from 'node:fs' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { + buildManagedCommandHook, buildWindowsAgentHookCurlPostCommand, readHooksJson, writeHooksJson, @@ -13,6 +14,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, buildWindowsHookEnvironmentGuardLines, @@ -28,6 +30,7 @@ import { getManagedScriptFileName, getConfigPath, getManagedCommand, + getManagedLifecycleHook, getManagedScriptPath, getPosixManagedScriptFileName, getRemoteConfigPath, @@ -36,6 +39,7 @@ import { getStatusLineScriptFileName, getStatusLineScriptPath, getStatusLineSlotState, + hasSameManagedHookInvocation, removeManagedHooks, removeManagedStatusLine, type ClaudeCompatibleHookSettings @@ -139,7 +143,7 @@ export class ClaudeHookService { } // Why: report partial registration instead of a false installed state. - const command = getManagedCommand(scriptPath) + const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings) const missing: string[] = [] let presentCount = 0 for (const event of CLAUDE_EVENTS) { @@ -147,7 +151,7 @@ export class ClaudeHookService { ? config.hooks![event.eventName]! : [] const hasCommand = definitions.some((definition) => - (definition.hooks ?? []).some((hook) => hook.command === command) + (definition.hooks ?? []).some((hook) => hasSameManagedHookInvocation(hook, expectedHook)) ) if (hasCommand) { presentCount += 1 @@ -171,6 +175,18 @@ export class ClaudeHookService { return { agent: this.options.agent, state, configPath, managedHooksPresent, detail } } + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent( + getManagedScriptPath(this.options.settings), + getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + ) + // Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate. + await refreshManagedScriptIfPresent( + getStatusLineScriptPath(this.options.settings), + getManagedStatusLineScript('local') + ) + } + install(): AgentHookInstallStatus { const configPath = getConfigPath(this.options.settings) const scriptPath = getManagedScriptPath(this.options.settings) @@ -185,10 +201,10 @@ export class ClaudeHookService { } } - const command = getManagedCommand(scriptPath) + const hook = getManagedLifecycleHook(scriptPath, this.options.settings) let nextConfig = applyManagedHooks( config, - command, + hook, getManagedScriptFileName(this.options.settings) ) writeManagedScript( @@ -246,9 +262,9 @@ export class ClaudeHookService { } } - // Why: the POSIX wrapper is identical regardless of where the script lands; only the path differs. - const command = getRemoteManagedCommand(remoteScriptPath) - const nextConfig = applyManagedHooks(config, command, remoteScriptFileName) + // Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home. + const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath)) + const nextConfig = applyManagedHooks(config, hook, remoteScriptFileName) // Why: write scripts before settings to avoid settings pointing to missing scripts. // Why: SSH scripts always use POSIX .sh paths, regardless of the local OS. diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index de932276545..d39b8e27bcb 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -1,30 +1,34 @@ import { homedir } from 'node:os' -import { join } from 'node:path' +import { basename, extname, join, win32 } from 'node:path' import { buildManagedCommandHook, createManagedCommandMatcher, getSharedManagedScriptPath, isPlainObject, + MANAGED_HOOK_TIMEOUT_SECONDS, removeManagedCommands, - wrapPosixHookCommand, - wrapWindowsGitBashHookCommand, + type HookCommandConfig, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' export type ClaudeCompatibleHookSettings = { configDirName: '.claude' | '.openclaude' scriptBaseName: 'claude-hook' | 'openclaude-hook' + supportsExecHookArgs: boolean } export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.claude', - scriptBaseName: 'claude-hook' + scriptBaseName: 'claude-hook', + supportsExecHookArgs: true } export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.openclaude', - scriptBaseName: 'openclaude-hook' + scriptBaseName: 'openclaude-hook', + supportsExecHookArgs: false } export const CLAUDE_EVENTS = [ @@ -105,18 +109,57 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S } export function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' - ? wrapWindowsGitBashHookCommand(scriptPath) - : wrapPosixHookCommand(scriptPath) + const scriptFileName = basename(scriptPath) + const extension = extname(scriptFileName) + return wrapRuntimeHomeHookCommand( + extension ? scriptFileName.slice(0, -extension.length) : scriptFileName + ) +} + +export function getManagedLifecycleHook( + scriptPath: string, + settings = CLAUDE_HOOK_SETTINGS +): HookCommandConfig { + if (process.platform !== 'win32' || !settings.supportsExecHookArgs) { + return buildManagedCommandHook(getManagedCommand(scriptPath)) + } + return getWindowsManagedLifecycleHook(scriptPath) +} + +export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig { + const system32 = win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') + const runtimeScriptPath = win32.join( + '%USERPROFILE%', + '.orca', + 'agent-hooks', + win32.basename(scriptPath) + ) + // Why: Claude's Windows shell form opens Git Bash consoles; exec form hosts the client in a windowless console. + return { + type: 'command', + command: win32.join(system32, 'conhost.exe'), + args: ['--headless', win32.join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath], + timeout: MANAGED_HOOK_TIMEOUT_SECONDS + } +} + +export function hasSameManagedHookInvocation( + actual: HookCommandConfig, + expected: HookCommandConfig +): boolean { + return ( + actual.command === expected.command && + JSON.stringify(actual.args ?? []) === JSON.stringify(expected.args ?? []) + ) } export function getRemoteManagedCommand(scriptPath: string): string { - return wrapPosixHookCommand(scriptPath) + return getManagedCommand(scriptPath) } export function applyManagedHooks( config: HooksConfig, - command: string, + hook: HookCommandConfig, scriptFileName = getManagedScriptFileName() ): HooksConfig { const nextHooks = { ...config.hooks } @@ -127,7 +170,7 @@ export function applyManagedHooks( const cleaned = removeManagedCommands(current, isManagedCommand) const definition: HookDefinition = { ...event.definition, - hooks: [buildManagedCommandHook(command)] + hooks: [hook] } nextHooks[event.eventName] = [...cleaned, definition] } diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 9c17fd21116..180611c1590 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -489,7 +489,7 @@ describe('CliInstaller', () => { const registryReader = new WindowsUserPathRegistryReader({ platform: 'win32', registryLoader: async () => ({ - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, getRegistryKey: () => ({ Path: { name: 'Path', type: 2, value: registryPath } }) diff --git a/src/main/cli/windows-user-path-registry.test.ts b/src/main/cli/windows-user-path-registry.test.ts index 12beca13852..7f919694626 100644 --- a/src/main/cli/windows-user-path-registry.test.ts +++ b/src/main/cli/windows-user-path-registry.test.ts @@ -7,7 +7,7 @@ import { function registryModule(pathValue?: string, type = 2) { return { - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, REG: { SZ: 1, EXPAND_SZ: 2 }, getRegistryKey: vi.fn(() => pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } } diff --git a/src/main/cli/windows-user-path-registry.ts b/src/main/cli/windows-user-path-registry.ts index d3e493fd87d..90e8ba7863e 100644 --- a/src/main/cli/windows-user-path-registry.ts +++ b/src/main/cli/windows-user-path-registry.ts @@ -1,26 +1,17 @@ -import { createRequire } from 'node:module' +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' export type WindowsUserPathReadResult = | { state: 'success'; value: string | null; expandable: boolean } | { state: 'unknown'; detail: string } -type RegistryValue = { - name?: unknown - type?: unknown - value?: unknown -} - -type WindowsRegistryModule = { - HK: { CU: number } - getRegistryKey: ( - root: number, - path: string - ) => Record | null | undefined -} - type WindowsUserPathRegistryReaderOptions = { platform?: NodeJS.Platform - registryLoader?: () => Promise + registryLoader?: () => Promise now?: () => number cacheTtlMs?: number } @@ -28,19 +19,9 @@ type WindowsUserPathRegistryReaderOptions = { const DEFAULT_CACHE_TTL_MS = 1_000 const USER_ENVIRONMENT_KEY = 'Environment' const USER_PATH_VALUE = 'Path' -const REG_SZ = 1 -const REG_EXPAND_SZ = 2 -const requireFromMain = createRequire(__filename) - -async function loadWindowsRegistryModule(): Promise { - // Why: the optional dependency is not present in non-Windows installs, so - // TypeScript and the main bundle must not resolve it eagerly. - return requireFromMain('windows-native-registry') as WindowsRegistryModule -} - export class WindowsUserPathRegistryReader { private readonly platform: NodeJS.Platform - private readonly registryLoader: () => Promise + private readonly registryLoader: () => Promise private readonly now: () => number private readonly cacheTtlMs: number private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null @@ -49,7 +30,7 @@ export class WindowsUserPathRegistryReader { constructor(options: WindowsUserPathRegistryReaderOptions = {}) { this.platform = options.platform ?? process.platform - this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule + this.registryLoader = options.registryLoader ?? (async () => loadWindowsNativeRegistry()) this.now = options.now ?? Date.now this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS } @@ -123,7 +104,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: null, expandable: false } } if ( - (pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) || + (pathEntry.type !== WINDOWS_REG_SZ && pathEntry.type !== WINDOWS_REG_EXPAND_SZ) || typeof pathEntry.value !== 'string' ) { return { @@ -134,7 +115,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: pathEntry.value || null, - expandable: pathEntry.type === REG_EXPAND_SZ + expandable: pathEntry.type === WINDOWS_REG_EXPAND_SZ } } catch { return { diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 13bd8088de3..581c6f075b6 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -313,7 +313,7 @@ describe('WslCliInstaller', () => { it('generates a launcher that forwards arguments through a PowerShell file bridge', () => { const launcher = _internals.buildWslLauncher( - 'C:\\Program Files\\Orca\\orca.cmd', + 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' ) const bridge = _internals.buildWslBridgeScript() @@ -333,21 +333,22 @@ describe('WslCliInstaller', () => { ) expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"') expect(launcher).not.toContain('-Command') - expect(bridge).toContain('[CmdletBinding(PositionalBinding=$false)]') - expect(bridge).toContain('[Parameter(Mandatory=$true, Position=0)]') - expect(bridge).toContain('[string]$WslCwd') - expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]') + expect(bridge).not.toContain('[CmdletBinding') + expect(bridge).not.toMatch(/^param\(/m) + expect(bridge).toContain("$args[1] -eq '-WslCwd'") + expect(bridge).toContain('[string]$OrcaLauncher = $args[0]') + expect(bridge).toContain('$WslCwd = $args[2]') + expect(bridge).toContain('$ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)])') expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))') expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd') expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)') - expect(bridge).toContain('& $OrcaLauncher @ForwardArgs') - const nullExitCodeBranch = bridge.indexOf('if ($null -eq $LASTEXITCODE)') - const invocationFailureBranch = bridge.indexOf('if (-not $?)') - expect(nullExitCodeBranch).toBeGreaterThan(-1) - // Why: native launchers can set a non-zero LASTEXITCODE while $? is false; - // checking the native status first preserves that specific exit code. - expect(nullExitCodeBranch).toBeLessThan(invocationFailureBranch) - expect(bridge).toContain('$exitCode = $LASTEXITCODE') + expect(bridge).toContain('function ConvertTo-NativeCommandLineArgument') + expect(bridge).toContain("[void]$Quoted.Append([char]'\\', $BackslashCount * 2 + 1)") + expect(bridge).toContain('$StartInfo.UseShellExecute = $false') + expect(bridge).toContain('[System.Diagnostics.Process]::Start($StartInfo)') + expect(bridge).toContain('$Process.WaitForExit()') + expect(bridge).toContain('$exitCode = $Process.ExitCode') + expect(bridge).not.toContain('& $OrcaLauncher @ForwardArgs') expect(bridge).toContain('Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue') expect(bridge).toContain('catch') expect(bridge).toContain('$exitCode = 1') diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index f6a1684e19c..3dee00a871b 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -379,7 +379,7 @@ export class WslCliInstaller { private async readCommandFile( distro: string, commandPath: string - ): Promise { + ): Promise<(string & {}) | 'not_file' | null> { const output = await this.run( distro, [ diff --git a/src/main/cli/wsl-cli-powershell-boundary.test.ts b/src/main/cli/wsl-cli-powershell-boundary.test.ts new file mode 100644 index 00000000000..7e706a0ed12 --- /dev/null +++ b/src/main/cli/wsl-cli-powershell-boundary.test.ts @@ -0,0 +1,120 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildWslBridgeScript, buildWslLauncher } from './wsl-cli-scripts' + +const FORWARDED_ARGS = [ + 'terminal', + 'wait', + '--terminal', + 'term_example', + '--for', + 'tui-idle', + '--wsl', + 'forwarded-wsl-value', + '--orca', + 'forwarded-orca-value', + '--debug', + 'forwarded-debug-value', + '--deps', + '["task_907c556bfed6"]', + '--quoted-text', + 'tell me "what is next"', + '--empty', + '', + '--trailing-backslash', + 'C:\\path with spaces\\', + '--', + 'tail' +] + +describe('WSL CLI PowerShell boundary', () => { + it('keeps forwarded argv outside PowerShell parsing', () => { + const launcher = buildWslLauncher('C:\\Program Files\\Orca\\orca.exe') + const bridge = buildWslBridgeScript() + + expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"') + expect(bridge).not.toContain('[CmdletBinding') + expect(bridge).not.toMatch(/^param\(/m) + expect(bridge).toContain('$ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)])') + expect(bridge).toContain('function ConvertTo-NativeCommandLineArgument') + expect(bridge).toContain('$StartInfo.UseShellExecute = $false') + }) + + it.skipIf(process.platform !== 'win32')( + 'preserves native argv and exit status through Windows PowerShell 5.1', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-wsl-powershell-boundary-')) + const fixtureDir = join(root, 'fixture with spaces') + const bridgePath = join(fixtureDir, 'orca-wsl-bridge.ps1') + const targetPath = join(fixtureDir, 'argv-target.cjs') + const wslCwd = join(root, 'WSL cwd with spaces') + + try { + await mkdir(fixtureDir) + await writeFile(bridgePath, buildWslBridgeScript(), 'utf8') + await writeFile( + targetPath, + 'process.stdout.write(JSON.stringify({ argv: process.argv.slice(2), cwd: process.env.ORCA_CLI_CWD ?? null }))\n', + 'utf8' + ) + const invocations = [ + { + bridgeArgs: [process.execPath, '-WslCwd', wslCwd, targetPath, ...FORWARDED_ARGS], + expected: { argv: FORWARDED_ARGS, cwd: wslCwd } + }, + { + bridgeArgs: [process.execPath, '-WslCwd', wslCwd, targetPath], + expected: { argv: [], cwd: wslCwd } + }, + { + bridgeArgs: [process.execPath, targetPath, ...FORWARDED_ARGS], + expected: { argv: FORWARDED_ARGS, cwd: null } + } + ] + for (const { bridgeArgs, expected } of invocations) { + const result = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + bridgePath, + ...bridgeArgs + ], + { encoding: 'utf8', env: { ...process.env, ORCA_CLI_CWD: 'stale' } } + ) + + expect(result.error).toBeUndefined() + expect(result.stderr).toBe('') + expect(result.status).toBe(0) + expect(JSON.parse(result.stdout.trim())).toEqual(expected) + } + + const exitResult = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + bridgePath, + process.execPath, + '-e', + 'process.exit(23)' + ], + { encoding: 'utf8' } + ) + expect(exitResult.error).toBeUndefined() + expect(exitResult.status).toBe(23) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/cli/wsl-cli-registration-registry.ts b/src/main/cli/wsl-cli-registration-registry.ts index d1a84a42f52..99d3faff5d4 100644 --- a/src/main/cli/wsl-cli-registration-registry.ts +++ b/src/main/cli/wsl-cli-registration-registry.ts @@ -138,7 +138,7 @@ async function readState(userDataPath: string): Promise normalizeWslDistroKey(entry) === key) - if (existingIndex < 0) { + if (existingIndex === -1) { return [...distros, distro.trim()] } return distros.map((entry, index) => (index === existingIndex ? distro.trim() : entry)) diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 3297b9be9c1..3dba83bd30c 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -34,35 +34,75 @@ exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_P export function buildWslBridgeScript(): string { return `${BRIDGE_MANAGED_MARKER} -[CmdletBinding(PositionalBinding=$false)] -param( - [Parameter(Mandatory=$true, Position=0)] - [string]$OrcaLauncher, +function ConvertTo-NativeCommandLineArgument { + param([AllowEmptyString()][string]$Value) - [string]$WslCwd, + if ($Value.Length -gt 0 -and $Value -notmatch '[\\s"]') { + return $Value + } - [Parameter(ValueFromRemainingArguments=$true)] - [string[]]$ForwardArgs -) + $Quoted = [System.Text.StringBuilder]::new() + [void]$Quoted.Append([char]'"') + [int]$BackslashCount = 0 + foreach ($Character in $Value.ToCharArray()) { + if ($Character -eq [char]'\\') { + $BackslashCount += 1 + continue + } + if ($Character -eq [char]'"') { + [void]$Quoted.Append([char]'\\', $BackslashCount * 2 + 1) + [void]$Quoted.Append([char]'"') + } else { + [void]$Quoted.Append([char]'\\', $BackslashCount) + [void]$Quoted.Append($Character) + } + $BackslashCount = 0 + } + [void]$Quoted.Append([char]'\\', $BackslashCount * 2) + [void]$Quoted.Append([char]'"') + return $Quoted.ToString() +} $exitCode = 0 try { + # Why: a param block prefix-binds forwarded flags such as --for in PowerShell 5.1. + if ($args.Count -lt 1) { + throw 'Invalid Orca WSL CLI bridge invocation.' + } + [string]$OrcaLauncher = $args[0] + [string]$WslCwd = '' + [int]$ForwardArgStart = 1 + if ($args.Count -ge 2 -and $args[1] -eq '-WslCwd') { + if ($args.Count -lt 3) { + throw 'Invalid Orca WSL CLI bridge invocation.' + } + $WslCwd = $args[2] + $ForwardArgStart = 3 + } + [string[]]$ForwardArgs = @() + if ($args.Count -gt $ForwardArgStart) { + $ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)]) + } if ([string]::IsNullOrEmpty($WslCwd)) { Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue } else { $env:ORCA_CLI_CWD = $WslCwd } Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher) - & $OrcaLauncher @ForwardArgs - if ($null -eq $LASTEXITCODE) { - if (-not $?) { - $exitCode = 1 - } else { - $exitCode = 0 - } - } else { - $exitCode = $LASTEXITCODE + # Why: Windows PowerShell 5.1 cannot losslessly splat strings to native argv. + $StartInfo = [System.Diagnostics.ProcessStartInfo]::new() + $StartInfo.FileName = $OrcaLauncher + $StartInfo.Arguments = (($ForwardArgs | ForEach-Object { + ConvertTo-NativeCommandLineArgument $_ + }) -join ' ') + $StartInfo.UseShellExecute = $false + $Process = [System.Diagnostics.Process]::Start($StartInfo) + if ($null -eq $Process) { + throw 'Unable to start the Orca Windows CLI launcher.' } + $Process.WaitForExit() + $exitCode = $Process.ExitCode + $Process.Dispose() } catch { Write-Error $_ $exitCode = 1 diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 15a48821501..966d901f994 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1206,6 +1206,44 @@ describe('CodexRuntimeHomeService', () => { } }) + it('resolves only Orca-owned homes used by live retained host shells', async () => { + const accountHome = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + ) + const unownedHome = join(testState.fakeHomeDir, 'unowned-codex-home') + mkdirSync(unownedHome, { recursive: true }) + writeFileSync(join(unownedHome, '.orca-managed-home'), 'account-2\n', 'utf-8') + writePaneRegistry({ + 'shared-pane': { selectionKey: 'host', accountId: null, homeRoute: 'shared-home' }, + 'account-pane': { selectionKey: 'host', accountId: 'account-1', homeRoute: 'account-home' }, + 'unowned-pane': { selectionKey: 'host', accountId: 'account-2', homeRoute: 'account-home' }, + 'real-pane': { selectionKey: 'host', accountId: null, homeRoute: 'real-home' }, + 'wsl-pane': { selectionKey: 'wsl:Ubuntu', accountId: null, homeRoute: 'wsl-home' } + }) + const settings = createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'managed@example.com', 'acct-managed', accountHome), + createCodexAccountRecord('account-2', 'other@example.com', 'acct-other', unownedHome) + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect( + service.getRetainedHostCodexHookHomePaths([ + 'shared-pane', + 'account-pane', + 'unowned-pane', + 'real-pane', + 'wsl-pane', + 'unknown-pane' + ]) + ).toEqual([getRuntimeCodexHomePath(), accountHome]) + }) + it('keeps pre-rollout shared-home panes authenticated on the real-home lane', async () => { const oldSystemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-system') const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 68d054f3571..2d4b8239aad 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -83,6 +83,7 @@ import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-mi import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import { + getCodexPaneAccount, hasRecordedLegacySharedCodexPane, type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' @@ -545,6 +546,40 @@ export class CodexRuntimeHomeService { return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' } + getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { + const settings = this.store.getSettings() + const homes = new Map() + for (const ptyId of ptyIds) { + const record = getCodexPaneAccount(ptyId) + if (!record || record.selectionKey !== 'host') { + continue + } + if ( + record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' + ) { + const homePath = this.getRuntimeHomePath() + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + continue + } + if (record.homeRoute !== 'account-home' || !record.accountId) { + continue + } + const account = settings.codexManagedAccounts.find( + (candidate) => candidate.id === record.accountId + ) + if (!account || this.getWslManagedHomePath(account)) { + continue + } + const homePath = this.getTrustedSelfContainedManagedHomePath(account) + if (homePath) { + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + } + } + return [...homes.values()] + } + // Why: the real-home hook installer flips this gate off when the trust-grant // client reports the host incapable, keeping that host byte-identical to the // managed lane instead of shipping status-blind panes. diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index b55c9b50315..eef834af323 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -1311,7 +1311,7 @@ export class CodexAccountService { const managedRootMarker = '/.local/share/orca/codex-accounts/' const markerIndex = wslInfo.linuxPath.indexOf(managedRootMarker) - if (markerIndex < 0) { + if (markerIndex === -1) { return null } const wslHome = wslInfo.linuxPath.slice(0, markerIndex) diff --git a/src/main/codex-usage/store-snapshot.benchmark.test.ts b/src/main/codex-usage/store-snapshot.benchmark.test.ts index 6ecd39963b9..dbd1482f34c 100644 --- a/src/main/codex-usage/store-snapshot.benchmark.test.ts +++ b/src/main/codex-usage/store-snapshot.benchmark.test.ts @@ -16,8 +16,8 @@ vi.mock('electron', () => ({ function createStoreWithState(state: CodexUsagePersistedState): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = state return store diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 83e5a43fabd..dc5d7b9fe56 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -1,25 +1,12 @@ /* eslint-disable max-lines */ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexUsageDailyAggregate, - CodexUsagePersistedFile, - CodexUsagePersistedState, - CodexUsageSession -} from './types' +import type { CodexUsagePersistedState } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -28,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanCodexUsageFiles: vi.fn() })) @@ -58,27 +22,7 @@ vi.mock('./scanner', () => ({ import { CodexUsageStore, initCodexUsagePath, normalizePersistedState } from './store' import { scanCodexUsageFiles } from './scanner' -type ScanResult = { - processedFiles: CodexUsagePersistedFile[] - sessions: CodexUsageSession[] - dailyAggregates: CodexUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void - reject: (reason?: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - return { promise, resolve, reject } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedFiles: [], sessions: [], @@ -89,9 +33,8 @@ function createEmptyScanResult(): ScanResult { function createStoreWithState(state: Partial): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = { schemaVersion: 1, @@ -118,11 +61,6 @@ describe('CodexUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-')) getPathMock.mockReturnValue(tempUserData) initCodexUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanCodexUsageFiles).mockReset() vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -134,7 +72,7 @@ describe('CodexUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one compact async disk write', async () => { + it('adapts Codex scans to compact cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -147,92 +85,9 @@ describe('CodexUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8') + expect(scanCodexUsageFiles).toHaveBeenCalledWith([], []) expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson))) - expect(persistedJson).not.toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: CodexUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => { diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 10aad99fbbf..c1784f556a1 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */ +/* eslint-disable max-lines -- Why: Codex pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, CodexUsageDailyPoint, CodexUsageRange, - CodexUsageScanState, CodexUsageScope, CodexUsageSessionRow, CodexUsageSnapshot, @@ -16,13 +13,12 @@ import type { } from '../../shared/codex-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 let _codexUsageFile: string | null = null @@ -308,31 +304,6 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: CodexUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - type ScopedCodexUsageModelRow = { modelKey: string modelLabel: string @@ -345,78 +316,21 @@ type ScopedCodexUsageModelRow = { totalTokens: number } -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class CodexUsageStore { - private state: CodexUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): CodexUsagePersistedState { - try { - const usageFile = getCodexUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as CodexUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[codex-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan. - return this.writer.write(() => JSON.stringify(this.state)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): CodexUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyCodexData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class CodexUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + CodexUsagePersistedState, + 'hasAnyCodexData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[codex-usage]', + resolveCacheFile: getCodexUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyCodexData', + scan: codexUsageProvider.scan + }) } getSnapshot( @@ -434,61 +348,6 @@ export class CodexUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await codexUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: CodexUsageScope, range: CodexUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -888,7 +747,7 @@ export class CodexUsageStore { } private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -901,9 +760,9 @@ export class CodexUsageStore { } private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -975,10 +834,4 @@ export class CodexUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 640dcd17506..39853471683 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -111,6 +111,7 @@ function isShellStartupHomeOverride(value: unknown): value is CodexShellStartupH typeof context.home === 'string' && context.home.length > 0 && (context.shell === undefined || typeof context.shell === 'string') && + (context.configHome === undefined || typeof context.configHome === 'string') && typeof context.codexHome === 'string' && context.codexHome.length > 0 ) @@ -207,6 +208,7 @@ function shellStartupHomeOverridesEqual( return ( left?.home === right?.home && left?.shell === right?.shell && + left?.configHome === right?.configHome && left?.codexHome === right?.codexHome ) } @@ -225,6 +227,13 @@ export function getCodexPaneAccount(ptyId: string): CodexPaneAccountRecord | nul return readRegistry().panes[ptyId] ?? null } +/** `custom-home` stays conservative because it can mask a non-comparable shared-home route. */ +export function isCodexPaneHomeRouteProvenAwayFromSharedHome( + route: CodexPaneHomeRoute | undefined +): boolean { + return route === 'real-home' || route === 'account-home' || route === 'wsl-home' +} + /** * Reports the lane each given PTY launched from, omitting panes with no record. * @@ -256,6 +265,18 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } +/** True when startup may need to repair hooks for a retained managed host pane. */ +export function hasRecordedManagedHostCodexPane(): boolean { + return Object.values(readRegistry().panes).some( + (record) => + record.selectionKey === 'host' && + (record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' || + (record.homeRoute === 'account-home' && record.accountId !== null)) + ) +} + /** Drops records whose daemon PTYs are authoritatively absent. */ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly string[]): void { const registry = readRegistry() diff --git a/src/main/codex/codex-real-home-path.test.ts b/src/main/codex/codex-real-home-path.test.ts index 7dc961c7a18..25d4f2b5bba 100644 --- a/src/main/codex/codex-real-home-path.test.ts +++ b/src/main/codex/codex-real-home-path.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -11,9 +11,15 @@ import { import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' const temporaryHomes: string[] = [] +const savedConfigHome = process.env.XDG_CONFIG_HOME afterEach(() => { __resetShellStartupEnvCache() + if (savedConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = savedConfigHome + } for (const path of temporaryHomes.splice(0)) { rmSync(path, { recursive: true, force: true }) } @@ -56,6 +62,9 @@ describe('hasCustomCodexHomeOverride', () => { temporaryHomes.push(paneHome) writeFileSync(join(paneHome, '.zshrc'), 'export CODEX_HOME="$HOME/custom-codex-home"\n') + // Why cleared: the context records XDG_CONFIG_HOME, so a developer machine + // that sets one would otherwise change the recorded shape. + delete process.env.XDG_CONFIG_HOME expect(hasCustomCodexHomeOverrideForLaunch({ HOME: paneHome, SHELL: '/bin/zsh' })).toBe(true) const override = getCustomCodexHomeOverrideForLaunch({ HOME: paneHome, @@ -75,4 +84,50 @@ describe('hasCustomCodexHomeOverride', () => { ).toBe(true) } ) + + // Why: a fish user who exports XDG_CONFIG_HOME from config.fish never passes it to + // a Dock-launched Orca, so the launch env is the only place it appears. Reading the + // main process env instead scans ~/.config and misses the override entirely. + it.skipIf(process.platform === 'win32')( + 'resolves a fish override under the launch env XDG_CONFIG_HOME, not the process one', + () => { + const paneHome = mkdtempSync(join(tmpdir(), 'orca-codex-fish-home-')) + temporaryHomes.push(paneHome) + const configHome = join(paneHome, 'xdg') + mkdirSync(join(configHome, 'fish'), { recursive: true }) + writeFileSync( + join(configHome, 'fish', 'config.fish'), + 'set -gx CODEX_HOME "$HOME/custom-codex-home"\n' + ) + // The decoy fish reads only if XDG_CONFIG_HOME is ignored. + mkdirSync(join(paneHome, '.config', 'fish'), { recursive: true }) + writeFileSync( + join(paneHome, '.config', 'fish', 'config.fish'), + 'set -gx CODEX_HOME /wrong-default-config-home\n' + ) + delete process.env.XDG_CONFIG_HOME + + const launchEnv = { + HOME: paneHome, + SHELL: '/opt/homebrew/bin/fish', + XDG_CONFIG_HOME: configHome + } + const override = getCustomCodexHomeOverrideForLaunch(launchEnv) + + expect(override).toEqual({ + source: 'shell-startup', + context: { + home: paneHome, + shell: '/opt/homebrew/bin/fish', + configHome, + codexHome: join(paneHome, 'custom-codex-home') + } + }) + // And the recorded configHome is what a later re-check resolves against. + expect( + override?.source === 'shell-startup' && + shellStartupCodexHomeOverrideMatches(override.context) + ).toBe(true) + } + ) }) diff --git a/src/main/codex/codex-real-home-path.ts b/src/main/codex/codex-real-home-path.ts index 700359d4f37..60c644032f8 100644 --- a/src/main/codex/codex-real-home-path.ts +++ b/src/main/codex/codex-real-home-path.ts @@ -5,6 +5,8 @@ import { readShellStartupEnvVar } from '../pty/shell-startup-env' export type CodexShellStartupHomeOverride = { home: string shell?: string + /** Why recorded: fish reads config under it, so re-reads must use the same root. */ + configHome?: string codexHome: string } @@ -54,7 +56,10 @@ export function getCustomCodexHomeOverrideForLaunch( } const home = launchEnv ? getLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME const shell = launchEnv ? getLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL - const shellCodexHome = readShellStartupEnvVar('CODEX_HOME', home, shell) + const configHome = launchEnv + ? getLaunchEnvValue(launchEnv, 'XDG_CONFIG_HOME') + : process.env.XDG_CONFIG_HOME + const shellCodexHome = readShellStartupEnvVar('CODEX_HOME', home, shell, configHome) if (!home || !shellCodexHome || !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome })) { return null } @@ -63,6 +68,7 @@ export function getCustomCodexHomeOverrideForLaunch( context: { home, ...(shell ? { shell } : {}), + ...(configHome ? { configHome } : {}), codexHome: shellCodexHome } } @@ -85,7 +91,8 @@ export function shellStartupCodexHomeOverrideMatches( const currentCodexHome = readShellStartupEnvVar( 'CODEX_HOME', currentContext.home, - currentContext.shell + currentContext.shell, + currentContext.configHome ) return Boolean( currentCodexHome && @@ -101,15 +108,16 @@ export function shellStartupCodexHomeOverrideContextsEqual( return ( normalizePathForComparison(left.home) === normalizePathForComparison(right.home) && left.shell === right.shell && + left.configHome === right.configHome && normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome) ) } function getLaunchEnvValue( launchEnv: NodeJS.ProcessEnv, - key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL' + key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL' | 'XDG_CONFIG_HOME' ): string | undefined { - return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key] + return Object.hasOwn(launchEnv, key) ? launchEnv[key] : process.env[key] } function normalizePathForComparison(value: string): string { diff --git a/src/main/codex/codex-session-migration-ignored-launches.ts b/src/main/codex/codex-session-migration-ignored-launches.ts new file mode 100644 index 00000000000..fcddb0393bf --- /dev/null +++ b/src/main/codex/codex-session-migration-ignored-launches.ts @@ -0,0 +1,35 @@ +import { + CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS, + evictStaleLeaseEntries +} from './codex-session-migration-recent-exits' + +type IgnoredLaunch = { recordedAt: number } + +/** Lease ids whose reattach was proven away from the shared home, so their exit owes no scan. */ +export class CodexSessionMigrationIgnoredLaunches { + private readonly launches = new Map() + + add(leaseId: string): void { + const now = Date.now() + this.launches.delete(leaseId) + this.launches.set(leaseId, { recordedAt: now }) + evictStaleLeaseEntries(this.launches, now) + } + + has(leaseId: string): boolean { + const launch = this.launches.get(leaseId) + if (!launch) { + return false + } + // Why: an entry whose exit never arrived must age out, or it absorbs a later exit and strands that launch active. + if (Date.now() - launch.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + this.launches.delete(leaseId) + return false + } + return true + } + + delete(leaseId: string): boolean { + return this.has(leaseId) && this.launches.delete(leaseId) + } +} diff --git a/src/main/codex/codex-session-migration-recent-exits.test.ts b/src/main/codex/codex-session-migration-recent-exits.test.ts new file mode 100644 index 00000000000..84d08768402 --- /dev/null +++ b/src/main/codex/codex-session-migration-recent-exits.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { CodexSessionMigrationRecentExits } from './codex-session-migration-recent-exits' + +describe('CodexSessionMigrationRecentExits', () => { + it('refreshes a rerecorded ID before evicting the oldest entry', () => { + const exits = new CodexSessionMigrationRecentExits() + for (let index = 0; index < 256; index += 1) { + exits.record(`pty-${index}`, index + 1) + } + + exits.record('pty-0', 500) + exits.record('pty-overflow', 501) + + expect(exits.matchesAfter('pty-0', 499)).toBe(true) + expect(exits.matchesAfter('pty-1', 0)).toBe(false) + }) +}) diff --git a/src/main/codex/codex-session-migration-recent-exits.ts b/src/main/codex/codex-session-migration-recent-exits.ts new file mode 100644 index 00000000000..2a92441537b --- /dev/null +++ b/src/main/codex/codex-session-migration-recent-exits.ts @@ -0,0 +1,60 @@ +export const CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS = 60_000 +const MAX_ENTRIES = 256 + +type RecentPtyExit = { sequence: number; recordedAt: number } + +// Why: one aging policy for every lease-keyed migration map, so none can grow for the process lifetime. +export function evictStaleLeaseEntries( + entries: Map, + now: number +): void { + for (const [leaseId, entry] of entries) { + if (now - entry.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + entries.delete(leaseId) + } + } + while (entries.size > MAX_ENTRIES) { + const oldestLeaseId = entries.keys().next().value + if (oldestLeaseId === undefined) { + break + } + entries.delete(oldestLeaseId) + } +} + +export class CodexSessionMigrationRecentExits { + private readonly exits = new Map() + + record(leaseId: string, sequence: number): void { + const now = Date.now() + this.exits.delete(leaseId) + this.exits.set(leaseId, { sequence, recordedAt: now }) + evictStaleLeaseEntries(this.exits, now) + } + + consumeAfter(leaseId: string, startedSequence: number | undefined): RecentPtyExit | null { + const exit = this.exits.get(leaseId) + this.exits.delete(leaseId) + if ( + !exit || + startedSequence === undefined || + exit.sequence <= startedSequence || + Date.now() - exit.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS + ) { + return null + } + return exit + } + + matchesAfter(leaseId: string, startedSequence: number | undefined): boolean { + const exit = this.exits.get(leaseId) + if (!exit) { + return false + } + if (Date.now() - exit.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + this.exits.delete(leaseId) + return false + } + return startedSequence !== undefined && exit.sequence > startedSequence + } +} diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts index 9f9aed3173c..b495afb8e0e 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -491,6 +491,174 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) }) + it('keeps an ignored reattach exit from poisoning a same-ID launch already starting', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.finishLaunch('stable-pty', 3) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: expect.any(Array), + writeCompletionMarker: false, + writeBoundedCompletionMarker: false + }), + undefined + ) + }) + + it('matches an ignored reattach exit that arrived before its lifecycle callback', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 3) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: expect.any(Array), + writeCompletionMarker: false, + writeBoundedCompletionMarker: false + }), + undefined + ) + }) + + it('keeps a newer same-ID launch active while the ignored incarnation exits', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + scheduler.finishLaunch('stable-pty', 3) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 4) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('releases a stranded active launch once the ignored incarnation ages out', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + // The ignored incarnation never reports its exit, so only the newer launch ever finishes. + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(61_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 3) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('keeps late duplicate ignored callbacks from poisoning reuse', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.finishLaunch('stable-pty', 3) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.beginLaunch('stable-pty', false, new Date(), 4) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledTimes(1) + + scheduler.finishLaunch('stable-pty', 5) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledTimes(2) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('keeps three exit-before-callback reattaches from stranding a reused ID', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('stable-pty', 4) + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 3) + scheduler.beginLaunch('stable-pty', false, new Date(), 5) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 6) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledTimes(2) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + it('does not consume an exit from an earlier stable-id incarnation', async () => { const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const scheduler = createCodexSessionMigrationScheduler({ diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index 28ce4b991da..9f08d83c5ea 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -1,4 +1,6 @@ import { getCodexSessionBackfillDate } from './codex-session-backfill-date' +import { CodexSessionMigrationIgnoredLaunches } from './codex-session-migration-ignored-launches' +import { CodexSessionMigrationRecentExits } from './codex-session-migration-recent-exits' import type { CodexSessionBackfillDate, CodexSessionBackfillOptions @@ -9,11 +11,6 @@ type MigrationRun = ( systemCodexHomePathOverride?: string ) => Promise -const EARLY_PTY_EXIT_RETENTION_MS = 60_000 -const MAX_EARLY_PTY_EXITS = 256 - -type EarlyPtyExit = { sequence: number; recordedAt: number } - export type CodexSessionMigrationScheduler = { beginLaunch( leaseId: string, @@ -21,6 +18,7 @@ export type CodexSessionMigrationScheduler = { startedAt?: Date, startedSequence?: number ): void + ignoreLaunch(leaseId: string, startedSequence: number): void finishLaunch(leaseId: string, exitSequence?: number): void scheduleInitialRun(): void scheduleRun(fullScanRequired?: boolean): void @@ -46,7 +44,10 @@ export function createCodexSessionMigrationScheduler(args: { let pendingFullScan = false let migrationTask: Promise | null = null const activeLaunches = new Map() - const earlyPtyExits = new Map() + // Why: non-shared reattach exits must not masquerade as exit-before-begin races for a reused id. + const ignoredLaunches = new CodexSessionMigrationIgnoredLaunches() + const ignoredPtyExits = new CodexSessionMigrationRecentExits() + const earlyPtyExits = new CodexSessionMigrationRecentExits() let activeRunStopObserved = false let rerunRequested = false @@ -194,18 +195,38 @@ export function createCodexSessionMigrationScheduler(args: { if (args.isQuitting() || activeLaunches.has(leaseId)) { return } - if (consumeEarlyPtyExit(earlyPtyExits, leaseId, startedSequence)) { + if (earlyPtyExits.consumeAfter(leaseId, startedSequence)) { scheduleRun(true) return } activeLaunches.set(leaseId, startedAt) scheduleRun(fullScanRequired) }, + ignoreLaunch(leaseId, startedSequence): void { + if (args.isQuitting() || ignoredLaunches.has(leaseId)) { + return + } + if (ignoredPtyExits.matchesAfter(leaseId, startedSequence)) { + return + } + const earlyExit = earlyPtyExits.consumeAfter(leaseId, startedSequence) + if (earlyExit) { + ignoredPtyExits.record(leaseId, earlyExit.sequence) + return + } + ignoredLaunches.add(leaseId) + }, finishLaunch(leaseId, exitSequence): void { + if (ignoredLaunches.delete(leaseId)) { + if (exitSequence !== undefined) { + ignoredPtyExits.record(leaseId, exitSequence) + } + return + } const startedAt = activeLaunches.get(leaseId) if (!startedAt) { if (exitSequence !== undefined) { - recordEarlyPtyExit(earlyPtyExits, leaseId, exitSequence) + earlyPtyExits.record(leaseId, exitSequence) } return } @@ -253,42 +274,6 @@ function compareBackfillDates( return left.join('-').localeCompare(right.join('-')) } -function recordEarlyPtyExit( - exits: Map, - leaseId: string, - sequence: number -): void { - const now = Date.now() - for (const [id, exit] of exits) { - if (now - exit.recordedAt > EARLY_PTY_EXIT_RETENTION_MS) { - exits.delete(id) - } - } - exits.set(leaseId, { sequence, recordedAt: now }) - while (exits.size > MAX_EARLY_PTY_EXITS) { - const oldestLeaseId = exits.keys().next().value - if (oldestLeaseId === undefined) { - break - } - exits.delete(oldestLeaseId) - } -} - -function consumeEarlyPtyExit( - exits: Map, - leaseId: string, - startedSequence: number | undefined -): boolean { - const exit = exits.get(leaseId) - exits.delete(leaseId) - return ( - exit !== undefined && - startedSequence !== undefined && - exit.sequence > startedSequence && - Date.now() - exit.recordedAt <= EARLY_PTY_EXIT_RETENTION_MS - ) -} - function isStoppedMigrationResult(result: unknown): boolean { return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) } diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts index 75d91d7ab34..2f0aa5429c6 100644 --- a/src/main/codex/codex-session-resume-home.test.ts +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -140,7 +140,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { trustedCodexHomes: [homePath], ...withoutHomeRanking, fileIsRegular: () => true, - listSessionFiles: async function* (): AsyncIterable { + async *listSessionFiles(): AsyncIterable { yield extendedEntry } }) @@ -153,7 +153,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { trustedCodexHomes: [homePath], ...withoutHomeRanking, fileIsRegular: () => true, - listSessionFiles: async function* (): AsyncIterable { + async *listSessionFiles(): AsyncIterable { yield `\\\\.\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-${sessionId}.jsonl` } }) diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index c5ee4802e5b..ebc953a4be7 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,6 +8,8 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, + hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, recordCodexPaneAccount } from './codex-pane-account-registry' @@ -46,6 +48,17 @@ afterEach(() => { }) describe('codex pane account registry', () => { + it.each([ + ['real-home', true], + ['account-home', true], + ['wsl-home', true], + ['shared-home', false], + ['custom-home', false], + [undefined, false] + ] as const)('classifies whether %s proves a pane avoided the shared home', (route, expected) => { + expect(isCodexPaneHomeRouteProvenAwayFromSharedHome(route)).toBe(expected) + }) + it('survives a process restart so a daemon-backed shell stays attributable', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', @@ -125,6 +138,40 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) + it('requests startup inventory only for managed host panes', () => { + recordCodexPaneAccount('pty-real', { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home' + }) + recordCodexPaneAccount('pty-wsl', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-wsl', + homeRoute: 'account-home' + }) + + expect(hasRecordedManagedHostCodexPane()).toBe(false) + + recordCodexPaneAccount('pty-shared', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + + expect(hasRecordedManagedHostCodexPane()).toBe(true) + + forgetCodexPaneAccount('pty-shared') + expect(hasRecordedManagedHostCodexPane()).toBe(false) + + recordCodexPaneAccount('pty-account', { + selectionKey: 'host', + accountId: 'account-host', + homeRoute: 'account-home' + }) + + expect(hasRecordedManagedHostCodexPane()).toBe(true) + }) + it('drops leaked records that are absent from the authoritative daemon inventory', () => { recordCodexPaneAccount('pty-live', { selectionKey: 'host', diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 1c4052cefca..3bf087b14d9 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -19,6 +19,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { @@ -1021,6 +1022,10 @@ function getWslReconciliationKey(runtimeHomePath: string): string { } export class CodexHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + private readonly wslReconciliationGeneration = new Map() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { diff --git a/src/main/codex/retained-codex-hook-state.test.ts b/src/main/codex/retained-codex-hook-state.test.ts new file mode 100644 index 00000000000..642b8bc7476 --- /dev/null +++ b/src/main/codex/retained-codex-hook-state.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { reconcileRetainedCodexHookHomes } from './retained-codex-hook-state' + +function status(state: 'installed' | 'not_installed' | 'error'): AgentHookInstallStatus { + return { + agent: 'codex', + state, + configPath: '/runtime/hooks.json', + managedHooksPresent: state === 'installed', + detail: state === 'error' ? 'failed' : null + } +} + +describe('retained Codex hook state', () => { + it('repairs Orca hooks before a retained shell can launch Codex', () => { + const install = vi.fn(() => status('installed')) + const refreshRuntimeUserHooks = vi.fn(() => status('not_installed')) + + reconcileRetainedCodexHookHomes({ + hookService: { install, refreshRuntimeUserHooks }, + hooksEnabled: true, + runtimeHomePaths: ['/orca/shared-home', '/orca/account-home'] + }) + + expect(install).toHaveBeenCalledTimes(2) + expect(install).toHaveBeenNthCalledWith(1, '/orca/shared-home') + expect(install).toHaveBeenNthCalledWith(2, '/orca/account-home') + expect(refreshRuntimeUserHooks).not.toHaveBeenCalled() + }) + + it('removes only Orca hooks from retained homes when hooks are disabled', () => { + const install = vi.fn(() => status('installed')) + const refreshRuntimeUserHooks = vi.fn(() => status('not_installed')) + + reconcileRetainedCodexHookHomes({ + hookService: { install, refreshRuntimeUserHooks }, + hooksEnabled: false, + runtimeHomePaths: ['/orca/shared-home'] + }) + + expect(refreshRuntimeUserHooks).toHaveBeenCalledWith('/orca/shared-home') + expect(install).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/retained-codex-hook-state.ts b/src/main/codex/retained-codex-hook-state.ts new file mode 100644 index 00000000000..ccdf8c86af3 --- /dev/null +++ b/src/main/codex/retained-codex-hook-state.ts @@ -0,0 +1,26 @@ +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' + +type RetainedCodexHookService = { + install: (runtimeHomePath: string) => AgentHookInstallStatus + refreshRuntimeUserHooks: (runtimeHomePath: string) => AgentHookInstallStatus +} + +export function reconcileRetainedCodexHookHomes(args: { + hookService: RetainedCodexHookService + hooksEnabled: boolean + runtimeHomePaths: readonly string[] +}): void { + for (const runtimeHomePath of args.runtimeHomePaths) { + try { + const status = args.hooksEnabled + ? args.hookService.install(runtimeHomePath) + : args.hookService.refreshRuntimeUserHooks(runtimeHomePath) + if (status.state === 'error') { + console.warn('[codex-hook-service] failed to reconcile retained Codex home', status.detail) + } + } catch (error) { + // Why: a retained home repair is best-effort; daemon availability must not depend on a writable Codex config. + console.warn('[codex-hook-service] failed to reconcile retained Codex home', error) + } + } +} diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 2906ffd2da3..1e1ab9e56af 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -14,6 +14,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -88,6 +89,10 @@ function buildInstalledConfig( } export class CommandCodeHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), buildCommandCodeManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/computer/key-mapping.test.ts b/src/main/computer/key-mapping.test.ts deleted file mode 100644 index 6b599cd483c..00000000000 --- a/src/main/computer/key-mapping.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseKey } from './key-mapping' - -describe('parseKey', () => { - it.each([ - ['a', { key: 'a', modifiers: [] }], - ['7', { key: '7', modifiers: [] }], - [';', { key: ';', modifiers: [] }], - ['+', { key: '+', modifiers: [] }], - ['Return', { key: 'Enter', modifiers: [] }], - ['Escape', { key: 'Escape', modifiers: [] }], - ['BackSpace', { key: 'Backspace', modifiers: [] }], - ['Tab', { key: 'Tab', modifiers: [] }], - ['space', { key: 'Space', modifiers: [] }], - ['Page_Up', { key: 'PageUp', modifiers: [] }], - ['Page_Down', { key: 'PageDown', modifiers: [] }], - ['Home', { key: 'Home', modifiers: [] }], - ['End', { key: 'End', modifiers: [] }], - ['Up', { key: 'ArrowUp', modifiers: [] }], - ['Down', { key: 'ArrowDown', modifiers: [] }], - ['Left', { key: 'ArrowLeft', modifiers: [] }], - ['Right', { key: 'ArrowRight', modifiers: [] }], - ['Delete', { key: 'Delete', modifiers: [] }], - ['Insert', { key: 'Insert', modifiers: [] }], - ['F1', { key: 'F1', modifiers: [] }], - ['F24', { key: 'F24', modifiers: [] }], - ['KP_0', { key: 'Numpad0', modifiers: [] }], - ['KP_9', { key: 'Numpad9', modifiers: [] }], - ['KP_Add', { key: 'NumpadAdd', modifiers: [] }], - ['KP_Subtract', { key: 'NumpadSubtract', modifiers: [] }], - ['KP_Multiply', { key: 'NumpadMultiply', modifiers: [] }], - ['KP_Divide', { key: 'NumpadDivide', modifiers: [] }], - ['KP_Enter', { key: 'NumpadEnter', modifiers: [] }] - ])('maps %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it.each([ - ['ctrl+a', { key: 'a', modifiers: ['Ctrl'] }], - ['control+a', { key: 'a', modifiers: ['Ctrl'] }], - ['ctrl+shift+t', { key: 't', modifiers: ['Ctrl', 'Shift'] }], - ['alt+F4', { key: 'F4', modifiers: ['Alt'] }], - ['cmd+a', { key: 'a', modifiers: ['Meta'] }], - ['command+a', { key: 'a', modifiers: ['Meta'] }], - ['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }], - ['ctrl++', { key: '+', modifiers: ['Ctrl'] }], - ['ctrl+shift++', { key: '+', modifiers: ['Ctrl', 'Shift'] }], - ['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }], - ['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }] - ])('maps chord %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it('rejects unknown modifiers and key names', () => { - expect(() => parseKey('hyper+a')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('NotAKey')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('F25')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - }) -}) diff --git a/src/main/computer/key-mapping.ts b/src/main/computer/key-mapping.ts deleted file mode 100644 index 4634f241268..00000000000 --- a/src/main/computer/key-mapping.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { RuntimeClientError } from './runtime-client-error' - -export type KeyChord = { - key: string - modifiers: string[] -} - -const MODIFIER_NAMES: Record = { - ctrl: 'Ctrl', - control: 'Ctrl', - shift: 'Shift', - alt: 'Alt', - meta: 'Meta', - super: 'Meta', - cmd: 'Meta', - command: 'Meta', - win: 'Meta' -} - -const PLATFORM_MODIFIER_NAMES: Record string> = { - cmdorctrl: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl'), - commandorcontrol: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl') -} - -const KEY_NAMES: Record = { - Return: 'Enter', - Enter: 'Enter', - Escape: 'Escape', - Esc: 'Escape', - BackSpace: 'Backspace', - Backspace: 'Backspace', - Tab: 'Tab', - space: 'Space', - Space: 'Space', - Page_Up: 'PageUp', - Page_Down: 'PageDown', - Home: 'Home', - End: 'End', - Up: 'ArrowUp', - Down: 'ArrowDown', - Left: 'ArrowLeft', - Right: 'ArrowRight', - Delete: 'Delete', - Insert: 'Insert', - KP_Add: 'NumpadAdd', - KP_Subtract: 'NumpadSubtract', - KP_Multiply: 'NumpadMultiply', - KP_Divide: 'NumpadDivide', - KP_Enter: 'NumpadEnter' -} - -export function parseKey(input: string): KeyChord { - const parts = splitKeyChord(input) - if (parts.length === 0) { - throw new RuntimeClientError('invalid_argument', 'key must not be empty') - } - - const keyPart = parts.at(-1)! - const modifiers = parts.slice(0, -1).map(parseModifier) - return { - key: parseBaseKey(keyPart), - modifiers: dedupeModifiers(modifiers) - } -} - -function splitKeyChord(input: string): string[] { - const trimmed = input.trim() - // Why: `+` is both the chord separator and a printable key users can press. - if (trimmed === '+') { - return ['+'] - } - if (trimmed.endsWith('+')) { - return [ - ...trimmed - .slice(0, -1) - .split('+') - .map((part) => part.trim()) - .filter(Boolean), - '+' - ] - } - return input - .split('+') - .map((part) => part.trim()) - .filter(Boolean) -} - -function parseModifier(input: string): string { - const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()] - if (platformModifier) { - return platformModifier() - } - const modifier = MODIFIER_NAMES[input.toLowerCase()] - if (!modifier) { - throw new RuntimeClientError('invalid_argument', `unknown modifier '${input}'`) - } - return modifier -} - -function parseBaseKey(input: string): string { - const mapped = KEY_NAMES[input] - if (mapped) { - return mapped - } - if (/^F([1-9]|1[0-9]|2[0-4])$/.test(input)) { - return input - } - const keypadDigit = input.match(/^KP_([0-9])$/) - if (keypadDigit) { - return `Numpad${keypadDigit[1]}` - } - if (isPrintableAscii(input)) { - return input - } - throw new RuntimeClientError('invalid_argument', `unknown key '${input}'`) -} - -function isPrintableAscii(input: string): boolean { - if (input.length !== 1) { - return false - } - const code = input.charCodeAt(0) - return code >= 0x20 && code <= 0x7e -} - -function dedupeModifiers(modifiers: string[]): string[] { - return [...new Set(modifiers)] -} diff --git a/src/main/computer/macos-native-provider-transport.ts b/src/main/computer/macos-native-provider-transport.ts index 54c4cf773aa..1048ce5a94c 100644 --- a/src/main/computer/macos-native-provider-transport.ts +++ b/src/main/computer/macos-native-provider-transport.ts @@ -52,7 +52,7 @@ export function consumeNativeProviderLines( let remaining = buffer while (true) { const newline = remaining.indexOf('\n') - if (newline < 0) { + if (newline === -1) { return remaining } const line = remaining.slice(0, newline) diff --git a/src/main/computer/permissions.test.ts b/src/main/computer/permissions.test.ts deleted file mode 100644 index fd94f389b34..00000000000 --- a/src/main/computer/permissions.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - notificationCtorMock, - notificationIsSupportedMock, - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock, - isTrustedAccessibilityClientMock -} = vi.hoisted(() => { - const notificationOnMock = vi.fn() - const notificationRemoveListenerMock = vi.fn() - const notificationShowMock = vi.fn() - const notificationCtorMock = vi.fn(function () { - return { - on: notificationOnMock, - removeListener: notificationRemoveListenerMock, - show: notificationShowMock - } - }) - return { - notificationCtorMock, - notificationIsSupportedMock: vi.fn(() => true), - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock: vi.fn(), - isTrustedAccessibilityClientMock: vi.fn(() => false) - } -}) - -vi.mock('electron', () => ({ - Notification: Object.assign(notificationCtorMock, { - isSupported: notificationIsSupportedMock - }), - shell: { - openExternal: shellOpenExternalMock - }, - systemPreferences: { - isTrustedAccessibilityClient: isTrustedAccessibilityClientMock - } -})) - -import { notifyPermissionRequired } from './permissions' - -describe('notifyPermissionRequired', () => { - const originalPlatform = process.platform - - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllTimers() - notificationCtorMock.mockClear() - notificationIsSupportedMock.mockReset() - notificationIsSupportedMock.mockReturnValue(true) - notificationOnMock.mockClear() - notificationRemoveListenerMock.mockClear() - notificationShowMock.mockClear() - shellOpenExternalMock.mockClear() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - afterEach(() => { - vi.useRealTimers() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void { - const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName) - if (!call) { - throw new Error(`Notification ${eventName} handler not registered`) - } - return call[1] as (...args: unknown[]) => void - } - - it('clears the retained notification fallback timer when the notification closes', () => { - notifyPermissionRequired('Enable accessibility') - - expect(notificationShowMock).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(1) - - const closeHandler = getNotificationEventHandler('close') - closeHandler() - - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function)) - }) - - it('opens macOS Accessibility settings on click after releasing notification state', () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - notifyPermissionRequired('Enable accessibility') - - getNotificationEventHandler('click')() - - expect(shellOpenExternalMock).toHaveBeenCalledWith( - expect.stringContaining('Privacy_Accessibility') - ) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears retained notification state when native delivery fails', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - notifyPermissionRequired('Enable accessibility') - expect(vi.getTimerCount()).toBe(1) - - const failedHandler = getNotificationEventHandler('failed') - failedHandler({}, 'Application is not code signed') - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Accessibility permission notification failed') - ) - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) - } finally { - warn.mockRestore() - } - }) -}) diff --git a/src/main/computer/permissions.ts b/src/main/computer/permissions.ts deleted file mode 100644 index a98f01c3dbc..00000000000 --- a/src/main/computer/permissions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Notification, shell } from 'electron' - -const ACCESSIBILITY_SETTINGS_URL = - 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' - -const activePermissionNotifications = new Set() - -/** Surface a notification through Orca's existing notification system (do not duplicate UI). */ -export function notifyPermissionRequired(instructions: string): void { - if (!Notification.isSupported()) { - return - } - - const notification = new Notification({ - title: 'Accessibility permission required', - body: instructions - }) - activePermissionNotifications.add(notification) - - let releaseTimer: ReturnType | null = null - let released = false - function release(): void { - if (released) { - return - } - released = true - activePermissionNotifications.delete(notification) - notification.removeListener('close', release) - notification.removeListener('click', onClick) - notification.removeListener('failed', onFailed) - if (releaseTimer) { - clearTimeout(releaseTimer) - releaseTimer = null - } - } - function onClick(): void { - release() - if (process.platform === 'darwin') { - void shell.openExternal(ACCESSIBILITY_SETTINGS_URL) - } - } - function onFailed(_event: unknown, error?: string): void { - // Why: Electron 42 reports macOS UNNotification delivery failures here, - // most commonly when a local validation build is not code-signed. - console.warn( - `[computer] Accessibility permission notification failed${error ? `: ${error}` : '.'}` - ) - release() - } - notification.on('close', release) - notification.on('click', onClick) - notification.on('failed', onFailed) - releaseTimer = setTimeout(release, 5 * 60 * 1000) - if (typeof releaseTimer.unref === 'function') { - releaseTimer.unref() - } - notification.show() -} diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index 6a8206d0b59..425efe4f00d 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -17,6 +17,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -117,7 +118,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ "Write-Output '{}'", - '$inputData = [Console]::In.ReadToEnd()', // Why: endpoint.cmd is cmd syntax, not PowerShell. Parse its `set KEY=...` // lines so surviving PTYs can refresh to the current Orca server. 'if ($env:ORCA_AGENT_HOOK_ENDPOINT -and (Test-Path -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT)) {', @@ -129,7 +129,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' }', ' } catch {}', '}', + // Why (#11549 class): missing Orca context means a user-wide hook fired outside an + // Orca pane. ReadToEnd blocks forever if that caller abandons the pipe, so the guard + // must run before the hook owns stdin; the payload would be discarded anyway. 'if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }', + '$inputData = [Console]::In.ReadToEnd()', 'if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }', 'try {', ' $payload = $inputData | ConvertFrom-Json', @@ -183,6 +187,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class CopilotHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 7b5f3b90dda..ab9d07b25eb 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -15,6 +15,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -101,6 +102,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class CursorHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() @@ -211,7 +216,7 @@ export class CursorHookService { return this.getStatus() } - // Installs managed Cursor hooks on an SSH remote (POSIX-only). See docs/design/agent-status-over-ssh.md §8. + // Installs managed Cursor hooks on an SSH remote (POSIX-only); the managed script/JSON shape must match local install() or remote panes report a different status. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.cursor/hooks.json` const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/cursor-hook.sh` diff --git a/src/main/daemon/binary-frame.test.ts b/src/main/daemon/binary-frame.test.ts deleted file mode 100644 index 81148f18a59..00000000000 --- a/src/main/daemon/binary-frame.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame' - -describe('encodeFrame', () => { - it('encodes a data frame with correct header', () => { - const payload = Buffer.from('hello') - const frame = encodeFrame(FrameType.Data, payload) - - expect(frame.length).toBe(FRAME_HEADER_SIZE + payload.length) - expect(frame[0]).toBe(FrameType.Data) - expect(frame.readUInt32BE(1)).toBe(payload.length) - expect(frame.subarray(FRAME_HEADER_SIZE).toString()).toBe('hello') - }) - - it('encodes a resize frame', () => { - const payload = Buffer.from(JSON.stringify({ cols: 120, rows: 40 })) - const frame = encodeFrame(FrameType.Resize, payload) - - expect(frame[0]).toBe(FrameType.Resize) - expect(frame.readUInt32BE(1)).toBe(payload.length) - }) - - it('encodes an exit frame with exit code', () => { - const payload = Buffer.from(JSON.stringify({ code: 0 })) - const frame = encodeFrame(FrameType.Exit, payload) - - expect(frame[0]).toBe(FrameType.Exit) - const decoded = JSON.parse(frame.subarray(FRAME_HEADER_SIZE).toString()) - expect(decoded.code).toBe(0) - }) - - it('encodes empty payload', () => { - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - - expect(frame.length).toBe(FRAME_HEADER_SIZE) - expect(frame[0]).toBe(FrameType.Kill) - expect(frame.readUInt32BE(1)).toBe(0) - }) - - it('throws on payload exceeding max size', () => { - const oversized = Buffer.alloc(1024 * 1024 + 1) - expect(() => encodeFrame(FrameType.Data, oversized)).toThrow() - }) -}) - -describe('createFrameParser', () => { - it('parses a single complete frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('test data') - const frame = encodeFrame(FrameType.Data, payload) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[0][1].toString()).toBe('test data') - }) - - it('parses multiple frames in one chunk', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame1 = encodeFrame(FrameType.Data, Buffer.from('one')) - const frame2 = encodeFrame(FrameType.Data, Buffer.from('two')) - parser.feed(Buffer.concat([frame1, frame2])) - - expect(onFrame).toHaveBeenCalledTimes(2) - expect(onFrame.mock.calls[0][1].toString()).toBe('one') - expect(onFrame.mock.calls[1][1].toString()).toBe('two') - }) - - it('handles frame split across header boundary', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('split test') - const frame = encodeFrame(FrameType.Data, payload) - - // Split in the middle of the header (3 bytes, then the rest) - parser.feed(frame.subarray(0, 3)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(3)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('split test') - }) - - it('handles frame split in the middle of payload', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('payload split') - const frame = encodeFrame(FrameType.Data, payload) - - // Split after header + 3 bytes of payload - const splitPoint = FRAME_HEADER_SIZE + 3 - parser.feed(frame.subarray(0, splitPoint)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(splitPoint)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('payload split') - }) - - it('handles byte-by-byte feeding', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Exit, Buffer.from('{"code":42}')) - - for (let i = 0; i < frame.length; i++) { - parser.feed(frame.subarray(i, i + 1)) - } - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Exit) - expect(JSON.parse(onFrame.mock.calls[0][1].toString())).toEqual({ code: 42 }) - }) - - it('handles zero-length payload frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Kill) - expect(onFrame.mock.calls[0][1].length).toBe(0) - }) - - it('parses different frame types correctly', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frames = [ - encodeFrame(FrameType.Data, Buffer.from('data')), - encodeFrame(FrameType.Resize, Buffer.from('resize')), - encodeFrame(FrameType.Signal, Buffer.from('signal')) - ] - - parser.feed(Buffer.concat(frames)) - - expect(onFrame).toHaveBeenCalledTimes(3) - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[1][0]).toBe(FrameType.Resize) - expect(onFrame.mock.calls[2][0]).toBe(FrameType.Signal) - }) - - it('resets buffer state', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - // Feed partial frame then reset - const frame = encodeFrame(FrameType.Data, Buffer.from('lost')) - parser.feed(frame.subarray(0, 3)) - parser.reset() - - // Feed a new complete frame - const frame2 = encodeFrame(FrameType.Data, Buffer.from('fresh')) - parser.feed(frame2) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('fresh') - }) -}) diff --git a/src/main/daemon/binary-frame.ts b/src/main/daemon/binary-frame.ts deleted file mode 100644 index e8c4fd6ae90..00000000000 --- a/src/main/daemon/binary-frame.ts +++ /dev/null @@ -1,56 +0,0 @@ -export { FrameType } from './types' -import type { FrameType } from './types' -import { FRAME_HEADER_SIZE, FRAME_MAX_PAYLOAD } from './types' - -export { FRAME_HEADER_SIZE } - -export function encodeFrame(type: FrameType, payload: Buffer): Buffer { - if (payload.length > FRAME_MAX_PAYLOAD) { - throw new Error(`Frame payload ${payload.length} exceeds max ${FRAME_MAX_PAYLOAD}`) - } - - const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length) - frame[0] = type - frame.writeUInt32BE(payload.length, 1) - payload.copy(frame, FRAME_HEADER_SIZE) - return frame -} - -export type FrameParser = { - feed(chunk: Buffer): void - reset(): void -} - -export function createFrameParser( - onFrame: (type: FrameType, payload: Buffer) => void -): FrameParser { - let buffer: Buffer = Buffer.alloc(0) - - function parse(): void { - while (buffer.length >= FRAME_HEADER_SIZE) { - const payloadLength = buffer.readUInt32BE(1) - const totalLength = FRAME_HEADER_SIZE + payloadLength - - if (buffer.length < totalLength) { - break - } - - const type = buffer[0] as FrameType - const payload = buffer.subarray(FRAME_HEADER_SIZE, totalLength) - buffer = buffer.subarray(totalLength) - - onFrame(type, payload) - } - } - - return { - feed(chunk: Buffer): void { - buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]) - parse() - }, - - reset(): void { - buffer = Buffer.alloc(0) - } - } -} diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 47b77cfb78e..266cd0fe255 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -335,6 +335,30 @@ describe('DaemonClient', () => { client = new DaemonClient({ socketPath, tokenPath }) await expect(client.ensureConnected()).rejects.toThrow() }) + + it('fails a retired endpoint at the connect, not at the token read', async () => { + rmSync(tokenPath) + client = new DaemonClient({ socketPath, tokenPath }) + + const error = (await client + .ensureConnected() + .catch((err: unknown) => err)) as NodeJS.ErrnoException + + expect(error.syscall).toBe('connect') + expect(['ENOENT', 'ECONNREFUSED']).toContain(error.code) + }) + + it('still attempts the handshake when the token is gone but a daemon is listening', async () => { + const hellos: HelloMessage[] = [] + await startMockDaemon({ onHello: (msg) => hellos.push(msg) }) + rmSync(tokenPath) + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + expect(hellos.length).toBeGreaterThan(0) + expect(hellos[0]?.token).toBe('') + }) }) describe('RPC', () => { diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index d137ee84ea5..147277c8450 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -114,12 +114,24 @@ export class DaemonClient { } } + // Why: a missing token must not preempt the connect that proves whether the endpoint is gone. + private readToken(): string { + try { + return readFileSync(this.tokenPath, 'utf-8').trim() + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + return '' + } + throw error + } + } + private async doConnect( timeoutMs: number, attemptGeneration: number, sharedBudget: boolean ): Promise { - const token = readFileSync(this.tokenPath, 'utf-8').trim() + const token = this.readToken() const deadlineMs = Date.now() + timeoutMs const remainingMs = (): number => sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs diff --git a/src/main/daemon/daemon-errors.test.ts b/src/main/daemon/daemon-errors.test.ts index d12ce645ee7..71374415772 100644 --- a/src/main/daemon/daemon-errors.test.ts +++ b/src/main/daemon/daemon-errors.test.ts @@ -2,8 +2,15 @@ import { describe, expect, it } from 'vitest' import { DaemonProtocolError, decodeDaemonResponseError, - SessionNotFoundError + isDaemonEndpointGoneError, + SessionNotFoundError, + TerminalHostGoneError } from './daemon-errors' +import { mapRuntimeError } from '../runtime/rpc/errors' + +function socketError(code: string, syscall: string): Error & { code: string; syscall: string } { + return Object.assign(new Error(`${syscall} ${code}`), { code, syscall }) +} describe('decodeDaemonResponseError', () => { it('types the exact legacy session-absence response', () => { @@ -18,3 +25,44 @@ describe('decodeDaemonResponseError', () => { ) }) }) + +describe('isDaemonEndpointGoneError', () => { + it('recognizes a missing Windows named pipe', () => { + const err = Object.assign( + new Error('connect ENOENT \\\\?\\pipe\\orca-terminal-host-v30-14cb7f94b511'), + { code: 'ENOENT', syscall: 'connect' } + ) + expect(isDaemonEndpointGoneError(err)).toBe(true) + }) + + it('recognizes a refused socket', () => { + expect(isDaemonEndpointGoneError(socketError('ECONNREFUSED', 'connect'))).toBe(true) + }) + + it('ignores a missing token file, which does not prove the endpoint is gone', () => { + expect(isDaemonEndpointGoneError(socketError('ENOENT', 'open'))).toBe(false) + }) + + it('ignores connect failures that are not proof of absence', () => { + for (const code of ['ETIMEDOUT', 'ECONNRESET', 'EPIPE', 'EACCES']) { + expect(isDaemonEndpointGoneError(socketError(code, 'connect'))).toBe(false) + } + }) + + it('ignores non-socket errors and non-objects', () => { + expect(isDaemonEndpointGoneError(new Error('Connection lost'))).toBe(false) + expect(isDaemonEndpointGoneError('connect ENOENT')).toBe(false) + expect(isDaemonEndpointGoneError(null)).toBe(false) + expect(isDaemonEndpointGoneError(undefined)).toBe(false) + }) + + it('keeps the host-gone marker across runtime RPC error mapping', () => { + const response = mapRuntimeError( + 'req-1', + { runtimeId: 'runtime-1' }, + new TerminalHostGoneError() + ) + + expect(response.error).toEqual({ code: 'runtime_error', message: 'terminal_host_gone' }) + }) +}) diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts index bddb3690a4a..d5b7dc2418e 100644 --- a/src/main/daemon/daemon-errors.ts +++ b/src/main/daemon/daemon-errors.ts @@ -28,6 +28,24 @@ export class TerminalSessionOwnerUnverifiedError extends Error { } } +export class TerminalHostGoneError extends Error { + constructor() { + super('terminal_host_gone') + this.name = 'TerminalHostGoneError' + } +} + +// Connect ENOENT/ECONNREFUSED proves the endpoint is absent; open ENOENT can be a missing token file. +export function isDaemonEndpointGoneError(err: unknown): boolean { + const candidate = err as { code?: unknown; syscall?: unknown } | null + return ( + typeof candidate === 'object' && + candidate !== null && + candidate.syscall === 'connect' && + (candidate.code === 'ENOENT' || candidate.code === 'ECONNREFUSED') + ) +} + export function decodeDaemonResponseError(message: string): Error { const prefix = 'Session not found: ' return message.startsWith(prefix) diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 07f291a60ff..7ce08eaf83b 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(32) + expect(PROTOCOL_VERSION).toBe(33) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -15,5 +15,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(31) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(32) }) }) diff --git a/src/main/daemon/daemon-incarnation-evidence.ts b/src/main/daemon/daemon-incarnation-evidence.ts index 01d9ffc97c5..f5c88935998 100644 --- a/src/main/daemon/daemon-incarnation-evidence.ts +++ b/src/main/daemon/daemon-incarnation-evidence.ts @@ -237,7 +237,7 @@ async function probeWindowsProcess( export function parseLinuxProcessState(statLine: string): string | null { const commandEnd = statLine.lastIndexOf(')') - if (commandEnd < 0) { + if (commandEnd === -1) { return null } return ( diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 53ad0c06341..d47eecc9722 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -8,6 +8,7 @@ import { MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION, + WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, supportsMode2031UnsubscribeFact @@ -15,7 +16,8 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(32) + expect(PROTOCOL_VERSION).toBe(33) + expect(WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION).toBe(33) expect(SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION).toBe(32) expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31) expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30) @@ -25,7 +27,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 31 }, (_, index) => index + 1) + Array.from({ length: 32 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index fee06b235f2..64c4d559670 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v32 carries the corrected terminal snapshot serializer; older owners remain attachable. -export const PROTOCOL_VERSION = 32 +// v33 preserves selected-WSL POSIX cwd semantics; older owners remain attachable. +export const PROTOCOL_VERSION = 33 +export const WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION = 33 export const SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION = 32 export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31 export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30 @@ -26,7 +27,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31 + 28, 29, 30, 31, 32 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 7a697e6d96c..1e41e9de9ee 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -595,6 +595,42 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('respawns after a retired daemon regardless of how the connection ended', async () => { + // Why this shape: the daemon retires when its last authenticated client drops, and that drop + // is often our own disconnect() — which observes no socket close. Once the token read stops + // preempting the connect, the retired endpoint fails as a connect and isDaemonGoneError + // classifies it, so recovery no longer depends on having witnessed the drop. + let respawnServer: DaemonServer | undefined + const respawn = vi.fn(async () => { + respawnServer = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await respawnServer.start() + }) + const healingAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { id } = await healingAdapter.spawn({ cols: 80, rows: 24 }) + const client = (healingAdapter as unknown as { client: DaemonClient }).client + + client.disconnect() + await server.shutdown() + expect(existsSync(tokenPath)).toBe(false) + expect(client.hasObservedAuthenticatedDisconnect()).toBe(false) + + await expect( + healingAdapter.spawn({ sessionId: id, cols: 80, rows: 24 }) + ).resolves.toMatchObject({ id }) + expect(respawn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + healingAdapter.dispose() + await respawnServer?.shutdown() + } + }) + it('does not spawn a daemon per keystroke after respawn fails', async () => { const respawn = vi.fn(async () => { throw new Error('daemon unavailable') @@ -1321,6 +1357,60 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { source: 'headless' }) }) + + // A proven `0` and an absent field are different facts. Dropping + // the zero left consumers unable to tell "the app negotiated nothing" from + // "this source could not say", which is what makes Preview guess wrong. + it('publishes proven kitty flags including a known zero', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('plain output\r\n') + + await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({ + kittyKeyboardFlags: 0 + }) + + lastSubprocess._simulateData('\x1b[>8u') + await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({ + kittyKeyboardFlags: 8 + }) + }) + + // The other half of that contract: a daemon that cannot say must leave the + // key absent, so consumers keep the state unknown instead of reading a `0`. + it('omits kitty flags when the daemon snapshot has none', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('plain output\r\n') + const realRequest = DaemonClient.prototype.request + const legacyClient = vi + .spyOn(DaemonClient.prototype, 'request') + .mockImplementation(async function (this: DaemonClient, type, payload, timeoutMs) { + const result = await realRequest.call(this, type, payload, timeoutMs) + if (type !== 'getSnapshot') { + return result + } + const { snapshot } = result as { snapshot: { modes: Record } } + const { kittyKeyboardFlags: _omitted, ...modes } = snapshot.modes + return { snapshot: { ...snapshot, modes } } + }) + + const snapshot = await adapter.getBufferSnapshot(id) + legacyClient.mockRestore() + + expect(snapshot).not.toBeNull() + expect(snapshot).not.toHaveProperty('kittyKeyboardFlags') + }) + + it('exposes live state separately from the visible frame', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('\x1b[?1049h\x1b[?1004h\x1b[?25lframe') + + const snapshot = await adapter.getBufferSnapshot(id) + + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot?.frameRestoreAnsi).not.toContain('frame') + expect(snapshot?.data).toContain('frame') + }) }) describe('shutdown', () => { @@ -1506,6 +1596,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.snapshot).toContain('prompt$') }) + it('publishes the alt-frame payload as explicit strings', async () => { + const sessionId = 'alt-frame-boundary' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + lastSubprocess._simulateData('\x1b[?1049h\x1b[HSTATIC-ALT-FRAME') + await new Promise((r) => setTimeout(r, 50)) + + const result = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.snapshotPrefixAnsi).toContain('\x1b[?1049h') + expect(result.snapshotFrameAnsi).toContain('STATIC-ALT-FRAME') + expect(result.snapshot).toBe([result.snapshotPrefixAnsi, result.snapshotFrameAnsi].join('')) + expect(result.snapshotFrameRestoreAnsi).not.toContain('STATIC-ALT-FRAME') + }) + it('returns the preserved sequence from attach-only adoption', async () => { const sessionId = 'attach-sequence-handoff' await adapter.spawn({ cols: 80, rows: 24, sessionId }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f8bdf30deab..a66ec323848 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -59,6 +59,7 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' import { isShellProcess } from '../../shared/agent-detection' import { resolveWslSessionContext } from './wsl-session-context' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' @@ -831,12 +832,15 @@ export class DaemonPtyAdapter implements IPtyProvider { } const isAltScreen = result.snapshot.modes.alternateScreen - const snapshotPayload = - result.snapshot.scrollbackAnsi + - result.snapshot.rehydrateSequences + - result.snapshot.snapshotAnsi + const snapshotPrefix = result.snapshot.scrollbackAnsi + result.snapshot.rehydrateSequences + const snapshotFrame = result.snapshot.snapshotAnsi + const snapshotPayload = snapshotPrefix + snapshotFrame // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). - const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags + // Why known `0` is no longer dropped: the pane tracker must be able to tell + // "the app negotiated nothing" from "this reattach proved nothing". + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( + result.snapshot.modes.kittyKeyboardFlags + ) return { id: sessionId, ...incarnationResult(), @@ -847,8 +851,16 @@ export class DaemonPtyAdapter implements IPtyProvider { snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + // Why only for an alt frame: normal history remains safe to replay at its capture grid. + ...(isAltScreen && snapshotFrame && result.snapshot.frameRestoreAnsi + ? { + snapshotPrefixAnsi: snapshotPrefix, + snapshotFrameAnsi: snapshotFrame, + snapshotFrameRestoreAnsi: result.snapshot.frameRestoreAnsi + } + : {}), ...(providerSequence ? { providerSequence } : {}), - ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 + ...(kittyKeyboardFlags !== undefined ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } : {}), isReattach: true, @@ -1214,8 +1226,10 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!snapshot || typeof snapshot.outputSequence !== 'number') { return null } + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags) return { data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + frameRestoreAnsi: snapshot.frameRestoreAnsi, scrollbackAnsi: snapshot.scrollbackAnsi, cols: snapshot.cols, rows: snapshot.rows, @@ -1225,6 +1239,9 @@ export class DaemonPtyAdapter implements IPtyProvider { source: 'headless', oscLinks: snapshot.oscLinks, alternateScreen: snapshot.modes.alternateScreen, + // Why known `0` is carried too: it proves the app negotiated nothing at + // this boundary, which is a different fact from a source that cannot say. + ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}) @@ -1456,8 +1473,7 @@ export class DaemonPtyAdapter implements IPtyProvider { ) return processes } catch (error) { - const missingAuthenticatedToken = - isMissingTokenFileError(error) && this.client.hasObservedAuthenticatedDisconnect() + const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing() const missingNamedPipe = isMissingWindowsNamedPipeError(error) this.observeAuditFailure( missingAuthenticatedToken @@ -2103,14 +2119,16 @@ export class DaemonPtyAdapter implements IPtyProvider { return 'unavailable' } - // Why: on daemon-death errors, respawn a fresh daemon and retry once rather than leaving terminals broken until app restart. + // Why: the token read no longer throws, so audit its absence directly after an authenticated drop. + private isRetiredEndpointTokenMissing(): boolean { + return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) + } + private async withDaemonRetry(fn: () => Promise): Promise { try { return await fn() } catch (err) { - // 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() + const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing() if (missingRetiredEndpointToken) { this.observeAuditFailure( 'token_missing_after_authenticated_disconnect', @@ -2118,11 +2136,7 @@ export class DaemonPtyAdapter implements IPtyProvider { ['token_file'] ) } - if ( - this.respawnAdoptionClosed || - !this.respawnFn || - (!isDaemonGoneError(err) && !missingRetiredEndpointToken) - ) { + if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) { throw err } if (!this.respawnPromise) { @@ -2516,14 +2530,6 @@ export function isDaemonGoneError(err: unknown): boolean { ) } -function isMissingTokenFileError(err: unknown): boolean { - if (!(err instanceof Error)) { - return false - } - 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 diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts deleted file mode 100644 index 50800ffec89..00000000000 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { mkdtempSync, rmSync } from 'node:fs' -import { DaemonPtyProvider } from './daemon-pty-provider' -import { DaemonServer } from './daemon-server' -import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' - -function createTestDir(): string { - return mkdtempSync(join(tmpdir(), 'daemon-provider-test-')) -} - -function createMockSubprocess(): SubprocessHandle & { - _simulateData: (data: string) => void - _simulateExit: (code: number) => void -} { - let onDataCb: ((data: string) => void) | null = null - let onExitCb: ((code: number) => void) | null = null - return { - pid: 77777, - getForegroundProcess: vi.fn(() => null), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), - signal: vi.fn(), - onData(cb) { - onDataCb = cb - }, - onExit(cb) { - onExitCb = cb - }, - dispose: vi.fn(), - _simulateData(data: string) { - onDataCb?.(data) - }, - _simulateExit(code: number) { - onExitCb?.(code) - } - } -} - -async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now() - while (!predicate()) { - if (Date.now() - start > timeoutMs) { - throw new Error('waitFor timed out') - } - await new Promise((r) => setTimeout(r, 10)) - } -} - -describe('DaemonPtyProvider', () => { - let dir: string - let socketPath: string - let tokenPath: string - let server: DaemonServer - let provider: DaemonPtyProvider - let lastSubprocess: ReturnType - - beforeEach(async () => { - dir = createTestDir() - socketPath = getDaemonSocketPath(dir) - tokenPath = join(dir, 'test.token') - - server = new DaemonServer({ - socketPath, - tokenPath, - spawnSubprocess: () => { - lastSubprocess = createMockSubprocess() - return lastSubprocess - } - }) - await server.start() - - provider = new DaemonPtyProvider({ socketPath, tokenPath }) - }) - - afterEach(async () => { - await provider?.cleanup() - await server?.shutdown() - rmSync(dir, { recursive: true, force: true }) - }) - - describe('spawn', () => { - it('creates a session in the daemon and returns an id', async () => { - const result = await provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'test-session' - }) - - expect(result.id).toBe('test-session') - }) - - it('spawns with cwd and env', async () => { - const result = await provider.spawn({ - cols: 80, - rows: 24, - cwd: '/home/user', - env: { MY_VAR: 'hello' }, - sessionId: 'session-with-env' - }) - - expect(result.id).toBe('session-with-env') - }) - }) - - describe('write', () => { - it('sends data to the daemon session', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Should not throw - provider.write('s1', 'ls\n') - - // Give the fire-and-forget notify time to arrive - await new Promise((r) => setTimeout(r, 50)) - expect(lastSubprocess.write).toHaveBeenCalledWith('ls\n') - }) - }) - - describe('resize', () => { - it('resizes the daemon session', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - provider.resize('s1', 120, 40) - - await new Promise((r) => setTimeout(r, 50)) - expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40) - }) - }) - - describe('shutdown (kill session)', () => { - it('kills the session in the daemon', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.shutdown('s1', { immediate: false }) - - expect(lastSubprocess.kill).toHaveBeenCalled() - }) - }) - - describe('data events', () => { - it('delivers data events from the daemon to onData listeners', async () => { - const dataPayloads: { id: string; data: string }[] = [] - provider.onData((payload) => dataPayloads.push(payload)) - - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Simulate PTY output from subprocess - lastSubprocess._simulateData('hello from shell') - - await waitFor(() => dataPayloads.length > 0) - expect(dataPayloads[0]).toEqual({ id: 's1', data: 'hello from shell' }) - }) - }) - - describe('exit events', () => { - it('delivers exit events from the daemon to onExit listeners', async () => { - const exits: { id: string; code: number }[] = [] - provider.onExit((payload) => exits.push(payload)) - - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - lastSubprocess._simulateExit(42) - - await waitFor(() => exits.length > 0) - expect(exits[0]).toEqual({ id: 's1', code: 42, incarnationId: expect.any(String) }) - }) - }) - - describe('cleanup', () => { - it('disconnects from daemon without killing sessions', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.cleanup() - - // Session should still be alive in the daemon — verify by connecting a new provider - const provider2 = new DaemonPtyProvider({ socketPath, tokenPath }) - const result = await provider2.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Should reattach (not create new) since the session is alive - // The daemon's createOrAttach returns isNew=false for existing sessions - expect(result.id).toBe('s1') - await provider2.cleanup() - }) - }) - - describe('multiple sessions', () => { - it('handles multiple concurrent sessions', async () => { - const r1 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'a' }) - const r2 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'b' }) - const r3 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'c' }) - - expect(r1.id).toBe('a') - expect(r2.id).toBe('b') - expect(r3.id).toBe('c') - }) - }) -}) diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts deleted file mode 100644 index 2c75f9dcfb8..00000000000 --- a/src/main/daemon/daemon-pty-provider.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { DaemonClient } from './client' -import type { DaemonEvent, CreateOrAttachResult } from './types' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' - -export type DaemonPtyProviderOptions = { - socketPath: string - tokenPath: string -} - -export type DaemonSpawnOptions = { - cols: number - rows: number - sessionId: string - cwd?: string - env?: Record - envToDelete?: string[] - command?: string -} - -export type DaemonSpawnResult = { - id: string - incarnationId?: PtyIncarnationId - isNew: boolean - pid: number | null -} - -export class DaemonPtyProvider { - private client: DaemonClient - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] - private exitListeners: ((payload: { - id: string - code: number - incarnationId?: PtyIncarnationId - }) => void)[] = [] - private removeEventListener: (() => void) | null = null - - constructor(opts: DaemonPtyProviderOptions) { - this.client = new DaemonClient({ - socketPath: opts.socketPath, - tokenPath: opts.tokenPath - }) - } - - async spawn(opts: DaemonSpawnOptions): Promise { - await this.client.ensureConnected() - this.setupEventRouting() - - const result = await this.client.request('createOrAttach', { - sessionId: opts.sessionId, - cols: opts.cols, - rows: opts.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command - }) - - return { - id: opts.sessionId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - isNew: result.isNew, - pid: result.pid - } - } - - write(id: string, data: string): void { - this.client.notify('write', { sessionId: id, data }) - } - - resize(id: string, cols: number, rows: number): void { - this.client.notify('resize', { sessionId: id, cols, rows }) - } - - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - // Why: convert the absolute teardown deadline to a relative timeout only here, - // at the RPC leaf; undefined keeps the DaemonClient 30s default. - await this.client.request( - 'kill', - { sessionId: id, immediate: opts.immediate ?? false }, - opts.deadlineMs !== undefined ? Math.max(1, opts.deadlineMs - Date.now()) : undefined - ) - } - - onData(callback: (payload: { id: string; data: string }) => void): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } - } - - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } - } - - async cleanup(): Promise { - this.removeEventListener?.() - this.removeEventListener = null - this.client.disconnect() - } - - private setupEventRouting(): void { - if (this.removeEventListener) { - return - } - - this.removeEventListener = this.client.onEvent((raw) => { - const event = raw as DaemonEvent - if (event.type !== 'event') { - return - } - - if (event.event === 'data') { - for (const listener of this.dataListeners) { - listener({ id: event.sessionId, data: event.payload.data }) - } - } else if (event.event === 'exit') { - for (const listener of this.exitListeners) { - listener({ - id: event.sessionId, - code: event.payload.code, - ...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}) - }) - } - } - }) - } -} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 026e8430c28..41cc8016169 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -408,7 +408,7 @@ describe('DaemonPtyRouter', () => { expect(current.confirmForegroundProcess).toHaveBeenCalledWith('current-session') }) - it('preserves v30/v31 session owners and routes new sessions to v32', async () => { + it('preserves older session owners and routes new sessions to v33', async () => { const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) const legacyV30 = createAdapter( 'v30', @@ -422,22 +422,27 @@ describe('DaemonPtyRouter', () => { undefined, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION ) - const router = new DaemonPtyRouter({ current, legacy: [legacyV30, legacyV31] }) + const legacyV32 = createAdapter('v32', ['v32-session'], undefined, 32) + const router = new DaemonPtyRouter({ current, legacy: [legacyV30, legacyV31, legacyV32] }) await router.discoverLegacySessions() await router.spawn({ sessionId: 'v30-session', cols: 80, rows: 24 }) await router.spawn({ sessionId: 'v31-session', cols: 80, rows: 24 }) + await router.spawn({ sessionId: 'v32-session', cols: 80, rows: 24 }) const fresh = await router.spawn({ cols: 80, rows: 24 }) router.write('v30-session', 'old-v30\n') router.write('v31-session', 'old-v31\n') + router.write('v32-session', 'old-v32\n') router.write(fresh.id, 'new\n') expect(legacyV30.spawn).toHaveBeenCalledWith({ sessionId: 'v30-session', cols: 80, rows: 24 }) expect(legacyV31.spawn).toHaveBeenCalledWith({ sessionId: 'v31-session', cols: 80, rows: 24 }) + expect(legacyV32.spawn).toHaveBeenCalledWith({ sessionId: 'v32-session', cols: 80, rows: 24 }) expect(current.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 }) expect(legacyV30.write).toHaveBeenCalledWith('v30-session', 'old-v30\n') expect(legacyV31.write).toHaveBeenCalledWith('v31-session', 'old-v31\n') + expect(legacyV32.write).toHaveBeenCalledWith('v32-session', 'old-v32\n') expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) diff --git a/src/main/daemon/daemon-self-retirement-respawn.test.ts b/src/main/daemon/daemon-self-retirement-respawn.test.ts index a5df5453fe6..c588b5a6918 100644 --- a/src/main/daemon/daemon-self-retirement-respawn.test.ts +++ b/src/main/daemon/daemon-self-retirement-respawn.test.ts @@ -213,17 +213,52 @@ describe('daemon self-retirement respawn', () => { expect(respawn).not.toHaveBeenCalled() }) - it('does not treat an initial missing token as respawn authority', async () => { + it('does not respawn a listening daemon whose token is absent', async () => { + await startServer() + rmSync(tokenPath) const respawn = vi.fn(async () => {}) const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) - await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toMatchObject( - { - code: 'ENOENT' - } - ) + await expect( + adapter.spawn({ sessionId: 'startup-window', cols: 80, rows: 24 }) + ).rejects.toThrow(/Invalid token/i) expect(respawn).not.toHaveBeenCalled() adapter.dispose() }) + + it('does not let stale disconnect evidence authorize respawning a listening daemon', async () => { + const original = await startServer() + const respawn = vi.fn(async () => {}) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean } + } + ).client + + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + await startServer() + rmSync(tokenPath) + + await expect( + adapter.spawn({ sessionId: 'replacement-startup-window', cols: 80, rows: 24 }) + ).rejects.toThrow(/Invalid token/i) + + expect(respawn).not.toHaveBeenCalled() + adapter.dispose() + }) + + it('respawns when nothing is accepting on the endpoint', async () => { + const respawn = vi.fn(async () => {}) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + + // Fails after the respawn attempt because the mock respawn starts no replacement. + await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toThrow() + + expect(respawn).toHaveBeenCalledTimes(1) + adapter.dispose() + }) }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 51d9d0913a2..731da9eefd5 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -145,7 +145,7 @@ export class DaemonServer { private transportSockets = new Set() private createOrAttachInFlight = 0 private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' - private initialAdoptionTimer: unknown | null = null + private initialAdoptionTimer: unknown = null private initialAdoptionDeadlineMs: number | null = null private retirementRequested = false private shutdownPromise: Promise | null = null diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 37633f32334..9ee6918113f 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -575,6 +575,55 @@ describe('HeadlessEmulator', () => { }) describe('rehydration sequences', () => { + it('separates complete live state from an alternate-screen frame', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[?1004h\x1b[?25l\x1b[5;10H\x1b7\x1b[31;44;1mframe') + + const snapshot = emulator.getSnapshot() + + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1049h') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot.frameRestoreAnsi).toContain('\x1b7') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[31;44;1m') + expect(snapshot.frameRestoreAnsi).not.toContain('frame') + + const restored = new HeadlessEmulator({ cols: 80, rows: 24 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + expect(restored.isAlternateScreen).toBe(true) + await restored.write('X') + expect(restored.getSnapshot().snapshotAnsi).toContain('\x1b[31;44;1mX') + restored.dispose() + }) + + it('restores current and saved cursor rows relative to origin-mode margins', async () => { + emulator = new HeadlessEmulator({ cols: 20, rows: 10 }) + await emulator.write('\x1b[?1049h\x1b[3;8r\x1b[?6h\x1b[2;7H\x1b7\x1b[4;5H') + + const snapshot = emulator.getSnapshot() + const restored = new HeadlessEmulator({ cols: 20, rows: 10 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + await restored.write('C\x1b8S') + + expect(restored.getVisibleLines()[5]?.[4]).toBe('C') + expect(restored.getVisibleLines()[3]?.[6]).toBe('S') + restored.dispose() + }) + + it('restores a saved cursor from before the current origin-mode margins', async () => { + emulator = new HeadlessEmulator({ cols: 20, rows: 10 }) + await emulator.write('\x1b[?1049h\x1b[1;3H\x1b7\x1b[3;8r\x1b[?6h\x1b[4;5H') + + const snapshot = emulator.getSnapshot() + const restored = new HeadlessEmulator({ cols: 20, rows: 10 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + await restored.write('C\x1b8S') + + expect(restored.getVisibleLines()[5]?.[4]).toBe('C') + expect(restored.getVisibleLines()[0]?.[2]).toBe('S') + restored.dispose() + }) + it('generates rehydration for non-default modes', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b[?2004h') diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 195b97a30f6..12be643d637 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -13,6 +13,7 @@ import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' +import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences' import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers' import { installTerminalViewAttributeResponder, @@ -255,6 +256,7 @@ export class HeadlessEmulator { this.restoredOscLinks ), rehydrateSequences: buildRehydrateSequences(modes), + ...buildFrameRestoreSnapshotFields(this.serializer, this.terminal, modes), cwd: this.oscText.cwd, modes, cols: this.terminal.cols, @@ -266,10 +268,6 @@ export class HeadlessEmulator { ? { pendingEscapeTailAnsi: this.partialEscapeTail } : {}) } - if (this.partialEscapeTail.length > 0) { - // Why a separate field: consumers write their own reset sequences after the body, and any ESC after a dangling partial would abort it. - snapshot.pendingEscapeTailAnsi = this.partialEscapeTail - } return snapshot } diff --git a/src/main/daemon/macos-login-session-death-watch.ts b/src/main/daemon/macos-login-session-death-watch.ts index 1f529ff3e00..e2df486e38f 100644 --- a/src/main/daemon/macos-login-session-death-watch.ts +++ b/src/main/daemon/macos-login-session-death-watch.ts @@ -66,9 +66,9 @@ export class MacosLoginSessionDeathWatch { private probeInFlight = false private stopped = false private retired = false - private scheduledProbeTimer: unknown | null = null + private scheduledProbeTimer: unknown = null private scheduledProbeAtMs: number | null = null - private ptyExitDebounceTimer: unknown | null = null + private ptyExitDebounceTimer: unknown = null private pendingProbeTrigger: string | null = null private probeAbortController: AbortController | null = null diff --git a/src/main/daemon/node-pty-error-hints.test.ts b/src/main/daemon/node-pty-error-hints.test.ts index e8fb5a0ef8c..f43951f05a6 100644 --- a/src/main/daemon/node-pty-error-hints.test.ts +++ b/src/main/daemon/node-pty-error-hints.test.ts @@ -50,7 +50,7 @@ describe('node-pty diagnostic error hints', () => { it('hints local wrapped spawn errors from pty allocation failures', () => { const message = - 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave=\'/dev/ttys003\' (shell: /bin/zsh, cwd: /tmp, arch: arm64, platform: darwin 25.0.0). If this persists, please file an issue.' + 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave=\'/dev/ttys003\' (shell: /bin/zsh, cwd: /tmp, arch: arm64, platform: darwin 25.0.0, orca: 1.4.178). If this persists, please file an issue.' expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) }) diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts deleted file mode 100644 index 4404c5c2c99..00000000000 --- a/src/main/daemon/production-launcher.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { createProductionLauncher } from './production-launcher' -import { startDaemon, type DaemonHandle } from './daemon-main' -import { DaemonClient } from './client' -import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' - -const { forkMock } = vi.hoisted(() => ({ - forkMock: vi.fn() -})) - -vi.mock('child_process', async () => { - const actual = await vi.importActual>('child_process') - return { ...actual, fork: forkMock } -}) - -function createTestDir(): string { - return mkdtempSync(join(tmpdir(), 'prod-launcher-test-')) -} - -function createMockSubprocess(): SubprocessHandle { - let onExitCb: ((code: number) => void) | null = null - return { - pid: 44444, - getForegroundProcess: vi.fn(() => null), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), - signal: vi.fn(), - onData(_cb: (data: string) => void) {}, - onExit(cb: (code: number) => void) { - onExitCb = cb - }, - dispose: vi.fn() - } -} - -describe('createProductionLauncher', () => { - let dir: string - let handles: DaemonHandle[] - - beforeEach(() => { - dir = createTestDir() - handles = [] - }) - - afterEach(async () => { - for (const h of handles) { - await h.shutdown().catch(() => {}) - } - forkMock.mockReset() - rmSync(dir, { recursive: true, force: true }) - }) - - it('returns a launcher function', () => { - const launcher = createProductionLauncher({ - 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', - getAppVersion: () => '1.2.3' - }) - - await expect( - launcher(socketPathFor(dir), tokenPathFor(dir), join(dir, 'daemon.pid')) - ).rejects.toThrow('provided together') - await expect( - launcher(socketPathFor(dir), tokenPathFor(dir), undefined, 'launch-a') - ).rejects.toThrow('provided together') - expect(forkMock).not.toHaveBeenCalled() - }) - - it('can be used with DaemonSpawner (in-process fallback)', async () => { - // Use in-process launcher for testing (same as DaemonSpawner tests) - const launcher = async (socketPath: string, tokenPath: string) => { - const handle = await startDaemon({ - socketPath, - tokenPath, - spawnSubprocess: () => createMockSubprocess() - }) - handles.push(handle) - return { shutdown: () => handle.shutdown() } - } - - const socketPath = getDaemonSocketPath(dir) - const tokenPath = join(dir, 'test.token') - const handle = await launcher(socketPath, tokenPath) - - const client = new DaemonClient({ socketPath, tokenPath }) - await client.ensureConnected() - expect(client.isConnected()).toBe(true) - client.disconnect() - - await handle.shutdown() - handles.pop() - }) - - it('removes startup child listeners after readiness', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - killed: false, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(), - disconnect: vi.fn(), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - 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, - linuxStartTicks: '4242', - bootId: 'boot-a' - }) - const handle = await launch - - expect(handle.shutdown).toEqual(expect.any(Function)) - expect(handlers.message).toHaveLength(0) - expect(handlers.error).toHaveLength(0) - expect(handlers.exit).toHaveLength(0) - expect(child.disconnect).toHaveBeenCalled() - expect(child.unref).toHaveBeenCalled() - expect(forkMock).toHaveBeenCalledWith( - join(dir, 'daemon-entry.js'), - expect.arrayContaining([ - '--pid-record', - pidPath, - '--launch-nonce', - 'launch-a', - '--entry-path', - join(dir, 'daemon-entry.js'), - '--app-version', - '1.2.3' - ]), - expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }) - ) - }) - - it('resolves shutdown only after observing child exit', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - connected: true, - exitCode: null as number | null, - signalCode: null as NodeJS.Signals | null, - on: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event]?.push(callback) - return child - }), - once: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event]?.push(callback) - return child - }), - off: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] - return child - }), - kill: vi.fn((signal: NodeJS.Signals) => { - if (signal === 'SIGTERM') { - queueMicrotask(() => { - child.exitCode = 0 - for (const callback of handlers.exit.slice()) { - callback(0) - } - }) - } - return true - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-shutdown' })) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-shutdown') - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) - const handle = await launch - - await expect(handle.shutdown()).resolves.toBeUndefined() - - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - expect(child.exitCode).toBe(0) - expect(existsSync(pidPath)).toBe(false) - }) - - it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => { - vi.useFakeTimers() - try { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - killed: false, - connected: false, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => true), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - 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 }) - const handle = await launch - - const shutdown = expect(handle.shutdown()).rejects.toThrow( - 'Daemon did not exit after SIGKILL' - ) - expect(handlers.exit).toHaveLength(1) - - await vi.advanceTimersByTimeAsync(6000) - await shutdown - - expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM') - expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') - expect(handlers.exit).toHaveLength(0) - expect(child.unref).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('preserves readiness and signaling failures while releasing startup IPC', async () => { - vi.useFakeTimers() - try { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const signalError = Object.assign(new Error('permission denied'), { code: 'EACCES' }) - const child = { - pid: 12345, - connected: true, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => { - throw signalError - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready' }) - - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() - expect(handlers.message).toHaveLength(0) - expect(handlers.error).toHaveLength(0) - expect(handlers.exit).toHaveLength(0) - } finally { - vi.useRealTimers() - } - }) - - it('preserves exact PID ownership when child exit cannot be confirmed', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const signalError = Object.assign(new Error('signal blocked'), { code: 'EPERM' }) - const child = { - pid: 12345, - connected: true, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => { - throw signalError - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-b' })) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') - handlers.message[0]?.({ type: 'ready' }) - - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() - expect(existsSync(pidPath)).toBe(true) - }) - - it('removes exact child-side PID ownership after a pre-ready exit', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - connected: true, - exitCode: null as number | null, - signalCode: null as NodeJS.Signals | null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-c' })) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-c') - child.exitCode = 1 - handlers.exit[0]?.(1) - - await expect(launch).rejects.toThrow('Daemon process exited prematurely with code 1') - expect(existsSync(pidPath)).toBe(false) - }) -}) - -function socketPathFor(dir: string): string { - return join(dir, 'test.sock') -} - -function tokenPathFor(dir: string): string { - return join(dir, 'test.token') -} diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts deleted file mode 100644 index 342ff6e94ba..00000000000 --- a/src/main/daemon/production-launcher.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { fork, type ChildProcess } from 'node:child_process' -import { - unlinkOwnedDaemonPidFile, - 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 { - return async ( - socketPath: string, - tokenPath: string, - pidPath?: string, - launchNonce?: string - ): Promise => { - if ((pidPath === undefined) !== (launchNonce === undefined)) { - // Why: partial ownership metadata would launch a v24 daemon that cannot - // prove which PID record it may remove during self-retirement. - throw new Error('Daemon PID path and launch nonce must be provided together') - } - const entryPath = opts.getDaemonEntryPath() - - const appVersion = opts.getAppVersion() - const child = fork( - entryPath, - [ - '--socket', - socketPath, - '--token', - tokenPath, - ...(pidPath && launchNonce - ? [ - '--pid-record', - pidPath, - '--launch-nonce', - launchNonce, - '--entry-path', - entryPath, - '--app-version', - appVersion - ] - : []) - ], - { - // Why: detached daemon output is not consumed; ignored streams cannot - // keep Electron alive after the child and IPC channel are unreferenced. - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - detached: true, - env: { ...process.env }, - ...(process.platform === 'win32' ? { windowsHide: true } : {}) - } - ) - - try { - await waitForReady(child) - } catch (error) { - return rejectAfterChildCleanup(child, error, pidPath, launchNonce) - } - if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { - return rejectAfterChildCleanup( - child, - new Error('Daemon readiness identity is incomplete'), - pidPath, - launchNonce - ) - } - - // Unref so the Electron process can exit without waiting for the daemon - child.unref() - child.disconnect() - - return { - shutdown: async () => { - await shutdownChild(child) - unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) - } - } - } -} - -function waitForReady(child: ChildProcess): Promise { - return new Promise((resolve, reject) => { - let timeout: ReturnType | undefined - let settled = false - function cleanupStartupListeners(): void { - if (timeout) { - clearTimeout(timeout) - } - child.off('message', onMessage) - child.off('error', onError) - child.off('exit', onExit) - } - function fail(error: Error): void { - if (settled) { - return - } - settled = true - cleanupStartupListeners() - reject(error) - } - function onMessage(msg: unknown): void { - if (msg && typeof msg === 'object' && (msg as Record).type === 'ready') { - if (settled) { - return - } - const readyIdentity = parseDaemonReadyIdentity(msg) - if (!readyIdentity) { - fail(new Error('Daemon readiness identity is incomplete')) - return - } - settled = true - // Why: the daemon is detached after readiness, so startup listeners - // must not keep the child process closure alive for the daemon lifetime. - cleanupStartupListeners() - resolve(readyIdentity) - } - } - function onError(err: Error): void { - fail(new Error(`Daemon process error: ${err.message}`)) - } - function onExit(code: number | null): void { - fail(new Error(`Daemon process exited prematurely with code ${code}`)) - } - - timeout = setTimeout(() => { - fail(new Error('Daemon failed to signal readiness within timeout')) - }, READY_TIMEOUT_MS) - - child.on('message', onMessage) - child.on('error', onError) - child.on('exit', onExit) - }) -} - -async function shutdownChild(child: ChildProcess): Promise { - try { - if ( - (child.exitCode !== null && child.exitCode !== undefined) || - (child.signalCode !== null && child.signalCode !== undefined) - ) { - return - } - await new Promise((resolve, reject) => { - let settled = false - let timeout: ReturnType - let forceTimeout: ReturnType | undefined - function finish(error?: unknown): void { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (forceTimeout) { - clearTimeout(forceTimeout) - } - child.off('exit', onExit) - if (error) { - reject(error) - } else { - resolve() - } - } - - function onExit(): void { - finish() - } - - timeout = setTimeout(() => { - try { - if (child.kill('SIGKILL') === false) { - finish(new Error('Failed to deliver SIGKILL to daemon')) - return - } - } catch (error) { - finish(isNoSuchProcessError(error) ? undefined : error) - return - } - // Why: signal delivery is not process exit; keep the listener for one - // bounded interval before releasing launcher-owned handles. - if (!settled) { - forceTimeout = setTimeout( - () => finish(new Error('Daemon did not exit after SIGKILL')), - 1000 - ) - } - }, 5000) - - child.once('exit', onExit) - try { - if (child.kill('SIGTERM') === false) { - finish(new Error('Failed to deliver SIGTERM to daemon')) - } - } catch (error) { - finish(isNoSuchProcessError(error) ? undefined : error) - } - }) - } finally { - if (child.connected) { - child.disconnect() - } - child.unref() - } -} - -async function rejectAfterChildCleanup( - child: ChildProcess, - launchError: unknown, - pidPath?: string, - launchNonce?: string -): Promise { - try { - await shutdownChild(child) - } catch (cleanupError) { - throw new AggregateError( - [launchError, cleanupError], - 'Daemon launch and child cleanup both failed' - ) - } - unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) - throw launchError -} - -function unlinkLaunchedDaemonPidFile( - child: ChildProcess, - pidPath?: string, - launchNonce?: string -): void { - if (pidPath && launchNonce && Number.isSafeInteger(child.pid) && (child.pid as number) > 0) { - unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) - } -} - -function isNoSuchProcessError(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' -} diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 9df17412c57..5322bd6294b 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2613,6 +2613,8 @@ describe('createPtySubprocess', () => { spawnMock.mockImplementation(() => { throw new Error('File not found: ') }) + const previousVersion = process.env.ORCA_APP_VERSION + process.env.ORCA_APP_VERSION = '1.4.178-test' try { expect(() => @@ -2623,9 +2625,14 @@ describe('createPtySubprocess', () => { shellOverride: 'not-a-real-shell.exe' }) ).toThrow( - /Daemon failed to spawn shell "not-a-real-shell\.exe" with cwd ".+": File not found:/ + /Daemon failed to spawn shell "not-a-real-shell\.exe" with cwd ".+": File not found:.*orca: 1\.4\.178-test/ ) } finally { + if (previousVersion === undefined) { + delete process.env.ORCA_APP_VERSION + } else { + process.env.ORCA_APP_VERSION = previousVersion + } if (platform) { Object.defineProperty(process, 'platform', platform) } @@ -2958,36 +2965,39 @@ describe('createPtySubprocess', () => { expect(spawnCall[2].env.WSLENV ?? '').not.toContain(POWERLEVEL10K_WIZARD_DISABLE_ENV) }) - it('keeps daemon WSL split panes in their distro when cwd is already POSIX', () => { - const proc = mockPtyProcess() - spawnMock.mockReturnValue(proc) - const platform = Object.getOwnPropertyDescriptor(process, 'platform') + it.each(['/home/jin/repo/subdir', '/a', '/c'])( + 'keeps daemon WSL split panes in their distro when cwd is POSIX (%s)', + (cwd) => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { value: 'win32' }) + Object.defineProperty(process, 'platform', { value: 'win32' }) - try { - createPtySubprocess({ - sessionId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo@@deadbeef', - cols: 80, - rows: 24, - cwd: '/home/jin/repo/subdir', - shellOverride: 'wsl.exe' - }) - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) + try { + createPtySubprocess({ + sessionId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo@@deadbeef', + cols: 80, + rows: 24, + cwd, + shellOverride: 'wsl.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } } - } - expect(validateWorkingDirectoryMock).toHaveBeenCalledWith( - '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\subdir' - ) - expect(spawnMock).toHaveBeenCalledWith( - 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")], - expect.objectContaining({ cwd: expect.any(String) }) - ) - }) + expect(validateWorkingDirectoryMock).toHaveBeenCalledWith( + `\\\\wsl.localhost\\Ubuntu${cwd.replaceAll('/', '\\')}` + ) + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining(`cd '${cwd}'`)], + expect.objectContaining({ cwd: expect.any(String) }) + ) + } + ) // Why: node-pty's UnixTerminal.destroy() registers _socket.once('close', () => // this.kill('SIGHUP')), and the socket 'close' event can fire concurrently diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 6fe39f57fba..9b87da0c8d5 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: daemon PTY spawning must keep platform launch setup, preflight, and lifecycle guards in one execution path. */ import * as pty from 'node-pty' import { statSync } from 'node:fs' +import { release } from 'node:os' import { delimiter, win32 as pathWin32 } from 'node:path' import type { SubprocessHandle } from './session' import { DaemonProtocolError } from './types' @@ -246,16 +247,25 @@ function removeInheritedElectronRunAsNode(env: Record): void { delete env.ELECTRON_RUN_AS_NODE } +function daemonEnvironmentDiagSuffix(): string { + const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' + const systemVersion = + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || + release() + const platform = `${process.platform} ${systemVersion}` + return ` (orca: ${orca}, arch: ${process.arch}, platform: ${platform})` +} + /** * Formats a daemon preflight failure with the same ENOENT details node-pty exposes. */ function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { const detailName = kind === 'helper' ? 'helper' : 'cwd' const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' + const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory' + const diag = daemonEnvironmentDiagSuffix() return new DaemonProtocolError( - `Daemon's ${kind === 'helper' ? 'node-pty install' : 'working directory'} is gone ` + - `(worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT ` + - `(errno 2, No such file or directory) - ${detailName}='${path}'` + `Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${diag}` ) } @@ -399,8 +409,9 @@ function preflightPosixPtySpawnEnvironment(validationCwd: string): void { */ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { const message = err instanceof Error ? err.message : String(err) + const diag = daemonEnvironmentDiagSuffix() const formatted = new DaemonProtocolError( - `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}` + `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${diag}` ) if (err instanceof Error && err.stack) { formatted.stack = err.stack @@ -1051,6 +1062,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return { pid: proc.pid, shellPath, + shellCwd: spawnCwd, + shellPathEnv: env.PATH, ...(slavePath ? { slavePath } : {}), ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { diff --git a/src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts b/src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts new file mode 100644 index 00000000000..74907d84406 --- /dev/null +++ b/src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts @@ -0,0 +1,419 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createPtySubprocess } from './pty-subprocess' +import { Session } from './session' + +const describePosix = process.platform === 'win32' ? describe.skip : describe +const hasZsh = process.platform !== 'win32' && spawnSync('/bin/zsh', ['--version']).status === 0 +const hasBash = process.platform !== 'win32' && spawnSync('/bin/bash', ['--version']).status === 0 +const COMMAND_OUTPUT = 'ORCA_STARTUP_COMMAND_RAN' +const READ_STARTED_FILE = '.orca-read-started' + +type ShellFixture = { + name: string + shellPath: string + startupFile: string + replacement: string + command: string + instrumentationOutput: string + secretRead: string + childRead: string +} + +const FIXTURES: ShellFixture[] = [ + { + name: 'zsh profile', + shellPath: '/bin/zsh', + startupFile: '.zprofile', + replacement: 'exec -a kiro-cli-term /bin/zsh -o noglobalrcs -l -i', + command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`, + instrumentationOutput: `${COMMAND_OUTPUT}:PF=`, + secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`, + childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n` + }, + { + name: 'zsh environment', + shellPath: '/bin/zsh', + startupFile: '.zshenv', + replacement: 'exec /bin/zsh -o noglobalrcs -l -i', + command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`, + instrumentationOutput: `${COMMAND_OUTPUT}:PF=`, + secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`, + childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n` + }, + { + name: 'bash profile', + shellPath: '/bin/bash', + startupFile: '.bash_profile', + replacement: 'exec -a figterm-test /bin/bash --noprofile --norc -l -i', + command: `printf 'ORCA_STARTUP_%s:PC=%s\\n' COMMAND_RAN "$PROMPT_COMMAND"\r`, + instrumentationOutput: `${COMMAND_OUTPUT}:PC=`, + secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -s -n 1\n`, + childRead: `/bin/bash --noprofile --norc -c ': > "$HOME/${READ_STARTED_FILE}"; read -s -n 1'\n` + } +] + +function count(text: string, needle: string): number { + return text.split(needle).length - 1 +} + +function waitForOutput( + subscribe: (settle: () => void) => void, + isDone: () => boolean, + timeoutMs = 5_000 +): Promise { + if (isDone()) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + const deadline = setTimeout( + () => reject(new Error('Timed out waiting for PTY output')), + timeoutMs + ) + const settle = (): void => { + if (!isDone()) { + return + } + clearTimeout(deadline) + resolve() + } + subscribe(settle) + settle() + }) +} + +function waitForCondition(isDone: () => boolean, timeoutMs = 5_000): Promise { + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (!isDone()) { + return + } + clearInterval(interval) + clearTimeout(deadline) + resolve() + }, 10) + const deadline = setTimeout(() => { + clearInterval(interval) + reject(new Error('Timed out waiting for fixture state')) + }, timeoutMs) + }) +} + +function runCleanupActions(...actions: (() => void)[]): void { + for (const action of actions) { + try { + action() + } catch {} + } +} + +type RunningFixture = { + session: Session + subprocess: ReturnType + output: () => string + readStarted: () => boolean + subscribe: (settle: () => void) => void + cleanup: () => Promise +} + +function startFixture( + fixture: ShellFixture, + startupContent: string, + extraFiles: Record = {} +): RunningFixture { + const tempHome = mkdtempSync(join(tmpdir(), 'orca-shell-ready-exec-')) + const previousHome = process.env.HOME + const previousZdotdir = process.env.ZDOTDIR + const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR + let subprocess: ReturnType | undefined + let session: Session | undefined + let consoleWarnSpy: { mockRestore: () => void } | undefined + try { + writeFileSync(join(tempHome, fixture.startupFile), startupContent) + for (const [fileName, content] of Object.entries(extraFiles)) { + writeFileSync(join(tempHome, fileName), content) + } + process.env.HOME = tempHome + delete process.env.ZDOTDIR + delete process.env.ORCA_ORIG_ZDOTDIR + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + subprocess = createPtySubprocess({ + sessionId: `repro-13767-${fixture.startupFile}`, + cols: 80, + rows: 24, + cwd: tempHome, + command: fixture.command, + shellOverride: fixture.shellPath, + env: { + HOME: tempHome, + PATH: process.env.PATH ?? '/usr/bin:/bin', + SHELL: fixture.shellPath, + TERM: 'xterm-256color' + }, + envToDelete: ['ORCA_EXEC_REPRO_DONE', 'ORCA_ORIG_ZDOTDIR', 'ZDOTDIR'] + }) + session = new Session({ + sessionId: `repro-13767-${fixture.startupFile}`, + cols: 80, + rows: 24, + subprocess, + shellReadySupported: true + }) + let output = '' + let onOutput = (): void => {} + session.attachClient({ + onData: (data) => { + output += data + onOutput() + }, + onExit: () => {} + }) + session.write(fixture.command) + const runningSubprocess = subprocess + const runningSession = session + const activeConsoleWarnSpy = consoleWarnSpy + + return { + session: runningSession, + subprocess: runningSubprocess, + output: () => output, + readStarted: () => existsSync(join(tempHome, READ_STARTED_FILE)), + subscribe: (settle) => { + onOutput = settle + }, + cleanup: async () => { + if (runningSession.isAlive) { + try { + await runningSession.forceKillAndWaitForExit(3_000) + } catch { + runCleanupActions(() => runningSubprocess.forceKill()) + } + } + runCleanupActions( + () => runningSession.dispose(), + () => activeConsoleWarnSpy.mockRestore(), + () => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir), + () => rmSync(tempHome, { recursive: true, force: true }) + ) + } + } + } catch (error) { + runCleanupActions( + () => subprocess?.forceKill(), + () => session?.dispose(), + () => !session && subprocess?.dispose(), + () => consoleWarnSpy?.mockRestore(), + () => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir), + () => rmSync(tempHome, { recursive: true, force: true }) + ) + throw error + } +} + +function restoreEnvironment( + home: string | undefined, + zdotdir: string | undefined, + originalZdotdir: string | undefined +): void { + setEnvironmentValue('HOME', home) + setEnvironmentValue('ZDOTDIR', zdotdir) + setEnvironmentValue('ORCA_ORIG_ZDOTDIR', originalZdotdir) +} + +function setEnvironmentValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} + +async function runExecOracle(fixture: ShellFixture): Promise { + const running = startFixture( + fixture, + `if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then + export ORCA_EXEC_REPRO_DONE=1 + ${fixture.replacement} +fi +` + ) + try { + await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT)) + expect(running.session.shellState).toBe('ready') + expect(count(running.output(), COMMAND_OUTPUT)).toBe(1) + expect(running.output()).toContain(fixture.instrumentationOutput) + expect(running.output()).not.toContain('orca-shell-start') + } finally { + await running.cleanup() + } +} + +async function runReadOracle(fixture: ShellFixture, child: boolean): Promise { + const running = startFixture(fixture, child ? fixture.childRead : fixture.secretRead) + try { + await waitForCondition(running.readStarted) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(running.session.shellState).toBe('pending') + expect(running.output()).not.toContain(COMMAND_OUTPUT) + + running.subprocess.write('x\r') + await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT)) + expect(count(running.output(), COMMAND_OUTPUT)).toBe(1) + expect(running.output()).not.toContain('orca-shell-start') + } finally { + await running.cleanup() + } +} + +describePosix('#13767 shell-ready marker loss across exec', () => { + it('continues setup cleanup after force-kill failure', () => { + const dispose = vi.fn() + const restore = vi.fn() + runCleanupActions( + () => { + throw new Error('force kill failed') + }, + dispose, + restore + ) + expect(dispose).toHaveBeenCalledOnce() + expect(restore).toHaveBeenCalledOnce() + }) + + for (const fixture of FIXTURES) { + const runnable = fixture.shellPath.endsWith('zsh') ? hasZsh : hasBash + const testCase = runnable ? it : it.skip + testCase( + `releases at the real ${fixture.name} prompt after exec`, + () => runExecOracle(fixture), + 10_000 + ) + testCase( + `keeps queued input out of a real ${fixture.name} silent read`, + () => runReadOracle(fixture, false), + 10_000 + ) + testCase( + `keeps queued input out of a child read during ${fixture.name} startup`, + () => runReadOracle(fixture, true), + 10_000 + ) + } + + const zshFixture = FIXTURES[0] as ShellFixture + const zshTest = hasZsh ? it : it.skip + zshTest( + 'keeps queued input out of a zle-line-init read after exec', + async () => { + const running = startFixture( + zshFixture, + `if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then + export ORCA_EXEC_REPRO_DONE=1 + exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i +fi +`, + { + '.zshrc': `zle-line-init() { + print -n 'HOOK_SECRET> ' + read -sk 1 + print -r -- HOOK_DONE +} +zle -N zle-line-init +` + } + ) + try { + try { + await waitForOutput(running.subscribe, () => running.output().includes('HOOK_SECRET> ')) + } catch (error) { + throw new Error(`ZLE hook startup output: ${JSON.stringify(running.output())}`, { + cause: error + }) + } + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(running.session.shellState).toBe('pending') + expect(running.output()).not.toContain(COMMAND_OUTPUT) + + running.subprocess.write('x') + try { + await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT)) + } catch (error) { + throw new Error(`ZLE hook output: ${JSON.stringify(running.output())}`, { cause: error }) + } + expect(running.output()).toContain('HOOK_DONE') + expect(count(running.output(), COMMAND_OUTPUT)).toBe(1) + } finally { + await running.cleanup() + } + }, + 10_000 + ) + + const sqliteTest = hasZsh && existsSync('/usr/bin/sqlite3') ? it : it.skip + sqliteTest( + 'does not treat an exec-replaced readline program as the shell prompt', + async () => { + const running = startFixture(zshFixture, 'exec /usr/bin/sqlite3\n') + try { + await waitForOutput(running.subscribe, () => running.output().includes('sqlite> ')) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(running.session.shellState).toBe('pending') + expect(running.output()).not.toContain(COMMAND_OUTPUT) + } finally { + await running.cleanup() + } + }, + 10_000 + ) + + sqliteTest( + 'does not trust a non-shell executable renamed to the shell basename', + async () => { + const running = startFixture( + zshFixture, + 'ln -s /usr/bin/sqlite3 "$HOME/zsh" && exec "$HOME/zsh"\n' + ) + try { + await waitForOutput(running.subscribe, () => running.output().includes('sqlite> ')) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(running.session.shellState).toBe('pending') + expect(running.output()).not.toContain(COMMAND_OUTPUT) + } finally { + await running.cleanup() + } + }, + 10_000 + ) + + zshTest( + 'retains the timeout backstop when zsh disables bracketed paste', + async () => { + const running = startFixture( + zshFixture, + `if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then + export ORCA_EXEC_REPRO_DONE=1 + exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i +fi +`, + { + '.zshrc': "zmodload zsh/zle\nunset zle_bracketed_paste\nPS1='NO_BRACKET_PROMPT> '\n" + } + ) + try { + await waitForOutput(running.subscribe, () => + running.output().includes('NO_BRACKET_PROMPT> ') + ) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(running.session.shellState).toBe('pending') + expect(running.output()).not.toContain(COMMAND_OUTPUT) + } finally { + await running.cleanup() + } + }, + 10_000 + ) +}) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index f24a6a7fcc9..377f0fee184 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -13,6 +13,16 @@ import { scanForShellReady, type ShellReadyScanState } from '../shell-ready-marker-scanner' +import { + createShellStartupIdentityScanState, + drainShellStartupIdentityHeldBytes, + scanForShellStartupIdentity, + type ShellStartupIdentityScanState +} from '../shell-startup-identity-scanner' +import { + createShellPromptReadinessProbe, + type ShellPromptReadinessProbe +} from '../shell-prompt-readiness-probe' import { isPowerShellProcess } from '../../shared/shell-process-detection' import { killWithDescendantSweep } from '../pty-descendant-termination' import type { TuiAgent } from '../../shared/types' @@ -60,6 +70,8 @@ export type SubprocessHandle = { /** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready * assumption against it so a fallback shell without a ready marker never gates startup commands. */ shellPath?: string + shellCwd?: string + shellPathEnv?: string /** Slave device path, so startup replies can read the line discipline's ECHO bit before * writing. Absent on handles with no POSIX slave to read (ConPTY, tests). */ slavePath?: string @@ -125,6 +137,9 @@ export class Session { private releaseStartupDeviceAttributesResponder: (() => void) | null = null private startupDeviceAttributesQueryFilter: StartupDeviceAttributesQueryFilter | null = null private shellReadyScanState: ShellReadyScanState | null = null + private shellStartupIdentityScanState: ShellStartupIdentityScanState | null = null + private shellStartupPid: number | null = null + private shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null private shellReadyTimer: ReturnType | null = null private killTimer: ReturnType | null = null private postReadyFlushGate: PostReadyFlushGate @@ -170,6 +185,7 @@ export class Session { if (opts.shellReadySupported) { this._shellState = 'pending' this.shellReadyScanState = createShellReadyScanState() + this.shellStartupIdentityScanState = createShellStartupIdentityScanState() // Why: `write` queues everything until the ready marker, including the renderer's DA1 // reply — and a shell that withholds its first prompt until DA1 is answered (fish) then // never emits the marker that would release it. Answer from the daemon, past the queue. @@ -195,6 +211,16 @@ export class Session { onEmission: (emission) => this.emitSubprocessOutput(emission), ...(echoProbe ? { echoProbe } : {}) }) + if (this._shellState === 'pending') { + this.shellPromptReadinessProbe = createShellPromptReadinessProbe({ + slavePath: this.subprocess.slavePath, + shellPath: this.subprocess.shellPath, + shellCwd: this.subprocess.shellCwd, + shellPathEnv: this.subprocess.shellPathEnv, + getShellPid: () => this.shellStartupPid, + onPromptReady: () => this.onShellPromptReady() + }) + } this.subprocess.onData((data) => this.handleSubprocessData(data)) this.subprocess.onExit((code) => this.handleSubprocessExit(code)) } @@ -603,6 +629,8 @@ export class Session { clearTimeout(this.shellReadyTimer) this.shellReadyTimer = null } + this.shellPromptReadinessProbe?.dispose() + this.shellPromptReadinessProbe = null this.shellReadyScanState = null this.preReadyStdinQueue = [] this.postReadyFlushGate.clear() @@ -649,6 +677,14 @@ export class Session { } let releaseStartupDeviceAttributes = false + if (this._shellState === 'pending' && this.shellStartupIdentityScanState) { + const scanned = scanForShellStartupIdentity(this.shellStartupIdentityScanState, data) + data = scanned.output + if (scanned.shellPid) { + this.shellStartupPid = scanned.shellPid + this.shellStartupIdentityScanState = null + } + } if (this._shellState === 'pending' && this.shellReadyScanState) { const scanned = scanForShellReady(this.shellReadyScanState, data) data = scanned.output @@ -661,6 +697,9 @@ export class Session { } this.startupIngress.accept(data) + if (this._shellState === 'pending' && data.length > 0) { + this.shellPromptReadinessProbe?.notifyOutput(data) + } if (releaseStartupDeviceAttributes) { this.releaseStartupDeviceAttributes() } @@ -696,6 +735,8 @@ export class Session { } this.releaseStartupDeviceAttributes() + this.shellPromptReadinessProbe?.dispose() + this.shellPromptReadinessProbe = null this.releaseHeldShellReadyBytes() this.startupIngress.drainAndClose() this._exitCode = code @@ -727,10 +768,19 @@ export class Session { } private releaseHeldShellReadyBytes(): string { - if (!this.shellReadyScanState) { + if (!this.shellReadyScanState && !this.shellStartupIdentityScanState) { return '' } - const heldBytes = drainShellReadyHeldBytes(this.shellReadyScanState) + let heldBytes = this.shellStartupIdentityScanState + ? drainShellStartupIdentityHeldBytes(this.shellStartupIdentityScanState) + : '' + this.shellStartupIdentityScanState = null + if (this.shellReadyScanState && heldBytes) { + heldBytes = scanForShellReady(this.shellReadyScanState, heldBytes).output + } + if (this.shellReadyScanState) { + heldBytes += drainShellReadyHeldBytes(this.shellReadyScanState) + } this.shellReadyScanState = null // Why: scanning strips marker bytes before fan-out; if readiness never completes, release any held prefix before timeout/exit discards it. this.startupIngress.accept(heldBytes) @@ -759,6 +809,9 @@ export class Session { private transitionToReady(postMarkerBytesObserved = false): void { this._shellState = 'ready' this.shellReadyScanState = null + this.shellStartupIdentityScanState = null + this.shellPromptReadinessProbe?.dispose() + this.shellPromptReadinessProbe = null if (this.shellReadyTimer) { clearTimeout(this.shellReadyTimer) this.shellReadyTimer = null @@ -775,11 +828,25 @@ export class Session { return } this._shellState = 'timed_out' + this.shellPromptReadinessProbe?.dispose() + this.shellPromptReadinessProbe = null this.releaseStartupDeviceAttributes() this.releaseHeldShellReadyBytes() this.flushPreReadyQueue() } + private onShellPromptReady(): void { + if (this._shellState !== 'pending') { + return + } + console.warn( + `[Session] ${this.sessionId}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.` + ) + this.releaseHeldShellReadyBytes() + this.transitionToReady(true) + this.releaseStartupDeviceAttributes() + } + private flushPreReadyQueue(): void { const queued = this.preReadyStdinQueue this.preReadyStdinQueue = [] diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 42e6ccc15f5..1739ba05c85 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import type * as ShellReadyModule from './shell-ready' import { getZshShellReadyMarkerRegistrationBlock } from '../shell-templates' +import { fishRequirementViolation, resolveFishBinary } from '../../shared/fish-binary-requirement' async function importFreshShellReady(): Promise { vi.resetModules() @@ -16,8 +17,8 @@ const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']) const itWithBash = hasBash ? it : it.skip const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 const itWithZsh = hasZsh ? it : it.skip -const hasFish = process.platform !== 'win32' && spawnSync('fish', ['--version']).status === 0 -const itWithFish = hasFish ? it : it.skip +const FISH = resolveFishBinary() +const itWithFish = FISH.available ? it : it.skip const SHELL_READY_MARKER_OUTPUT = '\x1b]777;orca-shell-ready\x07' @@ -166,6 +167,11 @@ function expectFinalZdotdirRestoreContext(content: string) { } describePosix('daemon shell-ready launch config', () => { + // Always runs, so the CI lane cannot report green with every live fish test skipped. + it('has the fish the live tests need when CI requires one', () => { + expect(fishRequirementViolation(FISH)).toBeNull() + }) + let previousUserDataPath: string | undefined let previousOrcaOrigZdotdir: string | undefined let userDataPath: string @@ -456,6 +462,7 @@ describePosix('daemon shell-ready launch config', () => { const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;') expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') expectZdotdirSourceContext(zprofile, '.zprofile') @@ -625,6 +632,12 @@ describePosix('daemon shell-ready launch config', () => { expect(zshrc).toContain(ompWrapperLine) expect(zlogin).toContain(ompWrapperLine) expect(bashRc).toContain(ompWrapperLine) + for (const wrapperFile of [zshrc, zlogin, bashRc]) { + expect(wrapperFile).not.toContain('prime-agent()') + expect(wrapperFile).not.toContain('__orca_prime_agent') + expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') + expect(wrapperFile).not.toContain('command prime-agent --extension') + } }) // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. @@ -638,6 +651,7 @@ describePosix('daemon shell-ready launch config', () => { const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') + expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') expect(bashRc).toContain('printf "\\033]133;C\\007"') // precmd is prepended (captures $? first), epilogue appended last, so a framework needing last position stays between them. expect(bashRc).toContain( diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 38a26be8b75..2b1f27309f8 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -16,6 +16,7 @@ import { getZshEnvTemplate, getZshFinalZdotdirRestoreBlock, getZshShellReadyMarkerRegistrationBlock, + SHELL_STARTUP_IDENTITY_MARKER_BLOCK, getZshStartupFileSourceBlock } from '../shell-templates' @@ -88,6 +89,7 @@ function shellReadyWrappersExist(): boolean { export function getDaemonBashShellReadyRcfileContent(): string { return `# Orca daemon bash shell-ready wrapper +${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} [[ -f /etc/profile ]] && source /etc/profile if [[ -f "$HOME/.bash_profile" ]]; then source "$HOME/.bash_profile" @@ -384,7 +386,8 @@ function getWrappedShellLaunchConfig( ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(), ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(), ZDOTDIR: join(root, 'zsh'), - ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0' + ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0', + ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0' }, supportsReadyMarker: options.emitReadyMarker } @@ -396,7 +399,8 @@ function getWrappedShellLaunchConfig( return { args: ['--rcfile', join(root, 'bash', 'rcfile')], env: { - ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0' + ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0', + ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0' }, supportsReadyMarker: options.emitReadyMarker } diff --git a/src/main/daemon/terminal-frame-restore-sequences.ts b/src/main/daemon/terminal-frame-restore-sequences.ts new file mode 100644 index 00000000000..6d91771c4d2 --- /dev/null +++ b/src/main/daemon/terminal-frame-restore-sequences.ts @@ -0,0 +1,97 @@ +import type { Terminal } from '@xterm/headless' +import type { SerializeAddon } from '@xterm/addon-serialize' +import { + buildAbsoluteCursorRestoreSequence, + readSavedCursorRegister +} from '../../shared/terminal-serialize-absolute-cursor' +import type { SavedCursorRegister } from '../../shared/terminal-serialize-absolute-cursor' +import type { TerminalModes } from './types' + +export function buildFrameRestoreSnapshotFields( + serializer: SerializeAddon, + terminal: Terminal, + modes: TerminalModes +): { frameRestoreAnsi?: string } { + return modes.alternateScreen + ? { + frameRestoreAnsi: buildTerminalFrameRestoreSequences( + serializer, + terminal, + modes, + readSavedCursorRegister(terminal) + ) + } + : {} +} + +/** Restores live terminal state without replaying an alternate-screen frame. */ +function buildTerminalFrameRestoreSequences( + serializer: SerializeAddon, + terminal: Terminal, + modes: TerminalModes, + savedCursor: SavedCursorRegister | null +): string { + const terminalModes = terminal.modes + const seqs: string[] = ['\x1b[0m\x1b[?1049h', serializeCurrentSgrState(serializer, terminal)] + if (terminalModes.applicationCursorKeysMode) { + seqs.push('\x1b[?1h') + } + if (terminalModes.applicationKeypadMode) { + seqs.push('\x1b[?66h') + } + if (terminalModes.bracketedPasteMode) { + seqs.push('\x1b[?2004h') + } + if (terminalModes.insertMode) { + seqs.push('\x1b[4h') + } + if (terminalModes.reverseWraparoundMode) { + seqs.push('\x1b[?45h') + } + if (terminalModes.sendFocusMode) { + seqs.push('\x1b[?1004h') + } + if (!terminalModes.wraparoundMode) { + seqs.push('\x1b[?7l') + } + switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { + case 'x10': + seqs.push('\x1b[?9h') + break + case 'vt200': + seqs.push('\x1b[?1000h') + break + case 'drag': + seqs.push('\x1b[?1002h') + break + case 'any': + seqs.push('\x1b[?1003h') + break + case 'none': + break + } + if (modes.sgrMousePixelsMode) { + seqs.push('\x1b[?1016h') + } else if (modes.sgrMouseMode) { + seqs.push('\x1b[?1006h') + } + if (!terminalModes.showCursor) { + seqs.push('\x1b[?25l') + } + seqs.push( + buildAbsoluteCursorRestoreSequence(terminal, savedCursor, { + restoreModesWithoutCursor: true + }) + ) + return seqs.join('') +} + +function serializeCurrentSgrState(serializer: SerializeAddon, terminal: Terminal): string { + // Unsupported addon behavior under our vendored patch: an out-of-range row emits only its authoritative pen-state diff. + const emptyRow = terminal.buffer.normal.length + return serializer.serialize({ + range: { start: emptyRow, end: emptyRow }, + excludeAltBuffer: true, + excludeModes: true + }) +} diff --git a/src/main/daemon/terminal-session-teardown.ts b/src/main/daemon/terminal-session-teardown.ts index 1ba58393721..352927a5dfd 100644 --- a/src/main/daemon/terminal-session-teardown.ts +++ b/src/main/daemon/terminal-session-teardown.ts @@ -39,9 +39,8 @@ export class TerminalSessionTeardown { } if (immediate) { return this.forceKillPlainShellSession(sessionId, session) - } else { - session.kill() } + session.kill() } /** diff --git a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts index b54227652dd..f0927fcf379 100644 --- a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts +++ b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts @@ -70,6 +70,24 @@ async function roundTripStyles(source: string): Promise { return replay(addon.serialize()) } +describe('out-of-range SGR state serialization', () => { + it('emits only the live pen state for a row beyond the normal buffer', async () => { + const { terminal, addon } = createTerminal() + await write(terminal, '\x1b[31;44;1m') + + // This deliberately pins unsupported SerializeAddon behavior used by + // terminal-frame-restore-sequences under Orca's vendored addon patch. + const outOfRangeRow = terminal.buffer.normal.length + expect( + addon.serialize({ + range: { start: outOfRangeRow, end: outOfRangeRow }, + excludeAltBuffer: true, + excludeModes: true + }) + ).toBe('\x1b[31;44;1m') + }) +}) + describe('SGR intensity round-trip (BUG B, addon patch)', () => { it('restores bold set immediately after dim is cleared (minimized repro)', async () => { const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB') diff --git a/src/main/daemon/terminal-snapshot.ts b/src/main/daemon/terminal-snapshot.ts index 653d8fc4bd6..6d378a8d107 100644 --- a/src/main/daemon/terminal-snapshot.ts +++ b/src/main/daemon/terminal-snapshot.ts @@ -9,6 +9,8 @@ export type TerminalSnapshot = { scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string + /** Live modes and cursor state that can be restored without the alt frame. */ + frameRestoreAnsi?: string cwd: string | null modes: TerminalModes cols: number diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts index ddd04bf11dc..decca3d3f99 100644 --- a/src/main/daemon/terminal-view-attribute-responder.ts +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -57,7 +57,7 @@ function isValidColorIndex(value: number): boolean { function relativeLuminance([r, g, b]: TerminalViewRgb): number { const linear = (channel: number): number => { const c = channel / 255 - return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 } return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722 } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 865572ea0b1..1f067885467 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -383,23 +383,6 @@ export type DaemonSessionInfo = SessionInfo & { // existing importers keep one types entry point. export * from './daemon-stream-events' -// ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── -// -// 5-byte header: [type:1][length:4 big-endian] -// Followed by `length` bytes of payload. - -export const enum FrameType { - Data = 0x01, - Resize = 0x02, - Exit = 0x03, - Error = 0x04, - Kill = 0x05, - Signal = 0x06 -} - -export const FRAME_HEADER_SIZE = 5 -export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB - // ─── Notify prefix ────────────────────────────────────────────────── // Requests with IDs starting with this prefix are fire-and-forget: // the daemon processes them but does not send a response. diff --git a/src/main/daemon/xterm-env-polyfill.ts b/src/main/daemon/xterm-env-polyfill.ts index 9ab12627299..27605b8fa9a 100644 --- a/src/main/daemon/xterm-env-polyfill.ts +++ b/src/main/daemon/xterm-env-polyfill.ts @@ -1,6 +1,6 @@ // @xterm/headless checks for `window` to detect browser vs node environment. // In ELECTRON_RUN_AS_NODE mode, `window` is undefined. This polyfill must be // imported before any @xterm/headless import. -if (typeof globalThis.window === 'undefined') { +if (globalThis.window === undefined) { ;(globalThis as Record).window = globalThis } diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 82b00a71c3c..6cff0d2b824 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -5,6 +5,7 @@ import { writeHooksJson, writeManagedScript } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readTextFileRemote, writeHooksJsonRemote, @@ -79,6 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class DevinHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getDevinManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getDevinConfigPath() const scriptPath = getDevinManagedScriptPath() diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 508f24e98c8..fdef56a2401 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -16,6 +16,7 @@ import { type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -159,6 +160,10 @@ function buildInstalledDroidConfig( } export class DroidHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/emulator/android/adb-devices.ts b/src/main/emulator/android/adb-devices.ts index 692bbb4d0fb..7264a825310 100644 --- a/src/main/emulator/android/adb-devices.ts +++ b/src/main/emulator/android/adb-devices.ts @@ -8,7 +8,7 @@ export type AndroidAdbDeviceState = | 'bootloader' | 'recovery' | 'no permissions' - | string + | (string & {}) export type AndroidAdbDevice = { serial: string diff --git a/src/main/emulator/android/scrcpy-control-protocol.test.ts b/src/main/emulator/android/scrcpy-control-protocol.test.ts deleted file mode 100644 index 10e0e7a842a..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SCRCPY_CONTROL_MSG_TYPE, - encodeBackOrScreenOn, - encodeInjectKeycode, - encodeInjectText, - encodeInjectTouchEvent -} from './scrcpy-control-protocol' - -describe('SCRCPY_CONTROL_MSG_TYPE', () => { - it('pins the v2.4 control message type codes', () => { - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE).toBe(0) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT).toBe(1) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT).toBe(2) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_SCROLL_EVENT).toBe(3) - expect(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON).toBe(4) - }) -}) - -describe('encodeInjectTouchEvent', () => { - it('encodes a touch-down event with the full 32-byte field layout', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 0x1122334455667788n, - x: 100, - y: 200, - screenWidth: 1080, - screenHeight: 1920 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT) - expect(buf.readUInt8(0)).toBe(2) - expect(buf.readUInt8(1)).toBe(0) // AMOTION_EVENT_ACTION_DOWN - expect(buf.readBigUInt64BE(2)).toBe(0x1122334455667788n) - expect(buf.readInt32BE(10)).toBe(100) - expect(buf.readInt32BE(14)).toBe(200) - expect(buf.readUInt16BE(18)).toBe(1080) - expect(buf.readUInt16BE(20)).toBe(1920) - expect(buf.readUInt16BE(22)).toBe(0xffff) // default pressure 1.0 for down - expect(buf.readUInt32BE(24)).toBe(1) // AMOTION_EVENT_BUTTON_PRIMARY - expect(buf.readUInt32BE(28)).toBe(1) // buttons set while pressed - }) - - it('encodes a move event with action code 2 and full default pressure', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(2) // AMOTION_EVENT_ACTION_MOVE - expect(buf.readUInt16BE(22)).toBe(0xffff) - expect(buf.readUInt32BE(28)).toBe(1) - }) - - it('encodes an up event with zero default pressure and cleared buttons', () => { - const buf = encodeInjectTouchEvent({ - action: 'up', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(1) // AMOTION_EVENT_ACTION_UP - expect(buf.readUInt16BE(22)).toBe(0) // default pressure 0 on up - expect(buf.readUInt32BE(24)).toBe(1) // actionButton stays primary - expect(buf.readUInt32BE(28)).toBe(0) // buttons cleared on up - }) - - it('writes negative coordinates as signed int32', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: -3, - y: -7, - screenWidth: 1, - screenHeight: 1 - }) - - expect(buf.readInt32BE(10)).toBe(-3) - expect(buf.readInt32BE(14)).toBe(-7) - }) - - it('encodes explicit fractional pressure as fixed-point u16', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 0.5 - }) - - expect(buf.readUInt16BE(22)).toBe(Math.round(0.5 * 0xffff)) - }) - - it('clamps pressure above 1 and below 0', () => { - const high = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 2 - }) - expect(high.readUInt16BE(22)).toBe(0xffff) - - const low = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: -1 - }) - expect(low.readUInt16BE(22)).toBe(0) - }) -}) - -describe('encodeInjectKeycode', () => { - it('encodes a keycode-down event (14 bytes) with round-tripped fields', () => { - const buf = encodeInjectKeycode({ - action: 'down', - keycode: 66, // AKEYCODE_ENTER - repeat: 3, - metaState: 0x1000 - }) - - expect(buf.length).toBe(14) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE) - expect(buf.readUInt8(0)).toBe(0) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - expect(buf.readInt32BE(2)).toBe(66) - expect(buf.readInt32BE(6)).toBe(3) - expect(buf.readInt32BE(10)).toBe(0x1000) - }) - - it('encodes a keycode-up event and applies repeat/metaState defaults', () => { - const buf = encodeInjectKeycode({ action: 'up', keycode: 4 }) // AKEYCODE_BACK - - expect(buf.length).toBe(14) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - expect(buf.readInt32BE(2)).toBe(4) - expect(buf.readInt32BE(6)).toBe(0) // default repeat - expect(buf.readInt32BE(10)).toBe(0) // default metaState - }) -}) - -describe('encodeInjectText', () => { - it('encodes the type byte, UTF-8 byte length header, and payload', () => { - const buf = encodeInjectText('hello') - - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT) - expect(buf.readUInt8(0)).toBe(1) - expect(buf.readUInt32BE(1)).toBe(5) - expect(buf.subarray(5).toString('utf8')).toBe('hello') - expect(buf.length).toBe(5 + 5) - }) - - it('uses UTF-8 byte length (not string length) for multi-byte characters', () => { - const text = 'é' // one JS char, two UTF-8 bytes - const buf = encodeInjectText(text) - - expect(text.length).toBe(1) - expect(buf.readUInt32BE(1)).toBe(2) - expect(buf.length).toBe(5 + 2) - expect(buf.subarray(5).toString('utf8')).toBe('é') - }) - - it('encodes an empty string as a 5-byte header with zero length', () => { - const buf = encodeInjectText('') - - expect(buf.length).toBe(5) - expect(buf.readUInt32BE(1)).toBe(0) - }) -}) - -describe('encodeBackOrScreenOn', () => { - it('encodes a 2-byte down message', () => { - const buf = encodeBackOrScreenOn('down') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON) - expect(buf.readUInt8(0)).toBe(4) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - }) - - it('encodes a 2-byte up message', () => { - const buf = encodeBackOrScreenOn('up') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - }) -}) diff --git a/src/main/emulator/android/scrcpy-control-protocol.ts b/src/main/emulator/android/scrcpy-control-protocol.ts deleted file mode 100644 index 4e35148948c..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.ts +++ /dev/null @@ -1,91 +0,0 @@ -// scrcpy server control protocol v2.4 -// Byte-exact encoders for the scrcpy control socket. Every multi-byte field is -// big-endian to match the server's DataInputStream reads. - -export const SCRCPY_CONTROL_MSG_TYPE = { - INJECT_KEYCODE: 0, - INJECT_TEXT: 1, - INJECT_TOUCH_EVENT: 2, - INJECT_SCROLL_EVENT: 3, - BACK_OR_SCREEN_ON: 4 -} as const - -export type ScrcpyKeyAction = 'down' | 'up' -export type ScrcpyTouchAction = 'down' | 'up' | 'move' - -// AKEY_EVENT_ACTION_* / AMOTION_EVENT_ACTION_* codes the server forwards to Android. -const KEY_ACTION_CODE: Record = { down: 0, up: 1 } -const TOUCH_ACTION_CODE: Record = { down: 0, up: 1, move: 2 } - -// AMOTION_EVENT_BUTTON_PRIMARY — scrcpy reports the primary button for touches. -const BUTTON_PRIMARY = 1 -// Pressure is a u16 fixed-point value where 0xFFFF represents 1.0. -const PRESSURE_MAX = 0xffff - -function encodePressure(pressure: number): number { - const fixed = Math.round(pressure * PRESSURE_MAX) - if (fixed < 0) { - return 0 - } - if (fixed > PRESSURE_MAX) { - return PRESSURE_MAX - } - return fixed -} - -export function encodeInjectTouchEvent(p: { - action: ScrcpyTouchAction - pointerId: bigint - x: number - y: number - screenWidth: number - screenHeight: number - pressure?: number -}): Buffer { - // A finger that lifts has no pressure; otherwise full pressure unless overridden. - const pressure = p.pressure ?? (p.action === 'up' ? 0 : 1) - const buf = Buffer.alloc(32) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT, 0) - buf.writeUInt8(TOUCH_ACTION_CODE[p.action], 1) - buf.writeBigUInt64BE(p.pointerId, 2) - buf.writeInt32BE(p.x, 10) - buf.writeInt32BE(p.y, 14) - buf.writeUInt16BE(p.screenWidth, 18) - buf.writeUInt16BE(p.screenHeight, 20) - buf.writeUInt16BE(encodePressure(pressure), 22) - buf.writeUInt32BE(BUTTON_PRIMARY, 24) - buf.writeUInt32BE(p.action === 'up' ? 0 : BUTTON_PRIMARY, 28) - return buf -} - -export function encodeInjectKeycode(p: { - action: ScrcpyKeyAction - keycode: number - repeat?: number - metaState?: number -}): Buffer { - const buf = Buffer.alloc(14) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE, 0) - buf.writeUInt8(KEY_ACTION_CODE[p.action], 1) - buf.writeInt32BE(p.keycode, 2) - buf.writeInt32BE(p.repeat ?? 0, 6) - buf.writeInt32BE(p.metaState ?? 0, 10) - return buf -} - -export function encodeInjectText(text: string): Buffer { - // The length prefix is the UTF-8 byte count, which can exceed text.length. - const payload = Buffer.from(text, 'utf8') - const buf = Buffer.alloc(5 + payload.length) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT, 0) - buf.writeUInt32BE(payload.length, 1) - payload.copy(buf, 5) - return buf -} - -export function encodeBackOrScreenOn(action: ScrcpyKeyAction): Buffer { - const buf = Buffer.alloc(2) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON, 0) - buf.writeUInt8(KEY_ACTION_CODE[action], 1) - return buf -} diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index b7cec131e2c..0333e9ee5db 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -22,8 +22,7 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe' // A live scrcpy session validated against a real emulator. The connection // handshake (dummy byte, 64-byte device name, codec meta) and the server option -// set are coupled to the pinned scrcpy server version; the pure framing -// (scrcpy-video-frame-parser) and control encoding (scrcpy-control-protocol) are +// set are coupled to the pinned scrcpy server version; pure video framing is // unit-tested, while this orchestration is exercised live via probes. const DEVICE_NAME_BYTES = 64 @@ -198,7 +197,6 @@ export class ScrcpyStreamSession { socket.on('error', (error) => emulatorProbeError('scrcpy.control.fail', error, { serial: this.options.serial }) ) - // Drop the ref on reset so sendControl() can't write to a dead socket (half-open session). socket.on('close', () => { if (this.controlSocket === socket) { this.controlSocket = null @@ -253,11 +251,6 @@ export class ScrcpyStreamSession { } } - // Sends an encoded scrcpy control message (see scrcpy-control-protocol). - sendControl(message: Buffer): void { - this.controlSocket?.write(message) - } - private fail(message: string): void { if (this.closed) { return diff --git a/src/main/emulator/emulator-bridge.test.ts b/src/main/emulator/emulator-bridge.test.ts index 33b4f9d2421..a880c0f15a3 100644 --- a/src/main/emulator/emulator-bridge.test.ts +++ b/src/main/emulator/emulator-bridge.test.ts @@ -216,6 +216,95 @@ describe('EmulatorBridge helper ownership', () => { expect(bridge.getActiveForWorktree('wt-1')).toBeNull() }) + it('does not clean up a failed attach while another start claim can register the device', async () => { + const waitForEndpointReady = vi.fn(async () => true) + execServeSimCommandMock.mockResolvedValue({ + device: 'device-1', + streamUrl: 'http://127.0.0.1:3102/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3102' + }) + const bridge = new EmulatorBridge({ waitForEndpointReady }) + + const failedLease = await bridge.acquireHelperForDevice('device-1') + const survivingLease = await bridge.acquireHelperForDevice('device-1') + + await failedLease.release({ cleanupIfUnused: true }) + bridge.registerActiveEmulator('wt-surviving', survivingLease.info, { managed: true }) + await survivingLease.release() + + expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled() + expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled() + expect(bridge.getActiveForWorktree('wt-surviving')).toMatchObject({ + deviceUdid: 'device-1' + }) + }) + + it('cleans up a failed attach when no start claim or registered workspace remains', async () => { + const waitForEndpointReady = vi.fn(async () => true) + execServeSimCommandMock.mockResolvedValue({ + device: 'device-1', + streamUrl: 'http://127.0.0.1:3102/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3102' + }) + const bridge = new EmulatorBridge({ waitForEndpointReady }) + + const lease = await bridge.acquireHelperForDevice('device-1') + await lease.release({ cleanupIfUnused: true }) + + expect(killServeSimHelperProcessesForDeviceMock).toHaveBeenCalledWith('device-1', { + helperPid: undefined, + includeOrphaned: true + }) + expect(shutdownSimulatorDeviceMock).toHaveBeenCalledWith('device-1') + }) + + it('keeps a shared device alive when one registered workspace detaches', async () => { + const bridge = new EmulatorBridge() + bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true }) + bridge.registerActiveEmulator('wt-2', session('device-1'), { managed: true }) + + await bridge.stopActiveManagedForWorktree('wt-1', { shutdownDevice: true }) + + expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled() + expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled() + expect(bridge.getActiveForWorktree('wt-1')).toBeNull() + expect(bridge.getActiveForWorktree('wt-2')).toMatchObject({ deviceUdid: 'device-1' }) + }) + + it('keeps a device alive when its last registered workspace closes during another attach', async () => { + let finishStart: + | ((info: Awaited>) => void) + | undefined + execServeSimCommandMock.mockImplementation( + () => + new Promise((resolve) => { + finishStart = resolve + }) + ) + const bridge = new EmulatorBridge({ waitForEndpointReady: vi.fn(async () => true) }) + bridge.registerActiveEmulator('wt-closing', session('device-1'), { managed: true }) + + const leasePromise = bridge.acquireHelperForDevice('device-1') + await vi.waitFor(() => expect(execServeSimCommandMock).toHaveBeenCalledOnce()) + const shutdown = bridge.shutdownActiveManagedForWorktree('wt-closing') + await Promise.resolve() + + expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled() + finishStart?.({ + device: 'device-1', + streamUrl: 'http://127.0.0.1:3102/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3102' + }) + const lease = await leasePromise + bridge.registerActiveEmulator('wt-attaching', lease.info, { managed: true }) + await lease.release() + await shutdown + + expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled() + expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled() + expect(bridge.getActiveForWorktree('wt-attaching')).toMatchObject({ deviceUdid: 'device-1' }) + }) + it('reuses the active helper for the same requested device', async () => { const waitForEndpointReady = vi.fn(async () => true) const bridge = new EmulatorBridge({ waitForEndpointReady }) @@ -267,7 +356,7 @@ describe('EmulatorBridge helper ownership', () => { }) const bridge = new EmulatorBridge({ waitForEndpointReady }) - const info = await bridge.startHelperForDevice('device-1') + const { info } = await bridge.acquireHelperForDevice('device-1') expect(info.deviceUdid).toBe('device-1') expect(waitForEndpointReady).toHaveBeenCalledTimes(2) @@ -309,7 +398,7 @@ describe('EmulatorBridge helper ownership', () => { }) const bridge = new EmulatorBridge({ waitForEndpointReady }) - await expect(bridge.startHelperForDevice('device-1')).rejects.toMatchObject({ + await expect(bridge.acquireHelperForDevice('device-1')).rejects.toMatchObject({ code: 'emulator_helper_failed' }) @@ -334,7 +423,7 @@ describe('EmulatorBridge helper ownership', () => { }) const bridge = new EmulatorBridge({ waitForEndpointReady }) - await expect(bridge.startHelperForDevice('device-1')).rejects.toMatchObject({ + await expect(bridge.acquireHelperForDevice('device-1')).rejects.toMatchObject({ code: 'emulator_helper_failed' }) @@ -368,7 +457,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -401,7 +491,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -435,7 +526,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-other', session('device-1'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -465,7 +557,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-1', session('device-active'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -499,7 +592,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { ) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -530,7 +624,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { ) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -551,7 +646,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -575,7 +671,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { const bridge = new EmulatorBridge() const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: false, @@ -599,7 +696,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { const bridge = new EmulatorBridge({ waitForEndpointReady }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -641,7 +739,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { const bridge = new EmulatorBridge({ waitForEndpointReady }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, diff --git a/src/main/emulator/emulator-bridge.ts b/src/main/emulator/emulator-bridge.ts index 84ecbb7025e..b201b34963a 100644 --- a/src/main/emulator/emulator-bridge.ts +++ b/src/main/emulator/emulator-bridge.ts @@ -5,6 +5,11 @@ import type { SimulatorDevice } from './simctl-simulator-devices' import type { EmulatorBridgeOptions } from './emulator-bridge-types' import type { EmulatorGesturePoint } from './emulator-gesture-sender' import { EmulatorSessionRegistry } from './emulator-session-registry' +import { + EmulatorStartLeaseRegistry, + type EmulatorStartLease +} from './emulator-start-lease-registry' +import { listAvailableEmulatorDevices } from './emulator-device-inventory' import { deriveAxUrlFromStreamUrl } from './serve-sim-detached-session' import { IosEmulatorBackend } from './backends/ios-emulator-backend' import { AndroidEmulatorBackend } from './backends/android-emulator-backend' @@ -22,6 +27,7 @@ import type { // backend a command targets (by the session's recorded backend, else by device). export class EmulatorBridge { private readonly sessionRegistry = new EmulatorSessionRegistry() + private readonly startLeases = new EmulatorStartLeaseRegistry() private readonly backends: EmulatorBackend[] private readonly iosBackend: IosEmulatorBackend private readonly androidBackend: AndroidEmulatorBackend @@ -41,19 +47,7 @@ export class EmulatorBridge { // Aggregated device list across host-supported backends (iOS simulators + // Android devices/AVDs), for the unified `orca emulator list`. async listAllDevices(): Promise { - const perBackend = await Promise.all( - this.backends.map(async (backend) => { - if (!backend.isSupportedOnHost()) { - return [] - } - try { - return await backend.listDevices() - } catch { - return [] - } - }) - ) - return perBackend.flat() + return listAvailableEmulatorDevices(this.backends) } // iOS-specific passthroughs kept for back-compat with the runtime + availability code. @@ -85,14 +79,10 @@ export class EmulatorBridge { return this.sessionRegistry.getActiveForWorktree(worktreeId) } - getActiveBackendKind(worktreeId: string): EmulatorBackendKind | null { - return this.backendForActiveWorktree(worktreeId)?.kind ?? null - } - // On a device switch, keep slow-to-boot Android emulators running for instant // switch-back; shut down other backends' devices so they are not leaked. async stopActiveForSwitch(worktreeId: string): Promise { - const keepAlive = this.getActiveBackendKind(worktreeId) === 'android' + const keepAlive = this.backendForActiveWorktree(worktreeId)?.kind === 'android' return this.stopActiveForWorktreeInternal(worktreeId, { shutdownDevice: !keepAlive }) } @@ -146,18 +136,26 @@ export class EmulatorBridge { if (!session || (options.managedOnly && !session.managed)) { return null } + if (this.sessionRegistry.hasActiveWorktreeForSession(key)) { + return session.deviceUdid + } const backend = this.backendForKind(session.backend) if (!backend) { return null } - await backend.stopHelperForDevice(session.deviceUdid, { - helperPid: session.pid, - includeOrphaned: !options.managedOnly - }) - if (options.shutdownDevice) { - await backend.shutdownDevice(session.deviceUdid).catch(() => {}) + const sessionInfo = this.sessionRegistry.toSessionInfo(session) + await this.startLeases.cleanupWhenIdle( + backend, + sessionInfo, + (info) => this.sessionRegistry.hasActiveWorktreeForSession(info.deviceUdid), + { + includeOrphaned: !options.managedOnly, + shutdownDevice: options.shutdownDevice + } + ) + if (!this.sessionRegistry.hasActiveWorktreeForSession(key)) { + this.sessionRegistry.clearSessionAndWorktrees(key) } - this.sessionRegistry.clearSessionAndWorktrees(key) return session.deviceUdid } @@ -243,9 +241,11 @@ export class EmulatorBridge { return run(backend, device) } - async startHelperForDevice(device: string): Promise { + async acquireHelperForDevice(device: string): Promise { const backend = await this.backendForDevice(device) - return backend.startSession(device) + return this.startLeases.acquire(backend, device, (info) => + this.sessionRegistry.hasActiveWorktreeForSession(info.deviceUdid) + ) } async kill(device?: string, worktreeId?: string): Promise { diff --git a/src/main/emulator/emulator-device-inventory.ts b/src/main/emulator/emulator-device-inventory.ts new file mode 100644 index 00000000000..35649b947f3 --- /dev/null +++ b/src/main/emulator/emulator-device-inventory.ts @@ -0,0 +1,19 @@ +import type { EmulatorBackend, EmulatorDevice } from './backends/emulator-backend' + +export async function listAvailableEmulatorDevices( + backends: EmulatorBackend[] +): Promise { + const perBackend = await Promise.all( + backends.map(async (backend) => { + if (!backend.isSupportedOnHost()) { + return [] + } + try { + return await backend.listDevices() + } catch { + return [] + } + }) + ) + return perBackend.flat() +} diff --git a/src/main/emulator/emulator-session-registry.ts b/src/main/emulator/emulator-session-registry.ts index b55578930e4..917b14a9e39 100644 --- a/src/main/emulator/emulator-session-registry.ts +++ b/src/main/emulator/emulator-session-registry.ts @@ -51,6 +51,14 @@ export class EmulatorSessionRegistry { return this.sessions.get(key) } + toSessionInfo(session: EmulatorSessionState): EmulatorSessionInfo { + return toSessionInfo(session) + } + + hasActiveWorktreeForSession(key: string): boolean { + return [...this.activeByWorktree.values()].some((activeKey) => activeKey === key) + } + listSessions(): EmulatorSessionState[] { return [...this.sessions.values()] } diff --git a/src/main/emulator/emulator-start-lease-registry.test.ts b/src/main/emulator/emulator-start-lease-registry.test.ts new file mode 100644 index 00000000000..f0b0e927569 --- /dev/null +++ b/src/main/emulator/emulator-start-lease-registry.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import type { EmulatorBackend } from './backends/emulator-backend' +import { EmulatorStartLeaseRegistry } from './emulator-start-lease-registry' +import type { EmulatorSessionInfo } from './emulator-types' + +function makeBackend(): { + backend: EmulatorBackend + resolveDeviceId: ReturnType + startSession: ReturnType + stopHelperForDevice: ReturnType + shutdownDevice: ReturnType +} { + const resolveDeviceId = vi.fn(async () => { + throw new Error('shutdown AVD cannot resolve before boot') + }) + const startSession = vi.fn( + async (): Promise => ({ + deviceUdid: 'emulator-5554', + streamUrl: 'scrcpy://emulator-5554', + wsUrl: '', + streamCodec: 'h264', + backend: 'android' + }) + ) + const stopHelperForDevice = vi.fn(async () => {}) + const shutdownDevice = vi.fn(async () => {}) + const backend: EmulatorBackend = { + kind: 'android', + streamCodec: 'h264', + capabilities: { + install: false, + launch: false, + permissions: false, + accessibilityTree: false, + logcat: false + }, + isSupportedOnHost: () => true, + checkAvailability: vi.fn(), + listDevices: vi.fn(async () => []), + ownsDevice: vi.fn(async () => true), + resolveDeviceId, + startSession, + stopHelperForDevice, + shutdownDevice, + isSessionReusable: vi.fn(async () => true), + tap: vi.fn(async () => {}), + gesture: vi.fn(async () => {}), + type: vi.fn(async () => {}), + button: vi.fn(async () => {}), + rotate: vi.fn(async () => {}), + exec: vi.fn(async () => undefined) + } + return { backend, resolveDeviceId, startSession, stopHelperForDevice, shutdownDevice } +} + +describe('EmulatorStartLeaseRegistry', () => { + it('lets the backend boot a shutdown device before a canonical id exists', async () => { + const { backend, resolveDeviceId, startSession } = makeBackend() + const registry = new EmulatorStartLeaseRegistry() + + const lease = await registry.acquire(backend, 'Pixel_Tablet', () => false) + await lease.release() + + expect(resolveDeviceId).not.toHaveBeenCalled() + expect(startSession).toHaveBeenCalledWith('Pixel_Tablet') + expect(lease.info.deviceUdid).toBe('emulator-5554') + }) + + it('waits for pending cleanup before starting a new lease', async () => { + const { backend, startSession, stopHelperForDevice, shutdownDevice } = makeBackend() + let finishCleanup: (() => void) | undefined + stopHelperForDevice.mockImplementation( + () => + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + const registry = new EmulatorStartLeaseRegistry() + const first = await registry.acquire(backend, 'Pixel_Tablet', () => false) + + const cleanup = first.release({ cleanupIfUnused: true }) + await vi.waitFor(() => expect(stopHelperForDevice).toHaveBeenCalledOnce()) + const second = registry.acquire(backend, 'Pixel_Tablet', () => false) + await Promise.resolve() + expect(startSession).toHaveBeenCalledOnce() + + finishCleanup?.() + await cleanup + const secondLease = await second + + expect(shutdownDevice).toHaveBeenCalledOnce() + expect(startSession).toHaveBeenCalledTimes(2) + await secondLease.release() + }) + + it('claims synchronously before an earlier lease can start cleanup', async () => { + const { backend, stopHelperForDevice, shutdownDevice } = makeBackend() + const registry = new EmulatorStartLeaseRegistry() + const first = await registry.acquire(backend, 'Pixel_Tablet', () => false) + + const second = registry.acquire(backend, 'Pixel_Tablet', () => false) + await first.release({ cleanupIfUnused: true }) + + expect(stopHelperForDevice).not.toHaveBeenCalled() + const secondLease = await second + await secondLease.release() + + expect(stopHelperForDevice).toHaveBeenCalledOnce() + expect(shutdownDevice).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/emulator/emulator-start-lease-registry.ts b/src/main/emulator/emulator-start-lease-registry.ts new file mode 100644 index 00000000000..94b1e6ce992 --- /dev/null +++ b/src/main/emulator/emulator-start-lease-registry.ts @@ -0,0 +1,127 @@ +import type { EmulatorBackend } from './backends/emulator-backend' +import type { EmulatorSessionInfo } from './emulator-types' + +export type EmulatorStartLease = { + info: EmulatorSessionInfo + release(options?: { cleanupIfUnused?: boolean }): Promise +} + +type PendingCleanup = { + info: EmulatorSessionInfo + isRegistered: (info: EmulatorSessionInfo) => boolean + includeOrphaned: boolean + shutdownDevice: boolean +} + +export class EmulatorStartLeaseRegistry { + private readonly claimsByBackend = new Map() + private readonly cleanupByBackend = new Map>() + private readonly pendingCleanupByBackend = new Map>() + + async acquire( + backend: EmulatorBackend, + device: string, + isRegistered: (info: EmulatorSessionInfo) => boolean + ): Promise { + this.claimsByBackend.set(backend, (this.claimsByBackend.get(backend) ?? 0) + 1) + try { + await this.cleanupByBackend.get(backend) + const info = await backend.startSession(device) + let released = false + return { + info, + release: async (options = {}) => { + if (released) { + return + } + released = true + if (options.cleanupIfUnused) { + this.addPendingCleanup(backend, info, isRegistered, { + includeOrphaned: true, + shutdownDevice: true + }) + } + await this.release(backend) + } + } + } catch (error) { + await this.release(backend) + throw error + } + } + + async cleanupWhenIdle( + backend: EmulatorBackend, + info: EmulatorSessionInfo, + isRegistered: (info: EmulatorSessionInfo) => boolean, + options: { includeOrphaned?: boolean; shutdownDevice?: boolean } = {} + ): Promise { + this.addPendingCleanup(backend, info, isRegistered, options) + await this.drainCleanup(backend) + } + + private async release(backend: EmulatorBackend): Promise { + const remaining = this.decrement(backend) + if (remaining > 0) { + return + } + await this.drainCleanup(backend) + } + + private async drainCleanup(backend: EmulatorBackend): Promise { + await this.cleanupByBackend.get(backend) + if ((this.claimsByBackend.get(backend) ?? 0) > 0) { + return + } + const pending = this.pendingCleanupByBackend.get(backend) + this.pendingCleanupByBackend.delete(backend) + if (!pending) { + return + } + const cleanup = Promise.allSettled( + [...pending.values()].map(async ({ info, isRegistered, includeOrphaned, shutdownDevice }) => { + if (isRegistered(info)) { + return + } + await backend.stopHelperForDevice(info.deviceUdid, { + helperPid: info.helperPid, + includeOrphaned + }) + if (shutdownDevice) { + await backend.shutdownDevice(info.deviceUdid) + } + }) + ) + .then(() => undefined) + .finally(() => this.cleanupByBackend.delete(backend)) + this.cleanupByBackend.set(backend, cleanup) + await cleanup + } + + private addPendingCleanup( + backend: EmulatorBackend, + info: EmulatorSessionInfo, + isRegistered: (info: EmulatorSessionInfo) => boolean, + options: { includeOrphaned?: boolean; shutdownDevice?: boolean } + ): void { + const pending = this.pendingCleanupByBackend.get(backend) ?? new Map() + const existing = pending.get(info.deviceUdid) + pending.set(info.deviceUdid, { + info, + isRegistered, + includeOrphaned: options.includeOrphaned === true || existing?.includeOrphaned === true, + shutdownDevice: options.shutdownDevice === true || existing?.shutdownDevice === true + }) + this.pendingCleanupByBackend.set(backend, pending) + } + + private decrement(backend: EmulatorBackend): number { + const next = Math.max(0, (this.claimsByBackend.get(backend) ?? 1) - 1) + if (next === 0) { + this.claimsByBackend.delete(backend) + } else { + this.claimsByBackend.set(backend, next) + } + return next + } +} diff --git a/src/main/emulator/mjpeg-frame-parser.ts b/src/main/emulator/mjpeg-frame-parser.ts index 841bc6a8c91..90733e2c1fe 100644 --- a/src/main/emulator/mjpeg-frame-parser.ts +++ b/src/main/emulator/mjpeg-frame-parser.ts @@ -32,7 +32,7 @@ export function extractJpegFrames( while (cursor.length > 0) { const frameStart = cursor.indexOf(JPEG_START) - if (frameStart < 0) { + if (frameStart === -1) { const keepLastByte = cursor.at(-1) === 0xff return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } } @@ -41,7 +41,7 @@ export function extractJpegFrames( } const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) - if (frameEnd < 0) { + if (frameEnd === -1) { return { frames, pending: trimPendingBuffer(cursor, maxPendingBytes) } } diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 1615ea336f9..72ee056bd5f 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -16,6 +16,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -97,6 +98,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class GeminiHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() @@ -195,7 +200,7 @@ export class GeminiHookService { return this.getStatus() } - // POSIX-only remote install mirroring ClaudeHookService.installRemote. See docs/design/agent-status-over-ssh.md §8. + // POSIX-only remote install mirroring ClaudeHookService.installRemote; the managed script/JSON shape must match local install() or remote panes report a different status. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.gemini/settings.json` const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/gemini-hook.sh` diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index 0d499aea163..b6e655efe62 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -1,13 +1,19 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache, - getSshGitCapabilityCache + getSshGitCapabilityCache, + withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests +} from './wsl-linked-worktree-git-routing' describe('Git capability execution-host state', () => { beforeEach(() => { clearGitCapabilityStateForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) it('shares native state while isolating each WSL distro', () => { @@ -34,4 +40,34 @@ describe('Git capability execution-host state', () => { getSshGitCapabilityCache(replacementProvider) ) }) + + it('uses native capability state for a prepared host-routed WSL worktree', async () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`) + + await expect( + withLocalGitCapabilityCacheForExecution( + { cwd: String.raw`C:\repo\linked`, wslDistro: 'Ubuntu' }, + async (capabilities) => capabilities + ) + ).resolves.toBe(getLocalGitCapabilityCache()) + expect(getLocalGitCapabilityCache()).not.toBe( + getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }) + ) + } finally { + platform.mockRestore() + } + }) + + it('starts non-candidate capability work without an added async turn', async () => { + let started = false + const result = withLocalGitCapabilityCacheForExecution({ cwd: '/repo' }, async () => { + started = true + return 'done' + }) + + expect(started).toBe(true) + await expect(result).resolves.toBe('done') + }) }) diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index aec7a369a26..7721df722c8 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -1,9 +1,14 @@ import { GitCapabilityCache } from '../../shared/git-capability-cache' import { parseWslUncPath } from '../../shared/wsl-paths' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from './wsl-linked-worktree-git-routing' type LocalGitCapabilityTarget = { cwd?: string wslDistro?: string + signal?: AbortSignal } const localCapabilitiesByExecutionHost = new Map() @@ -29,6 +34,28 @@ export function getLocalGitCapabilityCache( return cache } +export function withLocalGitCapabilityCacheForExecution( + target: LocalGitCapabilityTarget, + run: (capabilities: GitCapabilityCache) => Promise +): Promise { + if (!target.cwd || !isWslLinkedWorktreeGitRoutingCandidate(target.cwd, target.wslDistro)) { + try { + return run(getLocalGitCapabilityCache(target)) + } catch (error) { + return Promise.reject(error) + } + } + return prepareWslLinkedWorktreeGitRouting(target.cwd, target.wslDistro, { + signal: target.signal + }).then((usesHostGit) => + run( + getLocalGitCapabilityCache( + usesHostGit ? { cwd: target.cwd } : { cwd: target.cwd, wslDistro: target.wslDistro } + ) + ) + ) +} + export function getSshGitCapabilityCache(provider: object): GitCapabilityCache { let cache = sshCapabilitiesByProvider.get(provider) if (!cache) { diff --git a/src/main/git/repo-remote-drift-real.test.ts b/src/main/git/repo-remote-drift-real.test.ts new file mode 100644 index 00000000000..3ee2f7c45c9 --- /dev/null +++ b/src/main/git/repo-remote-drift-real.test.ts @@ -0,0 +1,63 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getRecentDriftSubjects, getRemoteDrift } from './repo' + +describe('remote drift real Git contract', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('preserves clean, diverged, and missing-ref results', async () => { + const repoPath = mkdtempSync(join(tmpdir(), 'orca-remote-drift-')) + tempPaths.push(repoPath) + const git = (...args: string[]): string => + execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) + + git('init', '--quiet') + git('config', 'user.name', 'Orca Test') + git('config', 'user.email', 'orca@example.test') + git('config', 'commit.gpgSign', 'false') + git('config', 'core.hooksPath', '.git/no-hooks') + writeFileSync(join(repoPath, 'fixture.txt'), 'base\n') + git('add', 'fixture.txt') + git('commit', '-m', 'base') + git('branch', '-M', 'fixture-base') + git('branch', 'feature') + + writeFileSync(join(repoPath, 'fixture.txt'), 'remote one\n') + git('commit', '-am', 'remote one') + writeFileSync(join(repoPath, 'fixture.txt'), 'remote two\n') + git('commit', '-am', 'remote two') + git('update-ref', 'refs/remotes/origin/main', 'HEAD') + + git('checkout', '--quiet', 'feature') + writeFileSync(join(repoPath, 'local.txt'), 'local one\n') + git('add', 'local.txt') + git('commit', '-m', 'local one') + + await expect(getRemoteDrift(repoPath, 'HEAD', 'origin/main')).resolves.toEqual({ + ahead: 1, + behind: 2 + }) + await expect(getRecentDriftSubjects(repoPath, 'HEAD', 'origin/main', 5)).resolves.toEqual([ + 'remote two', + 'remote one' + ]) + await expect(getRemoteDrift(repoPath, 'origin/main', 'origin/main')).resolves.toEqual({ + ahead: 0, + behind: 0 + }) + await expect( + getRecentDriftSubjects(repoPath, 'origin/main', 'origin/main', 5) + ).resolves.toEqual([]) + await expect(getRemoteDrift(repoPath, 'HEAD', 'missing-ref')).resolves.toBeNull() + await expect(getRecentDriftSubjects(repoPath, 'HEAD', 'missing-ref', 5)).resolves.toEqual([]) + }) +}) diff --git a/src/main/git/repo-remote-drift.test.ts b/src/main/git/repo-remote-drift.test.ts new file mode 100644 index 00000000000..3d0b7ef10e9 --- /dev/null +++ b/src/main/git/repo-remote-drift.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GitRunner from './runner' + +const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn() +})) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: gitExecFileSyncMock + } +}) + +import { getRecentDriftSubjects, getRemoteDrift } from './repo' + +describe('remote drift Git probes', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + }) + + it('parses drift asynchronously with the existing timeout and WSL routing', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '3\t4\n', stderr: '' }) + + await expect( + getRemoteDrift('C:\\repo', 'HEAD', 'origin/main', { wslDistro: 'Ubuntu' }) + ).resolves.toEqual({ ahead: 3, behind: 4 }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-list', '--left-right', '--count', 'HEAD...origin/main'], + { cwd: 'C:\\repo', wslDistro: 'Ubuntu', timeout: 15_000 } + ) + expect(gitExecFileSyncMock).not.toHaveBeenCalled() + }) + + it('keeps malformed output and Git failures unknown', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'invalid\n', stderr: '' }) + await expect(getRemoteDrift('/repo', 'HEAD', 'origin/main')).resolves.toBeNull() + + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('git failed')) + await expect(getRemoteDrift('/repo', 'HEAD', 'origin/main')).resolves.toBeNull() + }) + + it('preserves subject order and degrades a log failure to an empty list', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'newest subject\n\nolder subject\n', + stderr: '' + }) + await expect(getRecentDriftSubjects('/repo', 'HEAD', 'origin/main', 5)).resolves.toEqual([ + 'newest subject', + 'older subject' + ]) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['log', '--format=%s', '-n', '5', 'HEAD..origin/main'], + { cwd: '/repo', timeout: 15_000 } + ) + + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('git failed')) + await expect(getRecentDriftSubjects('/repo', 'HEAD', 'origin/main', 5)).resolves.toEqual([]) + }) +}) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 05d218e39db..26291ec5d39 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -535,17 +535,21 @@ export async function getBaseRefDefault( /** * Return { ahead, behind } (merge-base-symmetric delta) for localRef vs remoteRef, or null on failure. * ahead = commits on localRef not in remoteRef; behind = the reverse. Used by the stale-base dispatch guard (§3.1). + * Async because this runs before every orchestration dispatch on Electron main. */ -export function getRemoteDrift( +export async function getRemoteDrift( repoPath: string, localRef: string, remoteRef: string, options: LocalGitExecOptions = {} -): { ahead: number; behind: number } | null { +): Promise<{ ahead: number; behind: number } | null> { try { - const stdout = gitExecFileSync( + const { stdout } = await gitExecFileAsync( ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], - gitExecOptions(repoPath, options) + { + ...gitExecOptions(repoPath, options), + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + } ) const counts = parseGitRevListAheadBehindCounts(stdout) if (counts.status !== 'ok') { @@ -561,17 +565,20 @@ export function getRemoteDrift( * Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. * Powers the preamble drift section (§3.2) so a worker sees whether stale-base drift touches its area. */ -export function getRecentDriftSubjects( +export async function getRecentDriftSubjects( repoPath: string, localRef: string, remoteRef: string, limit: number, options: LocalGitExecOptions = {} -): string[] { +): Promise { try { - const stdout = gitExecFileSync( + const { stdout } = await gitExecFileAsync( ['log', '--format=%s', '-n', String(limit), `${localRef}..${remoteRef}`], - gitExecOptions(repoPath, options) + { + ...gitExecOptions(repoPath, options), + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + } ) return stdout.split('\n').filter((s) => s.trim().length > 0) } catch { @@ -925,7 +932,7 @@ export function parseAndFilterSearchRefDetails( .filter((line) => line.length > 0) .map((line) => { const nul = line.indexOf('\0') - if (nul < 0) { + if (nul === -1) { // Why: no NUL means an unexpected %(refname) format; drop it rather than hand callers an unusable "short" ref. return null } diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 41975f9d665..8d07bd5c555 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -179,8 +179,8 @@ describe('commandExecFileAsync Windows command shims', () => { describe('runner execFile timeout handling', () => { beforeEach(() => { execFileMock.mockReset() - execFileSyncMock.mockReset() spawnMock.mockReset() + execFileSyncMock.mockReset() vi.useFakeTimers() }) @@ -232,6 +232,22 @@ describe('runner execFile timeout handling', () => { expect(child.kill).toHaveBeenCalled() }) + it('kills an active gh execution when its caller aborts', async () => { + const child = createMockChildProcess(1234) + execFileMock.mockReturnValue(child) + const controller = new AbortController() + const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], { + cwd: '/repo', + signal: controller.signal + }) + const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + + controller.abort() + + await rejection + expect(child.kill).toHaveBeenCalled() + }) + it('honors explicit gh timeouts', async () => { const child = createMockChildProcess(1234) execFileMock.mockReturnValue(child) diff --git a/src/main/git/runner-windows-host-environment.test.ts b/src/main/git/runner-windows-host-environment.test.ts new file mode 100644 index 00000000000..e441b023ff4 --- /dev/null +++ b/src/main/git/runner-windows-host-environment.test.ts @@ -0,0 +1,266 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, spawnMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: spawnMock +})) + +import { + awaitWindowsHostGitEnvironmentReady, + configureWindowsHostGitEnvironmentReadiness, + gitExecFileAsync, + gitExecFileAsyncBuffer, + gitSpawnAfterWindowsEnvironmentReady, + gitStreamStdout +} from './runner' + +type MockChildProcess = EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + pid: number + kill: ReturnType +} + +function createMockChildProcess(pid: number): MockChildProcess { + const child = new EventEmitter() as MockChildProcess + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.pid = pid + child.kill = vi.fn() + return child +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await fn() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +describe('Windows host Git environment readiness', () => { + const originalPath = process.env.Path + + beforeEach(() => { + execFileMock.mockReset() + spawnMock.mockReset() + }) + + afterEach(() => { + configureWindowsHostGitEnvironmentReadiness(null) + if (originalPath === undefined) { + delete process.env.Path + } else { + process.env.Path = originalPath + } + }) + + it('waits for each native operation and refreshes a snapshotted PATH', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const waitUntilReady = vi.fn(() => ready.promise) + const child = createMockChildProcess(1234) + execFileMock.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, 'ok', '') + return child + }) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + process.env.Path = 'hydrating-path' + + const firstExec = gitExecFileAsync(['status'], { + cwd: String.raw`C:\repo`, + env: { Path: 'stale-path' } + }) + + expect(waitUntilReady).toHaveBeenCalledOnce() + expect(execFileMock).not.toHaveBeenCalled() + process.env.Path = 'hydrated-path' + ready.resolve() + await expect(firstExec).resolves.toEqual({ stdout: 'ok', stderr: '' }) + expect(execFileMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-path') + + await expect(gitExecFileAsync(['status'], { cwd: String.raw`C:\repo` })).resolves.toEqual({ + stdout: 'ok', + stderr: '' + }) + expect(waitUntilReady).toHaveBeenCalledTimes(2) + }) + }) + + it('waits before native spawned Git and refreshes a snapshotted PATH', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const waitUntilReady = vi.fn(() => ready.promise) + const child = createMockChildProcess(1234) + spawnMock.mockReturnValue(child) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + process.env.Path = 'hydrating-path' + + const spawned = gitSpawnAfterWindowsEnvironmentReady(['clone', 'url', 'repo'], { + cwd: String.raw`C:\repo`, + env: { Path: 'stale-path' }, + stdio: ['ignore', 'ignore', 'pipe'] + }) + + expect(waitUntilReady).toHaveBeenCalledOnce() + expect(spawnMock).not.toHaveBeenCalled() + process.env.Path = 'hydrated-path' + ready.resolve() + + await expect(spawned).resolves.toBe(child) + expect(spawnMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-path') + }) + }) + + it('does not gate spawned Git routed through WSL on the Windows host profile', async () => { + await withPlatform('win32', async () => { + const waitUntilReady = vi.fn(() => new Promise(() => {})) + const child = createMockChildProcess(1234) + spawnMock.mockReturnValue(child) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + + await expect( + gitSpawnAfterWindowsEnvironmentReady(['clone', 'url', 'repo'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu', + stdio: ['ignore', 'ignore', 'pipe'] + }) + ).resolves.toBe(child) + + expect(waitUntilReady).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalled() + }) + }) + + it('does not spawn Git when canceled during Windows environment readiness', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const controller = new AbortController() + configureWindowsHostGitEnvironmentReadiness(() => ready.promise) + + const spawned = gitSpawnAfterWindowsEnvironmentReady(['status'], { + cwd: String.raw`C:\repo`, + signal: controller.signal, + stdio: ['ignore', 'ignore', 'pipe'] + }) + controller.abort() + + await expect(spawned).rejects.toMatchObject({ name: 'AbortError' }) + expect(spawnMock).not.toHaveBeenCalled() + ready.resolve() + }) + }) + + it('cancels async Git immediately while Windows environment readiness is pending', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const controller = new AbortController() + configureWindowsHostGitEnvironmentReadiness(() => ready.promise) + + const operation = gitExecFileAsync(['status'], { + cwd: String.raw`C:\repo`, + signal: controller.signal + }) + controller.abort() + + await expect(operation).rejects.toMatchObject({ name: 'AbortError' }) + expect(execFileMock).not.toHaveBeenCalled() + ready.resolve() + }) + }) + + it('does not gate WSL paths at a synchronous Git consumer boundary', async () => { + await withPlatform('win32', async () => { + const waitUntilReady = vi.fn(() => new Promise(() => {})) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + + await expect( + awaitWindowsHostGitEnvironmentReady({ + cwd: String.raw`\\wsl.localhost\Ubuntu\home\me\repo` + }) + ).resolves.toBeUndefined() + + expect(waitUntilReady).not.toHaveBeenCalled() + }) + }) + + it('does not gate Git routed through WSL on the Windows host profile', async () => { + await withPlatform('win32', async () => { + const waitUntilReady = vi.fn(() => new Promise(() => {})) + const child = createMockChildProcess(1234) + execFileMock.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, 'ok', '') + return child + }) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + + await expect( + gitExecFileAsync(['status'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + ).resolves.toEqual({ stdout: 'ok', stderr: '' }) + + expect(waitUntilReady).not.toHaveBeenCalled() + expect(execFileMock).toHaveBeenCalled() + }) + }) + + it('gates buffered native Git reads on the current generation', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const child = createMockChildProcess(1234) + execFileMock.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, Buffer.from('blob'), Buffer.alloc(0)) + return child + }) + configureWindowsHostGitEnvironmentReadiness(() => ready.promise) + + const read = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: String.raw`C:\repo` }) + expect(execFileMock).not.toHaveBeenCalled() + ready.resolve() + + await expect(read).resolves.toEqual({ stdout: Buffer.from('blob') }) + }) + }) + + it('gates native Git streams and refreshes their snapshotted PATH', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const child = createMockChildProcess(1234) + spawnMock.mockReturnValue(child) + configureWindowsHostGitEnvironmentReadiness(() => ready.promise) + process.env.Path = 'hydrated-stream-path' + + const stream = gitStreamStdout(['status'], { + cwd: String.raw`C:\repo`, + env: { Path: 'stale-stream-path' }, + onStdout: () => {} + }) + expect(spawnMock).not.toHaveBeenCalled() + ready.resolve() + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) + expect(spawnMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-stream-path') + child.emit('close', 0) + + await expect(stream).resolves.toEqual({ stoppedEarly: false }) + }) + }) +}) diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index f90ea76ad9b..cc6e8364d7f 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -17,12 +17,18 @@ vi.mock('../observability/instrumentation', () => ({ })) vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) -import { gitExecFileAsync, gitStreamStdout } from './runner' +import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' import { getWslGitReadEnvironment, resetWslGitReadEnvironmentForTests, seedWslGitReadEnvironmentForTests } from './wsl-git-read-environment' +import { + prepareWslLinkedWorktreeGitRouting, + resetWslLinkedWorktreeGitRoutingForTests, + WSL_LINKED_WORKTREE_ROUTE_TTL_MS, + type WslLinkedWorktreeRoutingFileSystem +} from './wsl-linked-worktree-git-routing' const DISTRO = 'Ubuntu' const LOGIN_ENVIRONMENT = { @@ -71,10 +77,12 @@ describe('WSL direct Git reads', () => { execFileSyncMock.mockReset() spawnMock.mockReset() resetWslGitReadEnvironmentForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) afterEach(() => { resetWslGitReadEnvironmentForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) it('coalesces the login-shell environment probe per distro', async () => { @@ -495,4 +503,125 @@ describe('WSL direct Git reads', () => { expect(execFileMock.mock.calls[0]?.[0]).toBe('git') }) }) + + it('keeps gitSpawn cache-only while async linked-worktree discovery is pending', async () => { + await withPlatform('win32', async () => { + let releaseStat: (() => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + await delayedStat + return { isDirectory: () => false, isFile: () => true } + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const pending = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + spawnMock.mockReturnValue(createMockChild()) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0]?.[0]).toBe('wsl.exe') + releaseStat?.() + await expect(pending).resolves.toBe(true) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[1]?.[0]).toBe('git') + }) + }) + + it('aborts an async Git call while linked-worktree discovery remains pending', async () => { + await withPlatform('win32', async () => { + let releaseStat: (() => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + await delayedStat + return { isDirectory: () => false, isFile: () => true } + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const discovery = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + const controller = new AbortController() + + const command = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + signal: controller.signal + }) + controller.abort() + + await expect(command).rejects.toMatchObject({ name: 'AbortError' }) + expect(execFileMock).not.toHaveBeenCalled() + releaseStat?.() + await expect(discovery).resolves.toBe(true) + }) + }) + + it('keeps gitSpawn cache-only when a linked-worktree route expires', async () => { + await withPlatform('win32', async () => { + let currentTime = 1_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => currentTime) + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => ({ isDirectory: () => false, isFile: () => true })), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + spawnMock.mockReturnValue(createMockChild()) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + expect(spawnMock.mock.calls[0]?.[0]).toBe('git') + expect(spawnMock.mock.calls[1]?.[0]).toBe('wsl.exe') + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[2]?.[0]).toBe('git') + } finally { + nowSpy.mockRestore() + } + }) + }) }) diff --git a/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts new file mode 100644 index 00000000000..efc37c192f7 --- /dev/null +++ b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts @@ -0,0 +1,98 @@ +import { EventEmitter } from 'node:events' +import type * as FsPromises from 'node:fs/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, statMock, readFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + statMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: statMock, + readFile: readFileMock +})) + +import { gitExecFileAsync } from './runner' +import { + resetWslLinkedWorktreeGitRoutingForTests, + WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS +} from './wsl-linked-worktree-git-routing' + +function createMockChild(): EventEmitter & { pid: number; kill: ReturnType } { + const child = new EventEmitter() as EventEmitter & { + pid: number + kill: ReturnType + } + child.pid = 1234 + child.kill = vi.fn() + return child +} + +async function withWindowsPlatform(run: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + await run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + resetWslLinkedWorktreeGitRoutingForTests() +}) + +describe('WSL linked-worktree routing probe timeout', () => { + it('retries discovery on the next production Git call', async () => { + vi.useFakeTimers() + await withWindowsPlatform(async () => { + statMock + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValue({ isDirectory: () => false, isFile: () => true }) + readFileMock.mockResolvedValue('gitdir: C:/main/.git/worktrees/linked\n') + execFileMock.mockImplementation((command, _args, _options, callback) => { + const child = createMockChild() + queueMicrotask(() => { + if (command === 'wsl.exe') { + callback?.( + Object.assign(new Error('not a git repository'), { code: 128 }), + '', + 'fatal: not a git repository' + ) + } else { + callback?.(null, 'host git recovered\n', '') + } + }) + return child + }) + + const first = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + const firstFailure = expect(first).rejects.toThrow('not a git repository') + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await firstFailure + + await expect( + gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + ).resolves.toEqual({ stdout: 'host git recovered\n', stderr: '' }) + + expect(statMock).toHaveBeenCalledTimes(2) + expect(execFileMock.mock.calls.map(([command]) => command)).toEqual(['wsl.exe', 'git']) + }) + }) +}) diff --git a/src/main/git/runner-wsl-linked-gitdir-windows.test.ts b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts new file mode 100644 index 00000000000..1af46a51069 --- /dev/null +++ b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts @@ -0,0 +1,161 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listFilesWithGit } from '../ipc/filesystem-list-files-git-fallback' +import { searchWithGitGrep } from '../ipc/filesystem-search-git' +import { + gitExecFileAsync, + gitExecFileAsyncBuffer, + gitStreamStdout, + toLinuxPath, + toWindowsWslPath +} from './runner' +import { listWorktrees } from './worktree' +import { resetWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing' + +const distro = process.env.ORCA_TEST_WSL_DISTRO?.trim() +const fixtureRoots: string[] = [] +const wslFixtureRoots: string[] = [] + +function hostGit(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) +} + +function wslExec(args: string[]): string { + return execFileSync('wsl.exe', ['-d', distro!, '--exec', ...args], { encoding: 'utf8' }) +} + +afterEach(async () => { + await Promise.all( + fixtureRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) + for (const path of wslFixtureRoots.splice(0)) { + if (!/^\/tmp\/orca-wsl-native-[A-Za-z0-9]+$/.test(path)) { + throw new Error(`Refusing to remove unexpected WSL fixture path: ${path}`) + } + wslExec(['rm', '-rf', '--', path]) + } +}) + +describe.runIf(process.platform === 'win32' && Boolean(distro))( + 'Windows-authored linked worktree in WSL', + () => { + it('handles the worktree through the production Git runner', async () => { + const fixtureRoot = await mkdtemp(join(process.cwd(), '.wsl-linked-gitdir-')) + fixtureRoots.push(fixtureRoot) + const mainPath = join(fixtureRoot, 'main') + const linkedPath = join(fixtureRoot, 'linked') + + hostGit(['init', mainPath], fixtureRoot) + hostGit(['config', 'user.name', 'Orca Test'], mainPath) + hostGit(['config', 'user.email', 'test@invalid'], mainPath) + await writeFile(join(mainPath, 'tracked.txt'), 'tracked\n') + hostGit(['add', 'tracked.txt'], mainPath) + hostGit(['commit', '-m', 'fixture'], mainPath) + hostGit(['worktree', 'add', '-b', 'linked', linkedPath], mainPath) + await writeFile(join(linkedPath, 'untracked.txt'), 'untracked\n') + + const gitFile = await readFile(join(linkedPath, '.git'), 'utf8') + expect(gitFile).toMatch(/^gitdir: [A-Za-z]:\//) + expect(() => + execFileSync( + 'wsl.exe', + ['-d', distro!, '--exec', 'git', '-C', toLinuxPath(linkedPath), 'status', '--short'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) + ).toThrow(/not a git repository/i) + + await expect(listFilesWithGit(linkedPath, [], { wslDistro: distro! })).resolves.toEqual( + expect.arrayContaining(['tracked.txt', 'untracked.txt']) + ) + resetWslLinkedWorktreeGitRoutingForTests() + await expect( + searchWithGitGrep(linkedPath, { query: 'tracked', rootPath: linkedPath }, 10, { + wslDistro: distro! + }) + ).resolves.toMatchObject({ totalMatches: 2 }) + + let streamedStatus = '' + await expect( + gitStreamStdout(['status', '--short'], { + cwd: linkedPath, + wslDistro: distro!, + onStdout: (chunk) => { + streamedStatus += chunk + } + }) + ).resolves.toEqual({ stoppedEarly: false }) + expect(streamedStatus).toBe('?? untracked.txt\n') + + await gitExecFileAsync(['add', '--', 'untracked.txt'], { + cwd: linkedPath, + wslDistro: distro! + }) + await expect( + gitExecFileAsync(['status', '--short'], { cwd: linkedPath, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: 'A untracked.txt\n' }) + await expect( + gitExecFileAsync(['status', '--short'], { cwd: linkedPath }) + ).resolves.toMatchObject({ stdout: 'A untracked.txt\n' }) + await expect( + gitExecFileAsyncBuffer(['show', ':untracked.txt'], { + cwd: linkedPath, + wslDistro: distro! + }) + ).resolves.toMatchObject({ stdout: Buffer.from('untracked\n') }) + + const folderPath = join(linkedPath, 'packages', 'app') + await mkdir(folderPath, { recursive: true }) + await expect( + gitExecFileAsync(['rev-parse', '--show-toplevel'], { + cwd: folderPath, + wslDistro: distro! + }) + ).resolves.toMatchObject({ stdout: `${linkedPath.replace(/\\/g, '/')}\n` }) + + const linkedExecPath = await gitExecFileAsync(['--exec-path'], { + cwd: linkedPath, + wslDistro: distro! + }) + const mainExecPath = await gitExecFileAsync(['--exec-path'], { + cwd: mainPath, + wslDistro: distro! + }) + expect(linkedExecPath.stdout).toMatch(/^[A-Za-z]:\//) + expect(mainExecPath.stdout).toMatch(/(?:^|\n)\/[^\n]+\n$/) + await expect(listWorktrees(linkedPath, { wslDistro: distro! })).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: expect.stringMatching(/[\\/]linked$/) }) + ]) + ) + + const nestedPath = join(linkedPath, 'nested') + wslExec(['git', 'init', toLinuxPath(nestedPath)]) + await expect( + gitExecFileAsync(['--exec-path'], { cwd: nestedPath, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) }) + }) + + it('keeps WSL-native repositories on WSL Git', async () => { + const linuxRoot = wslExec(['mktemp', '-d', '/tmp/orca-wsl-native-XXXXXX']).trim() + wslFixtureRoots.push(linuxRoot) + const windowsRoot = toWindowsWslPath(linuxRoot, distro!) + + wslExec(['git', 'init', linuxRoot]) + wslExec(['git', '-C', linuxRoot, 'config', 'user.name', 'Orca Test']) + wslExec(['git', '-C', linuxRoot, 'config', 'user.email', 'test@invalid']) + await writeFile(join(windowsRoot, 'tracked.txt'), 'tracked\n') + wslExec(['git', '-C', linuxRoot, 'add', 'tracked.txt']) + wslExec(['git', '-C', linuxRoot, 'commit', '-m', 'fixture']) + await writeFile(join(windowsRoot, 'untracked.txt'), 'untracked\n') + + await expect( + gitExecFileAsync(['status', '--short'], { cwd: windowsRoot, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\?\? untracked\.txt\n$/) }) + await expect( + gitExecFileAsync(['--exec-path'], { cwd: windowsRoot, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) }) + }) + } +) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index ccde4a492f2..264bd7cbfca 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -48,6 +48,11 @@ import { peekWslGitReadEnvironment, type WslGitReadEnvironment } from './wsl-git-read-environment' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting, + usesHostGitForWslLinkedWorktree +} from './wsl-linked-worktree-git-routing' // Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module. import { extractExecError, parseRetryAfterMs } from './exec-error' export { extractExecError, parseRetryAfterMs } @@ -177,12 +182,100 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand { } let defaultWslDistroOverride: string | null = null +let waitForWindowsHostGitEnvironment: (() => Promise) | null = null // Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails. export function setDefaultWslDistroOverride(distro: string | null): void { defaultWslDistroOverride = distro } +export function configureWindowsHostGitEnvironmentReadiness( + waitUntilReady: (() => Promise) | null +): void { + waitForWindowsHostGitEnvironment = waitUntilReady +} + +export async function awaitWindowsHostGitEnvironmentReady(options: { + cwd: string + wslDistro?: string + signal?: AbortSignal +}): Promise { + const resolved = resolveGitCommand(['--version'], options) + await prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) +} + +function refreshWindowsHostPath(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined { + if (!env) { + return undefined + } + const currentPath = process.env.Path ?? process.env.PATH + if (currentPath === undefined) { + return env + } + const next = { ...env } + const pathKeys = Object.keys(next).filter((key) => key.toLowerCase() === 'path') + if (pathKeys.length === 0) { + next[process.env.Path === undefined ? 'PATH' : 'Path'] = currentPath + } else { + for (const key of pathKeys) { + next[key] = currentPath + } + } + return next +} + +function prepareWindowsHostGitEnvironment( + resolved: ResolvedCommand, + env: NodeJS.ProcessEnv | undefined, + signal?: AbortSignal +): Promise | null { + if ( + process.platform !== 'win32' || + resolved.wsl !== null || + waitForWindowsHostGitEnvironment === null + ) { + return null + } + const ready = waitForWindowsHostGitEnvironment().then(() => refreshWindowsHostPath(env)) + if (!signal) { + return ready + } + if (signal.aborted) { + return Promise.reject(createAbortError()) + } + return new Promise((resolve, reject) => { + let settled = false + const cleanup = (): void => signal.removeEventListener('abort', onAbort) + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(createAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + ready.then( + (value) => { + if (settled) { + return + } + settled = true + cleanup() + resolve(value) + }, + (error) => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + ) + }) +} + function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null { const distro = defaultWslDistroOverride ?? getDefaultWslDistro() return distro ? resolveCommand(command, args, undefined, distro) : null @@ -335,6 +428,10 @@ function resolveGitCommand( options: GitExecOptions, forceLoginShell = false ): ResolvedCommand { + if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { + // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. + return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } + } if (!forceLoginShell && shouldAttemptWslDirectGit(options)) { const distro = wslDistroForCommand(options.cwd, options.wslDistro) const environment = distro ? peekWslGitReadEnvironment(distro) : undefined @@ -908,13 +1005,7 @@ async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ return { env: promptEnv, mode: 'explicit-env' } } - const resolved = resolveCommand( - 'git', - ['config', '--get', 'core.sshCommand'], - options.cwd, - options.wslDistro, - { useWslLoginShell: Boolean(options.wslDistro) } - ) + const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true) let configuredCommand = '' try { const { stdout } = await execFileCapture(resolved.binary, resolved.args, { @@ -964,10 +1055,23 @@ export async function gitExecFileAsync( return withGitSpan( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, async () => { - const resolved = resolveGitCommand(args, options) - const policy = options.useConfiguredSshCommandForNetwork - ? await buildNetworkSshPolicyEnv(options) - : { env: nonInteractiveGitEnv(options.env), mode: 'default' as const } + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } + let resolved = resolveGitCommand(args, options) + const environmentReady = prepareWindowsHostGitEnvironment( + resolved, + options.env, + options.signal + ) + const env = environmentReady ? await environmentReady : options.env + const effectiveOptions = env === options.env ? options : { ...options, env } + resolved = resolveGitCommand(args, effectiveOptions) + const policy = effectiveOptions.useConfiguredSshCommandForNetwork + ? await buildNetworkSshPolicyEnv(effectiveOptions) + : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } const capture = ( command: ResolvedCommand ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => @@ -986,7 +1090,7 @@ export async function gitExecFileAsync( } catch (error) { if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) { const wasMissing = invalidateMissingDirectWslGit(error, resolved) - result = await capture(resolveGitCommand(args, options, true)) + result = await capture(resolveGitCommand(args, effectiveOptions, true)) // Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient. disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) const { stdout, stderr } = result @@ -1055,9 +1159,15 @@ export async function gitExecFileAsyncBuffer( args: string[], options: { cwd: string; maxBuffer?: number; wslDistro?: string } ): Promise<{ stdout: Buffer }> { - const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, { - useWslLoginShell: Boolean(options.wslDistro) - }) + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) + } + let resolved = resolveGitCommand(args, options, true) + const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined) + if (environmentReady) { + await environmentReady + } + resolved = resolveGitCommand(args, options, true) const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: 'buffer', @@ -1098,6 +1208,11 @@ export async function gitStreamStdout( ): Promise { const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER return withGitSpan({ args, cwd: options.cwd }, async () => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } const gitOptions: GitExecOptions = { cwd: options.cwd, ...(options.env ? { env: options.env } : {}), @@ -1106,6 +1221,15 @@ export async function gitStreamStdout( ...(options.signal ? { signal: options.signal } : {}) } let resolved = resolveGitCommand(args, gitOptions) + const environmentReady = prepareWindowsHostGitEnvironment( + resolved, + gitOptions.env, + options.signal + ) + if (environmentReady) { + gitOptions.env = await environmentReady + } + resolved = resolveGitCommand(args, gitOptions) const stream = (command: ResolvedCommand): Promise => new Promise((resolve, reject) => { if (options.signal?.aborted) { @@ -1115,7 +1239,7 @@ export async function gitStreamStdout( const stdio: SpawnOptions['stdio'] = ['ignore', 'pipe', 'pipe'] const spawnOptions = { cwd: options.cwd, - env: nonInteractiveGitEnv(options.env), + env: nonInteractiveGitEnv(gitOptions.env), stdio, wslDistro: options.wslDistro, windowsHide: true @@ -1289,13 +1413,34 @@ export function gitExecFileSync( * Spawn a git child process. Drop-in replacement for * `spawn('git', args, { cwd, stdio, ... })`. */ -export function gitSpawn( +type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string } + +export async function gitSpawnAfterWindowsEnvironmentReady( args: string[], - options: SpawnOptions & { cwd: string; wslDistro?: string } -): ChildProcess { + options: GitSpawnOptions +): Promise { + if (options.signal?.aborted) { + throw createAbortError() + } + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.env ? { env: options.env } : {}) + }) + const env = await (prepareWindowsHostGitEnvironment(resolved, options.env, options.signal) ?? + options.env) + if (options.signal?.aborted) { + throw createAbortError() + } + return gitSpawn(args, env === options.env ? options : { ...options, env }) +} + +export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess { const { wslDistro, ...spawnOptions } = options - const resolved = resolveCommand('git', args, options.cwd, wslDistro, { - useWslLoginShell: Boolean(wslDistro) + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(wslDistro ? { wslDistro } : {}), + ...(spawnOptions.env ? { env: spawnOptions.env } : {}) }) const spawnStartedAt = performance.now() const child = spawn(resolved.binary, resolved.args, { @@ -1438,8 +1583,24 @@ const GH_RETRY_DELAYS_MS = [250, 1000] as const const GH_RETRY_AFTER_MAX_MS = 30_000 const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000 -async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) +async function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw createAbortError() + } + await new Promise((resolve, reject) => { + const timer = setTimeout(finish, ms) + const onAbort = (): void => finish(createAbortError()) + function finish(error?: Error): void { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + if (error) { + reject(error) + } else { + resolve() + } + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) } function defaultGhExecTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { @@ -1611,7 +1772,8 @@ export async function ghExecFileAsync( maxBuffer: options.maxBuffer, // Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane. timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env), - env: nonInteractiveGhEnv(options.env) + env: nonInteractiveGhEnv(options.env), + signal: options.signal }) return { stdout: stdout as string, stderr: stderr as string } } catch (err) { @@ -1653,7 +1815,7 @@ export async function ghExecFileAsync( retryAfterMs !== null ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) : GH_RETRY_DELAYS_MS[attempt] - await sleep(delayMs) + await sleep(delayMs, options.signal) continue } throw err diff --git a/src/main/git/status-conflict-overlap.bench.test.ts b/src/main/git/status-conflict-overlap.bench.test.ts new file mode 100644 index 00000000000..5c92a23e10c --- /dev/null +++ b/src/main/git/status-conflict-overlap.bench.test.ts @@ -0,0 +1,250 @@ +import { performance } from 'node:perf_hooks' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromises from 'node:fs/promises' +import type * as GitRunner from './runner' +import type { GitExec } from '../../relay/git-handler-ops' +import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' + +const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({ + gitStreamStdoutMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readFile: readFileMock } +}) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, gitStreamStdout: gitStreamStdoutMock } +}) + +import { getStatus } from './status' +import { getStatusOp } from '../../relay/git-handler-status-ops' + +const BENCH_DELAY_MS = 25 +const BENCH_SAMPLES = 31 +const BENCH_WARMUPS = 5 +const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +type BenchmarkResult = { + path: string + medianMs: number + p95Ms: number + minMs: number + maxMs: number +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +function percentile(samples: number[], fraction: number): number { + const sorted = [...samples].sort((a, b) => a - b) + return sorted[Math.ceil(sorted.length * fraction) - 1]! +} + +describe('git status conflict-read overlap', () => { + const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) + + beforeEach(() => { + readFileMock.mockReset() + gitStreamStdoutMock.mockReset() + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false }) + }) + + it('starts native status before conflict-marker I/O settles', async () => { + const markerRead = deferred() + const statusStarted = deferred() + readFileMock.mockReturnValue(markerRead.promise) + gitStreamStdoutMock.mockImplementation(async () => { + statusStarted.resolve() + return { stoppedEarly: false } + }) + + const resultPromise = getStatus('/repo') + await statusStarted.promise + expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('starts relay status before conflict-marker I/O settles', async () => { + const markerRead = deferred() + const statusStarted = deferred() + readFileMock.mockReturnValue(markerRead.promise) + const relayStreamGit: RelayGitStreamExec = async () => { + statusStarted.resolve() + return { stoppedEarly: false } + } + + const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + await statusStarted.promise + expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('observes an early native status rejection while marker I/O remains pending', async () => { + const markerRead = deferred() + readFileMock.mockReturnValue(markerRead.promise) + gitStreamStdoutMock.mockRejectedValue(new Error('status failed first')) + let settled = false + + const resultPromise = getStatus('/repo').finally(() => { + settled = true + }) + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('keeps a synchronous native status failure fail-soft', async () => { + const statusError = new Error('status threw') + gitStreamStdoutMock.mockImplementation(() => { + throw statusError + }) + + await expect(getStatus('/repo')).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => { + const statusError = new Error(`status ${mode}`) + const relayStreamGit = (() => { + if (mode === 'throw') { + throw statusError + } + return Promise.reject(statusError) + }) as RelayGitStreamExec + + await expect( + getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + ).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' }) + }) + + it('rethrows the original relay status failure after cancellation', async () => { + const controller = new AbortController() + const statusError = new Error('cancelled status') + const relayStreamGit: RelayGitStreamExec = async () => { + controller.abort(statusError) + throw statusError + } + + await expect( + getStatusOp( + relayGit, + relayStreamGit, + { worktreePath: '/repo' }, + { signal: controller.signal } + ) + ).rejects.toBe(statusError) + }) + + it('surfaces detector errors without waiting for a hung status read', async () => { + const statusStarted = deferred() + const relayStreamGit: RelayGitStreamExec = () => { + statusStarted.resolve() + return new Promise(() => {}) + } + const invalidPath = { toString: () => '/repo' } + + const resultPromise = getStatusOp(relayGit, relayStreamGit, { + worktreePath: invalidPath + }) + const rejection = expect(resultPromise).rejects.toThrow(TypeError) + await statusStarted.promise + await rejection + }) + + it('keeps detector errors ahead of concurrent status failures', async () => { + const statusError = new Error('status failed too') + const relayStreamGit = (() => { + throw statusError + }) as RelayGitStreamExec + const invalidPath = { toString: () => '/repo' } + + const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath }) + await expect(result).rejects.toBeInstanceOf(TypeError) + }) +}) + +describeBench('git status conflict-read overlap benchmark', () => { + it('measures native and relay orchestration with matched independent latency', async () => { + readFileMock.mockImplementation(async () => { + await wait(BENCH_DELAY_MS) + return 'gitdir: /repo/.git/worktrees/feature\n' + }) + gitStreamStdoutMock.mockImplementation(async () => { + await wait(BENCH_DELAY_MS) + return { stoppedEarly: false } + }) + const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) + const relayStreamGit: RelayGitStreamExec = async () => { + await wait(BENCH_DELAY_MS) + return { stoppedEarly: false } + } + const cases = [ + { name: 'native', run: () => getStatus('/repo') }, + { + name: 'relay', + run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + } + ] + + const results: BenchmarkResult[] = [] + for (const benchmarkCase of cases) { + for (let index = 0; index < BENCH_WARMUPS; index += 1) { + await benchmarkCase.run() + } + const samples: number[] = [] + for (let index = 0; index < BENCH_SAMPLES; index += 1) { + const startedAt = performance.now() + await benchmarkCase.run() + samples.push(performance.now() - startedAt) + } + results.push({ + path: benchmarkCase.name, + medianMs: Number(percentile(samples, 0.5).toFixed(3)), + p95Ms: Number(percentile(samples, 0.95).toFixed(3)), + minMs: Number(Math.min(...samples).toFixed(3)), + maxMs: Number(Math.max(...samples).toFixed(3)) + }) + } + + console.table(results) + expect(results).toHaveLength(2) + }, 10_000) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 07bf124d31c..a51d1784ddf 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -315,22 +315,32 @@ async function runGetStatus( // Why: stream + parse and stop at `limit` so a huge un-ignored folder can't buffer enough to crash the process. const parser = new StatusPorcelainParser() let didHitLimit = false + // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + const statusSettlementPromise = Promise.allSettled([ + (async () => { + const result = await gitStreamStdout(statusArgs, { + cwd: worktreePath, + wslDistro: options.wslDistro, + preferWslDirectGit: true, + // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. + env: gitOptionalLocksDisabledEnv(), + signal: options.signal, + onStdout: (chunk) => parser.update(chunk, limit) + }) + if (!result.stoppedEarly) { + parser.finish() + } + return result + })() + ]) const conflictOperation = await conflictPromise try { - const { stoppedEarly } = await gitStreamStdout(statusArgs, { - cwd: worktreePath, - wslDistro: options.wslDistro, - preferWslDirectGit: true, - // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. - env: gitOptionalLocksDisabledEnv(), - signal: options.signal, - onStdout: (chunk) => parser.update(chunk, limit) - }) - if (!stoppedEarly) { - parser.finish() + const [statusResult] = await statusSettlementPromise + if (statusResult.status === 'rejected') { + throw statusResult.reason } - didHitLimit = stoppedEarly + didHitLimit = statusResult.value.stoppedEarly statusSucceeded = true } catch (error) { // Why: an aborted scan must reject, not resolve as an empty result. diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 064eb0887fd..9ee8eb2e188 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -30,7 +30,7 @@ import { isUnsupportedRevParsePathFormatError, isUnsupportedWorktreeListZError } from '../../shared/git-worktree-command-capabilities' -import { getLocalGitCapabilityCache } from './git-capability-state' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir, runWithGitReadCacheInvalidation } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' @@ -410,32 +410,32 @@ async function readRepoLocation( resolveBasePath: string, options: GitWorktreeExecOptions = {} ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: options.wslDistro - }) try { - return await capabilities.runWithFallback( - 'rev-parse-path-format', - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) + return await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'rev-parse-path-format', + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + if (hasUnsupportedRevParsePathFormatEcho(stdout)) { + // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. + capabilities.rememberUnsupported('rev-parse-path-format') + } + return parseRepoLocation(resolveBasePath, stdout) + }, + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + return parseRepoLocation(resolveBasePath, stdout) + }, + isUnsupportedRevParsePathFormatError ) - if (hasUnsupportedRevParsePathFormatEcho(stdout)) { - // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. - capabilities.rememberUnsupported('rev-parse-path-format') - } - return parseRepoLocation(resolveBasePath, stdout) - }, - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) - ) - return parseRepoLocation(resolveBasePath, stdout) - }, - isUnsupportedRevParsePathFormatError ) } catch { return undefined @@ -578,41 +578,44 @@ async function readWorktreeList( repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: options.wslDistro - }) const execOptions = { cwd: repoPath, ...options, timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS } - return capabilities.runWithFallback( - 'worktree-list-z', - async () => { - const { stdout } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain', '-z'], - execOptions + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'worktree-list-z', + async () => { + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain', '-z'], + execOptions + ) + return normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }), + options + ) + }, + async () => { + // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + execOptions + ) + const normalized = await normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout), + options + ) + // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting + // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). + return annotatePrunableByExistence(normalized, repoPath, options) + }, + isUnsupportedWorktreeListZError ) - return normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout, { nulDelimited: true }), - options - ) - }, - async () => { - // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], execOptions) - const normalized = await normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout), - options - ) - // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting - // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). - return annotatePrunableByExistence(normalized, repoPath, options) - }, - isUnsupportedWorktreeListZError ) } @@ -1355,14 +1358,12 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( }) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) // Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes. - if ( - !(await branchHasNoUnmergedChangesWithLazyTargetRefresh( - runGit, - branchName, - targetRefs, - getLocalGitCapabilityCache({ cwd: repoPath, wslDistro: options.wslDistro }) - )) - ) { + const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + branchHasNoUnmergedChangesWithLazyTargetRefresh(runGit, branchName, targetRefs, capabilities) + ) + if (!hasNoUnmergedChanges) { return false } await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) => diff --git a/src/main/git/wsl-git-read-environment.ts b/src/main/git/wsl-git-read-environment.ts index f67d696a37b..7df86a44a94 100644 --- a/src/main/git/wsl-git-read-environment.ts +++ b/src/main/git/wsl-git-read-environment.ts @@ -26,7 +26,7 @@ function parseProbe(stdout: string): WslGitReadEnvironment | null { const gitPath = fields[markerIndex + 2] ?? '' const home = fields[markerIndex + 3] ?? '' if ( - markerIndex < 0 || + markerIndex === -1 || !path.includes('/') || path.length > 32_768 || !gitPath.startsWith('/') || diff --git a/src/main/git/wsl-linked-worktree-git-routing.test.ts b/src/main/git/wsl-linked-worktree-git-routing.test.ts new file mode 100644 index 00000000000..9ab07309b1f --- /dev/null +++ b/src/main/git/wsl-linked-worktree-git-routing.test.ts @@ -0,0 +1,600 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + parseWindowsLinkedGitdir, + prepareWslLinkedWorktreeGitRouting, + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests, + usesHostGitForWslLinkedWorktree, + WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD, + WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD, + WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL, + WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS, + WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS, + WSL_LINKED_WORKTREE_ROUTE_TTL_MS, + type WslLinkedWorktreeRoutingFileSystem +} from './wsl-linked-worktree-git-routing' + +afterEach(() => resetWslLinkedWorktreeGitRoutingForTests()) + +const fileMarker = { isDirectory: () => false, isFile: () => true } +const directoryMarker = { isDirectory: () => true, isFile: () => false } + +function missingMarker(): NodeJS.ErrnoException { + return Object.assign(new Error('missing'), { code: 'ENOENT' }) +} + +describe('parseWindowsLinkedGitdir', () => { + it.each([ + ['gitdir: C:/repo/.git/worktrees/linked\n', 'C:/repo/.git/worktrees/linked'], + [String.raw`gitdir: D:\repo\.git\worktrees\linked`, String.raw`D:\repo\.git\worktrees\linked`] + ])('accepts a Windows drive-qualified gitdir', (content, expected) => { + expect(parseWindowsLinkedGitdir(content)).toBe(expected) + }) + + it.each([ + 'gitdir: ../main/.git/worktrees/linked', + 'gitdir: /home/dev/repo/.git/worktrees/linked', + 'gitdir: C:repo/.git/worktrees/linked', + 'not a gitdir' + ])('rejects the non-Windows-linked marker %s', (content) => { + expect(parseWindowsLinkedGitdir(content)).toBeNull() + }) +}) + +describe('usesHostGitForWslLinkedWorktree', () => { + it('scopes a cached route to the exact primed folder workspace', () => { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked\packages\app`) + + expect( + usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked\packages\app`, 'Ubuntu', 'win32') + ).toBe(true) + expect(usesHostGitForWslLinkedWorktree('C:/repo/linked/packages/app/', 'Ubuntu', 'win32')).toBe( + true + ) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\main`, 'Ubuntu', 'win32')).toBe(false) + }) + + it('does not affect native Windows, WSL-native, or non-Windows execution', () => { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`) + + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked`, undefined, 'win32')).toBe( + false + ) + expect( + usesHostGitForWslLinkedWorktree( + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', + 'Ubuntu', + 'win32' + ) + ).toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked`, 'Ubuntu', 'linux')).toBe( + false + ) + }) +}) + +describe('prepareWslLinkedWorktreeGitRouting', () => { + it('discovers parent markers independently for sibling folder workspaces', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => { + if (path === String.raw`C:\repo\.git`) { + return fileMarker + } + throw missingMarker() + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\other`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(6) + expect(fileSystem.readFile).toHaveBeenCalledTimes(2) + }) + + it('caches an ordinary repository marker without reading it', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => directoryMarker), + readFile: vi.fn(async () => '') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(fileSystem.readFile).not.toHaveBeenCalled() + }) + + it('fails closed without caching a marker read error', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => fileMarker), + readFile: vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('access denied'), { code: 'EACCES' })) + .mockResolvedValueOnce('gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(2) + }) + + it('backs off repeated marker stat errors after one immediate retry', async () => { + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + throw Object.assign(new Error('device unavailable'), { code: 'EIO' }) + }), + readFile: vi.fn(async () => '') + } + const prepare = (): Promise => + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + + await expect(prepare()).resolves.toBe(false) + await expect(prepare()).resolves.toBe(false) + await expect(prepare()).resolves.toBe(false) + currentTime += WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS - 1 + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + + currentTime += 1 + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(3) + }) + + it('does not inherit a linked-parent route across a nested repository boundary', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => + path === String.raw`C:\repo\linked\nested\.git` ? directoryMarker : fileMarker + ), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\linked`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\linked\nested`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect( + usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked\nested`, 'Ubuntu', 'win32') + ).toBe(false) + }) + + it('does not conflate case-distinct directories on case-sensitive Windows storage', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => + path === String.raw`C:\Repo\.git` ? fileMarker : directoryMarker + ), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\Repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + }) + + it('revalidates a linked route that becomes an ordinary repository', async () => { + let linked = true + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => (linked ? fileMarker : directoryMarker)), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + linked = false + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS - 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + currentTime += 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32', now)).toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('revalidates an ordinary repository that becomes a linked worktree', async () => { + let linked = false + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => (linked ? fileMarker : directoryMarker)), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + linked = true + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS - 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + currentTime += 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32', now)).toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('does not let a sibling route supersede an unexpired nested-repository miss', async () => { + let linked = false + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => { + if (path === String.raw`C:\repo\.git`) { + return linked ? fileMarker : directoryMarker + } + throw missingMarker() + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + linked = true + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\other`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(6) + }) + + it('does not evict fresh routes while pruning under working-directory churn', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => fileMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + for (let index = 0; index <= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD; index += 1) { + await prepareWslLinkedWorktreeGitRouting(`C:\\repo-${index}`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + } + + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo-0`, 'Ubuntu', 'win32')).toBe(true) + expect( + usesHostGitForWslLinkedWorktree( + `C:\\repo-${WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD}`, + 'Ubuntu', + 'win32' + ) + ).toBe(true) + }) + + it('coalesces delayed discovery without blocking an event-loop turn', async () => { + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + let settled = false + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }).finally(() => { + settled = true + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(settled).toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + releaseStat?.(fileMarker) + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('cancels a waiter without canceling or duplicating shared discovery', async () => { + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const controller = new AbortController() + + const cancelled = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + signal: controller.signal + }) + controller.abort() + + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + releaseStat?.(fileMarker) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + }) + + it('ignores an old probe that settles after reset and a new same-path probe starts', async () => { + let releaseOld: ((marker: typeof fileMarker) => void) | undefined + const oldMarker = new Promise((resolve) => { + releaseOld = resolve + }) + const oldFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => oldMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const oldRoute = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: oldFileSystem + }) + + resetWslLinkedWorktreeGitRoutingForTests() + let releaseCurrent: ((marker: typeof directoryMarker) => void) | undefined + const currentMarker = new Promise((resolve) => { + releaseCurrent = resolve + }) + const currentFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => currentMarker), + readFile: vi.fn(async () => '') + } + const currentRoute = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: currentFileSystem + }) + + releaseOld?.(fileMarker) + await expect(oldRoute).resolves.toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32')).toBe(false) + const currentJoiner = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: currentFileSystem + }) + expect(currentFileSystem.stat).toHaveBeenCalledTimes(1) + + releaseCurrent?.(directoryMarker) + await expect(Promise.all([currentRoute, currentJoiner])).resolves.toEqual([false, false]) + expect(currentFileSystem.stat).toHaveBeenCalledTimes(1) + }) + + it('fails closed and retries after a stalled discovery deadline', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce(fileMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + + await expect(Promise.all([first, second])).resolves.toEqual([false, false]) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('caps outstanding probes for one working directory after repeated deadlines', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => new Promise(() => {})), + readFile: vi.fn(async () => '') + } + const prepare = (): Promise => + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + + for (let index = 0; index < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD; index += 1) { + const route = prepare() + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await expect(route).resolves.toBe(false) + } + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS) + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD) + } finally { + vi.useRealTimers() + } + }) + + it('caps outstanding probes across working directories', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => new Promise(() => {})), + readFile: vi.fn(async () => '') + } + const pending = Array.from( + { length: WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL }, + (_, index) => + prepareWslLinkedWorktreeGitRouting(`C:\\repo-${index}`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ) + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\blocked`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL) + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await expect(Promise.all(pending)).resolves.toEqual( + Array.from({ length: WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL }, () => false) + ) + } finally { + vi.useRealTimers() + } + }) + + it('coalesces concurrent revalidation after a cached route expires', async () => { + let currentTime = 1_000 + const now = (): number => currentTime + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi + .fn() + .mockResolvedValueOnce(directoryMarker) + .mockReturnValueOnce(delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + releaseStat?.(fileMarker) + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/git/wsl-linked-worktree-git-routing.ts b/src/main/git/wsl-linked-worktree-git-routing.ts new file mode 100644 index 00000000000..440307fd5f0 --- /dev/null +++ b/src/main/git/wsl-linked-worktree-git-routing.ts @@ -0,0 +1,306 @@ +import * as fsPromises from 'node:fs/promises' +import { win32 } from 'node:path' +import type { Stats } from 'node:fs' + +type CachedRoute = { usesHostGit: boolean; expiresAt: number } +type TransientRouteFailure = { count: number; retryAfter: number } + +// Bounds stale routing after delete/recreate while amortizing async parent walks on slow storage. +export const WSL_LINKED_WORKTREE_ROUTE_TTL_MS = 30_000 +export const WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD = 512 +export const WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS = 5_000 +export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD = 2 +export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL = 32 +export const WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS = 1_000 +const WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS = 30_000 + +const routeByCwd = new Map() +const pendingRoutes = new Map>() +const transientFailuresByCwd = new Map() +const activeProbeCountByCwd = new Map() +let activeProbeCount = 0 +let routeProbeGeneration = 0 +let nextRoutePruneAt = 0 + +export function isWslLinkedWorktreeGitRoutingCandidate( + cwd: string, + wslDistro: string | undefined, + platform: NodeJS.Platform = process.platform +): boolean { + return platform === 'win32' && Boolean(wslDistro) && /^[A-Za-z]:[/\\]/.test(cwd) +} + +function normalize(path: string): string { + return win32.resolve(path) +} + +function cachedRoute(cwd: string, now: number): boolean | undefined { + const exact = routeByCwd.get(cwd) + if (!exact) { + return undefined + } + if (exact.expiresAt <= now) { + routeByCwd.delete(cwd) + return undefined + } + return exact.usesHostGit +} + +export function parseWindowsLinkedGitdir(content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + return firstLine.match(/^gitdir:\s*([A-Za-z]:[/\\].*?)\s*$/i)?.[1] ?? null +} + +export type WslLinkedWorktreeRoutingFileSystem = { + stat(path: string): Promise> + readFile(path: string): Promise +} + +export type WslLinkedWorktreeRoutingOptions = { + platform?: NodeJS.Platform + fileSystem?: WslLinkedWorktreeRoutingFileSystem + now?: () => number + signal?: AbortSignal +} + +const defaultFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: (path) => fsPromises.stat(path), + readFile: (path) => fsPromises.readFile(path, 'utf8') +} + +async function shouldUseHostGit( + cwd: string, + fileSystem: WslLinkedWorktreeRoutingFileSystem +): Promise { + let candidate = cwd + const driveRoot = win32.parse(candidate).root + while (true) { + const markerPath = win32.join(candidate, '.git') + try { + const marker = await fileSystem.stat(markerPath) + if (marker.isDirectory()) { + return false + } + if (marker.isFile()) { + return parseWindowsLinkedGitdir(await fileSystem.readFile(markerPath)) !== null + } + return false + } catch (error) { + const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : null + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error + } + } + if (candidate === driveRoot) { + return false + } + candidate = win32.dirname(candidate) + } +} + +function rememberRoute(cwd: string, usesHostGit: boolean, now: number): boolean { + const expiresAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS + if ( + routeByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD && + now >= nextRoutePruneAt + ) { + for (const [cachedCwd, route] of routeByCwd) { + if (route.expiresAt <= now) { + routeByCwd.delete(cachedCwd) + } + } + nextRoutePruneAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS + } + routeByCwd.set(cwd, { usesHostGit, expiresAt }) + return usesHostGit +} + +function routingAbortError(): Error { + return Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) +} + +function rememberTransientFailure(cwd: string, now: number): void { + const count = (transientFailuresByCwd.get(cwd)?.count ?? 0) + 1 + const retryDelay = + count === 1 + ? 0 + : Math.min( + WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS * 2 ** Math.min(count - 2, 10), + WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS + ) + if ( + !transientFailuresByCwd.has(cwd) && + transientFailuresByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD + ) { + const oldest = transientFailuresByCwd.keys().next().value + if (oldest !== undefined) { + transientFailuresByCwd.delete(oldest) + } + } + transientFailuresByCwd.delete(cwd) + transientFailuresByCwd.set(cwd, { count, retryAfter: now + retryDelay }) +} + +function canStartRouteProbe(cwd: string, now: number): boolean { + const failure = transientFailuresByCwd.get(cwd) + return Boolean( + (!failure || failure.retryAfter <= now) && + (activeProbeCountByCwd.get(cwd) ?? 0) < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD && + activeProbeCount < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL + ) +} + +function trackRouteProbe(cwd: string): () => void { + const generation = routeProbeGeneration + activeProbeCount += 1 + activeProbeCountByCwd.set(cwd, (activeProbeCountByCwd.get(cwd) ?? 0) + 1) + return () => { + if (generation !== routeProbeGeneration) { + return + } + activeProbeCount -= 1 + const remaining = (activeProbeCountByCwd.get(cwd) ?? 1) - 1 + if (remaining === 0) { + activeProbeCountByCwd.delete(cwd) + } else { + activeProbeCountByCwd.set(cwd, remaining) + } + } +} + +function waitForRouteUnlessAborted( + route: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + return route + } + if (signal.aborted) { + return Promise.reject(routingAbortError()) + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + reject(routingAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + route.then( + (result) => { + signal.removeEventListener('abort', onAbort) + resolve(result) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +function discoverRoute( + cwd: string, + fileSystem: WslLinkedWorktreeRoutingFileSystem, + now: () => number +): Promise { + return new Promise((resolve) => { + const generation = routeProbeGeneration + let settled = false + const finish = (usesHostGit: boolean, cacheable: boolean): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + if (generation !== routeProbeGeneration) { + resolve(false) + return + } + if (cacheable) { + transientFailuresByCwd.delete(cwd) + resolve(rememberRoute(cwd, usesHostGit, now())) + } else { + rememberTransientFailure(cwd, now()) + resolve(usesHostGit) + } + } + const timer = setTimeout(() => finish(false, false), WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + timer.unref() + const releaseProbe = trackRouteProbe(cwd) + void shouldUseHostGit(cwd, fileSystem).then( + (usesHostGit) => { + releaseProbe() + finish(usesHostGit, true) + }, + () => { + releaseProbe() + finish(false, false) + } + ) + }) +} + +export async function prepareWslLinkedWorktreeGitRouting( + cwd: string, + wslDistro: string | undefined, + options: WslLinkedWorktreeRoutingOptions = {} +): Promise { + const platform = options.platform ?? process.platform + const fileSystem = options.fileSystem ?? defaultFileSystem + const now = options.now ?? Date.now + if (!isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform)) { + return false + } + if (options.signal?.aborted) { + throw routingAbortError() + } + const normalizedCwd = normalize(cwd) + const cached = cachedRoute(normalizedCwd, now()) + if (cached !== undefined) { + return cached + } + const pending = pendingRoutes.get(normalizedCwd) + if (pending) { + return waitForRouteUnlessAborted(pending, options.signal) + } + if (!canStartRouteProbe(normalizedCwd, now())) { + return false + } + const discovery = discoverRoute(normalizedCwd, fileSystem, now) + const route = discovery.finally(() => { + if (pendingRoutes.get(normalizedCwd) === route) { + pendingRoutes.delete(normalizedCwd) + } + }) + pendingRoutes.set(normalizedCwd, route) + return waitForRouteUnlessAborted(route, options.signal) +} + +export function usesHostGitForWslLinkedWorktree( + cwd: string, + wslDistro: string | undefined, + platform: NodeJS.Platform = process.platform, + now: () => number = Date.now +): boolean { + return ( + isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform) && + cachedRoute(normalize(cwd), now()) === true + ) +} + +export function resetWslLinkedWorktreeGitRoutingForTests(): void { + routeByCwd.clear() + pendingRoutes.clear() + transientFailuresByCwd.clear() + activeProbeCountByCwd.clear() + activeProbeCount = 0 + routeProbeGeneration += 1 + nextRoutePruneAt = 0 +} + +export function seedWslLinkedWorktreeGitRoutingForTests(root: string): void { + const normalizedRoot = normalize(root) + routeByCwd.set(normalizedRoot, { + usesHostGit: true, + expiresAt: Number.POSITIVE_INFINITY + }) +} diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts index b092b01eb94..4bb2fcbae76 100644 --- a/src/main/github/client-pr-check-details.test.ts +++ b/src/main/github/client-pr-check-details.test.ts @@ -148,20 +148,86 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', 'repos/acme/widgets/check-runs/88'], - { cwd: '/repo-root', host: 'github.com' } + expect.objectContaining({ + cwd: '/repo-root', + host: 'github.com', + signal: expect.any(AbortSignal) + }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/acme/widgets/check-runs/88/annotations?per_page=20'], - { cwd: '/repo-root', host: 'github.com' } + expect.objectContaining({ + cwd: '/repo-root', + host: 'github.com', + signal: expect.any(AbortSignal) + }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', 'repos/acme/widgets/actions/runs/77/jobs?per_page=100'], - { cwd: '/repo-root', host: 'github.com' } + expect.objectContaining({ + cwd: '/repo-root', + host: 'github.com', + signal: expect.any(AbortSignal) + }) ) }) + it('rejects provider failures so callers can distinguish them from unavailable details', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('authentication failed')) + + await expect(getPRCheckDetails('/repo-root', { checkRunId: 88 })).rejects.toThrow( + 'authentication failed' + ) + }) + + it('aborts host work at the cumulative check-details deadline', async () => { + vi.useFakeTimers() + try { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation( + (_args: string[], options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => { + const error = new Error('aborted') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + const request = getPRCheckDetails('/repo-root', { checkRunId: 88 }) + const rejection = expect(request).rejects.toThrow('Timed out loading check details.') + + await vi.advanceTimersByTimeAsync(25_000) + + await rejection + } finally { + vi.useRealTimers() + } + }) + + it('stops waiting for shared repository resolution at the host deadline', async () => { + vi.useFakeTimers() + try { + getOwnerRepoMock.mockImplementationOnce(() => new Promise(() => {})) + const request = getPRCheckDetails('/repo-root', { checkRunId: 88 }) + const rejection = expect(request).rejects.toThrow('Timed out loading check details.') + + await vi.advanceTimersByTimeAsync(25_000) + + await rejection + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('fetches sliced log tails for failed workflow jobs only', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) const actionUrl = 'https://github.com/acme/widgets/actions/runs/77/job/88' @@ -225,7 +291,11 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 4, ['api', 'repos/acme/widgets/actions/jobs/8801/logs'], - { cwd: '/repo-root', host: 'github.com' } + expect.objectContaining({ + cwd: '/repo-root', + host: 'github.com', + signal: expect.any(AbortSignal) + }) ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(4) }) diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index a2aa0595c74..543d6f239f2 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -225,10 +225,23 @@ describe('GitHub PR local runtime routing', () => { return { stdout: '[]' } } if (endpoint.endsWith('/pulls/7/comments/11/replies')) { - return { stdout: JSON.stringify({ id: 12, user: null, body: 'Reply' }) } + return { + stdout: JSON.stringify({ id: 12, node_id: 'PRRC_reply_12', user: null, body: 'Reply' }) + } } if (endpoint.endsWith('/pulls/7/comments')) { - return { stdout: JSON.stringify({ id: 13, user: null, body: 'Inline' }) } + return { + stdout: JSON.stringify({ id: 13, node_id: 'PRRC_inline_13', user: null, body: 'Inline' }) + } + } + if (args.length === 2 && endpoint === 'repos/acme/orca/pulls/7') { + return { + stdout: JSON.stringify({ + number: 7, + head: { ref: 'feature', sha: 'head-oid' }, + base: { ref: 'main', sha: 'base-oid' } + }) + } } return { stdout: '', stderr: '' } }) @@ -250,7 +263,10 @@ describe('GitHub PR local runtime routing', () => { prRepo, localGitOptions ) - ).resolves.toMatchObject({ ok: true }) + ).resolves.toMatchObject({ + ok: true, + comment: { reactionSubjectId: 'PRRC_reply_12' } + }) await expect( addPRReviewComment({ repoPath: '/repo-root', @@ -263,7 +279,10 @@ describe('GitHub PR local runtime routing', () => { path: 'src/app.ts', line: 10 }) - ).resolves.toMatchObject({ ok: true }) + ).resolves.toMatchObject({ + ok: true, + comment: { reactionSubjectId: 'PRRC_inline_13' } + }) await expect( updatePRTitle('/repo-root', 7, 'New title', null, prRepo, localGitOptions) ).resolves.toBe(true) @@ -457,6 +476,15 @@ describe('GitHub PR local runtime routing', () => { if (endpoint.endsWith('/check-runs/88/annotations?per_page=20')) { return { stdout: '[]' } } + if (args.length === 2 && endpoint === 'repos/team/orca/pulls/7') { + return { + stdout: JSON.stringify({ + number: 7, + head: { ref: 'feature', sha: 'head-sha' }, + base: { ref: 'main', sha: 'base-sha' } + }) + } + } if (query) { return { stdout: JSON.stringify({ data: { repository: {} } }) } } diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 4b5f1375aa8..d139c345b59 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -1,6 +1,7 @@ /* oxlint-disable max-lines -- Why: GitHub client fixtures cover local and SSH repo identity paths in one suite so mocked CLI behavior stays consistent. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' type RateLimitGuardResult = | { blocked: false } @@ -91,7 +92,8 @@ vi.mock('./gh-utils', () => ({ })) vi.mock('../git/runner', () => ({ - gitExecFileAsync: gitExecFileAsyncMock + gitExecFileAsync: gitExecFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock })) vi.mock('../providers/ssh-git-dispatch', () => ({ @@ -102,6 +104,11 @@ vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) +vi.mock('./github-enterprise-repository', async (importOriginal) => ({ + ...(await importOriginal()), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, @@ -178,6 +185,7 @@ import { getPullRequestPushTarget, mergePR, resolveReviewThread, + setPRCommentReaction, setPRAutoMerge, updatePRState, updatePRTitle, @@ -185,6 +193,7 @@ import { _getTrackedUpstreamBranchCacheSizesForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests, + _resetPRStackSummaryCacheForTests, __resetTrackedUpstreamBranchCacheForTests } from './client' import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' @@ -192,6 +201,7 @@ import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-me import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' +import { _resetGitHubPRStackCacheForTests } from './github-pr-stack' // The origin-repository cache is module-level state; reset it so slugs // resolved by one test cannot leak into the next. @@ -272,6 +282,8 @@ describe('getPRForBranch', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + _resetPRStackSummaryCacheForTests() + _resetGitHubPRStackCacheForTests() __resetTrackedUpstreamBranchCacheForTests() __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() @@ -430,7 +442,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ @@ -452,6 +464,81 @@ describe('getPRForBranch', () => { }) }) + it('caches exact REST stack probes across linked PR refreshes', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'open', + stack: null + }) + } + }) + + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99, 'ssh-1') + + expect( + ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'api' && args[1]?.includes('/99') + ) + ).toHaveLength(2) + expect(ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr')).toHaveLength(3) + }) + + it('caches failed REST stack probes across linked PR refreshes', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + throw new Error('GitHub is temporarily unavailable') + }) + + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99) + + expect( + ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'api' && args[1]?.includes('/99') + ) + ).toHaveLength(1) + }) + it('hydrates repository merge method settings for exact PR lookups', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock @@ -517,6 +604,54 @@ describe('getPRForBranch', () => { ) }) + it('isolates viewer-dependent merge metadata across SSH connections', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + let metadataProbe = 0 + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('graphql')) { + metadataProbe += 1 + return { + stdout: JSON.stringify({ + data: { + repository: { + autoMergeAllowed: metadataProbe === 1, + mergeQueue: null + } + } + }) + } + } + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + return { stdout: JSON.stringify({ number: 99, state: 'open', stack: null }) } + }) + + const firstAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-1') + const secondAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-2') + + expect(firstAccount?.autoMergeAllowed).toBe(true) + expect(secondAccount?.autoMergeAllowed).toBe(false) + expect(metadataProbe).toBe(2) + }) + it('treats linked PR metadata as authoritative even when the branch head differs', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' }) @@ -558,7 +693,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(pr?.number).toBe(99) }) @@ -1180,7 +1315,7 @@ describe('getPRForBranch', () => { expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('leaves linked merged divergence unset when the membership probe throws', async () => { @@ -1222,7 +1357,7 @@ describe('getPRForBranch', () => { expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('prefers branch lookup over a fallback PR number', async () => { @@ -2865,7 +3000,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'refs/heads/local-created-from-pr', 77) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', @@ -4014,6 +4149,8 @@ describe('GitHub GraphQL rate-limit guard', () => { __resetPRConflictSummaryCachesForTests() }) + afterEach(() => vi.restoreAllMocks()) + it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { rateLimitGuardMock.mockImplementation(((bucket: string) => bucket === 'graphql' @@ -4043,6 +4180,60 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(noteRateLimitSpendMock).not.toHaveBeenCalledWith('graphql') }) + it('maps review summary reaction subjects from GraphQL', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { nodes: [] }, + comments: { nodes: [] }, + reviews: { + nodes: [ + { + id: 'PRR_44', + databaseId: 44, + author: { + __typename: 'Bot', + login: 'coderabbitai', + avatarUrl: 'https://avatar' + }, + body: 'Automated review summary', + createdAt: '2026-04-01T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/7#pullrequestreview-44', + reactionGroups: [ + { + content: 'ROCKET', + viewerHasReacted: true, + reactors: { totalCount: 2 } + } + ] + } + ] + } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + + await expect(getPRComments('/repo-root', 7)).resolves.toEqual([ + expect.objectContaining({ + id: 44, + reactionSubjectId: 'PRR_44', + isBot: true, + reactions: [{ content: 'rocket', count: 2, viewerHasReacted: true }] + }) + ]) + expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([expect.stringContaining('reviews(first: 100)')]) + ) + }) + it('uses explicit PR repo for comments when a fork PR is discovered', async () => { rateLimitGuardMock.mockImplementation(((bucket: string) => bucket === 'graphql' @@ -4082,8 +4273,774 @@ describe('GitHub GraphQL rate-limit guard', () => { ) }) + it('returns GraphQL reaction subjects and viewer state for PR comments', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + comments: { + nodes: [ + { + id: 'IC_1', + databaseId: 10, + author: { login: 'octo', avatarUrl: '', __typename: 'User' }, + body: 'Issue comment', + createdAt: '2026-04-01T00:00:00Z', + url: 'https://example.test/issue-comment', + reactionGroups: [ + { + content: 'THUMBS_UP', + viewerHasReacted: true, + reactors: { totalCount: 2 } + } + ] + } + ] + }, + reviewThreads: { + nodes: [ + { + id: 'PRRT_1', + isResolved: false, + line: 4, + startLine: null, + originalLine: 4, + originalStartLine: null, + comments: { + nodes: [ + { + id: 'PRRC_1', + databaseId: 11, + author: { login: 'reviewer', avatarUrl: '', __typename: 'User' }, + body: 'Inline comment', + createdAt: '2026-04-01T00:01:00Z', + url: 'https://example.test/review-comment', + path: 'src/app.ts', + reactionGroups: [ + { + content: 'EYES', + viewerHasReacted: false, + reactors: { totalCount: 1 } + } + ] + } + ] + } + } + ] + } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + + const comments = await getPRComments('/repo-root', 7, { + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + + expect(comments).toEqual([ + expect.objectContaining({ + id: 10, + reactionSubjectId: 'IC_1', + reactions: [{ content: '+1', count: 2, viewerHasReacted: true }] + }), + expect.objectContaining({ + id: 11, + reactionSubjectId: 'PRRC_1', + reactions: [{ content: 'eyes', count: 1, viewerHasReacted: false }] + }) + ]) + }) + + it('adds and removes PR comment reactions through GraphQL', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '{}', stderr: '' }) + + await expect(setPRCommentReaction('/repo-root', 'IC_1', '+1', true)).resolves.toBe(true) + await expect(setPRCommentReaction('/repo-root', 'PRRC_1', 'eyes', false)).resolves.toBe(true) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining(['subjectId=IC_1', 'content=THUMBS_UP']), + expect.any(Object) + ) + expect(ghExecFileAsyncMock.mock.calls[0]?.[0].join(' ')).toContain('addReaction') + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining(['subjectId=PRRC_1', 'content=EYES']), + expect.any(Object) + ) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0].join(' ')).toContain('removeReaction') + expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2) + }) + + it('hydrates GitHub-registered stack metadata for exact linked PRs', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca', host: 'github.com' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'stack/models', + headRefName: 'stack/api', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + html_url: 'https://github.com/stablyai/orca/pull/202', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 2, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 1, + pullRequest: { + number: 201, + title: 'Stack models', + url: 'https://github.com/stablyai/orca/pull/201', + state: 'OPEN', + isDraft: false, + mergeable: 'MERGEABLE', + statusCheckRollup: { state: 'SUCCESS' } + } + }, + { + position: 2, + pullRequest: { + number: 202, + title: 'Stack API', + url: 'https://github.com/stablyai/orca/pull/202', + state: 'OPEN', + isDraft: false, + mergeable: 'MERGEABLE', + statusCheckRollup: { state: 'SUCCESS' } + } + } + ] + } + } + } + } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + mergeQueue: null, + ref: { rules: { nodes: [{ type: 'MERGE_QUEUE' }] } } + } + } + }) + }) + + const pr = await getPRForBranch('/repo-root', 'stack/api', 202) + + expect(pr?.stack).toMatchObject({ + number: 51, + position: 2, + size: 2, + baseRefName: 'main', + entries: [ + { number: 201, position: 1 }, + { number: 202, position: 2 } + ] + }) + expect(pr?.mergeQueueRequired).toBe(true) + const mergeQueueMetadataCall = ghExecFileAsyncMock.mock.calls.find( + ([args]) => args.includes('graphql') && args.includes('branch=main') + ) + expect(mergeQueueMetadataCall?.[0]).toEqual(expect.arrayContaining(['-f', 'branch=main'])) + }) + + const validStackHeadSha = 'a'.repeat(40) + const validStackBaseSha = 'b'.repeat(40) + const validSha256HeadSha = 'c'.repeat(64) + + it.each([ + { + objectFormat: 'SHA-1 with a base SHA', + headSha: validStackHeadSha, + baseSha: validStackBaseSha + }, + { + objectFormat: 'SHA-256 without a base SHA', + headSha: validSha256HeadSha, + baseSha: undefined + } + ])('uses async merge only for GitHub-registered stacks using $objectFormat', async (scenario) => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + head: { ref: 'stack/api', sha: scenario.headSha }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { + ref: 'main', + ...(scenario.baseSha ? { sha: scenario.baseSha } : {}) + } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) + }) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + const mergeCall = ghExecFileAsyncMock.mock.calls.find(([args]) => + args.includes('repos/stablyai/orca/pulls/202/merge-async') + ) + expect(mergeCall?.[0]).toEqual( + expect.arrayContaining([ + 'PUT', + 'repos/stablyai/orca/pulls/202/merge-async', + 'merge_action=merge_queue', + `sha=${scenario.headSha}` + ]) + ) + expect(mergeCall?.[0]).not.toContain('merge_method=squash') + expect(acquireMock).toHaveBeenCalledTimes(2) + expect(releaseMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ).toBe(false) + }) + + it('never falls back to legacy merge after an async stack merge transport failure', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + state: 'open', + head: { ref: 'stack/api', sha: validStackHeadSha }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) + }) + .mockRejectedValueOnce(new Error('socket closed after request submission')) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: false, error: 'socket closed after request submission' }) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ).toBe(false) + }) + + const stackMetadataUnavailableError = + 'Could not verify GitHub pull request stack metadata. Refresh and try again.' + + it.each([ + { + failure: 'local transport failure', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: new Error('stack metadata unavailable'), + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'stack metadata unavailable', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'GitHub Enterprise transport failure', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: new Error('enterprise stack metadata unavailable'), + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'enterprise stack metadata unavailable', + expectedOptions: { cwd: '/repo-root', host: 'github.enterprise.test' } + }, + { + failure: 'unparsable probe response over SSH', + repoPath: '/remote/repo-root', + connectionId: 'ssh-1', + probeResponse: { stdout: '' }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'invalid JSON response', + expectedOptions: { host: 'github.com' } + }, + { + failure: 'valid JSON with a non-object payload', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { stdout: 'null' }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'invalid response shape', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'valid JSON with an array payload', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { stdout: '[]' }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'invalid response shape', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'present primitive stack metadata', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: true + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'malformed stack response', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 2, + size: '2', + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'incoherent stack position', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 3, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'non-positive stack number', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 0, + position: 1, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'fractional stack size', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 1, + size: 1.5, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'unsafe stack number', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: Number.MAX_SAFE_INTEGER + 1, + position: 1, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'blank stack base ref', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: ' ', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'invalid stack base SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 123 } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'invalid string stack base SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { sha: validStackHeadSha }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'not-a-git-object-id' } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'malformed stack', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'registered stack response without a head SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { ref: 'stack/api' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'missing head SHA', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'registered stack response with an invalid head SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { ref: 'stack/api', sha: {} }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'missing head SHA', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + }, + { + failure: 'registered stack response with an invalid string head SHA', + repoPath: '/repo-root', + connectionId: undefined, + probeResponse: { + stdout: JSON.stringify({ + number: 202, + head: { ref: 'stack/api', sha: 'not-a-git-object-id' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: validStackBaseSha } + } + }) + }, + expectedError: stackMetadataUnavailableError, + expectedDiagnostic: 'missing head SHA', + expectedOptions: { cwd: '/repo-root', host: 'github.com' } + } + ])('fails closed on $failure', async (scenario) => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + if (scenario.probeResponse instanceof Error) { + ghExecFileAsyncMock.mockRejectedValueOnce(scenario.probeResponse) + } else { + ghExecFileAsyncMock.mockResolvedValueOnce(scenario.probeResponse) + } + // Why: disabling the guard must expose the legacy merge fallthrough, not fail on an unstubbed call. + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'stack/models', + headRefName: 'stack/api', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + const result = await mergePR(scenario.repoPath, 202, 'squash', scenario.connectionId, { + owner: 'stablyai', + repo: 'orca', + host: scenario.expectedOptions.host + }) + + expect.soft(result).toEqual({ ok: false, error: scenario.expectedError }) + expect + .soft(ghExecFileAsyncMock.mock.calls.map(([args]) => args)) + .toEqual([['api', 'repos/stablyai/orca/pulls/202']]) + expect.soft(ghExecFileAsyncMock.mock.calls[0]?.[1]).toEqual(scenario.expectedOptions) + expect + .soft( + ghExecFileAsyncMock.mock.calls.some(([args]) => + args.includes('repos/stablyai/orca/pulls/202/merge-async') + ) + ) + .toBe(false) + expect + .soft( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ) + .toBe(false) + expect.soft(acquireMock).toHaveBeenCalledTimes(1) + expect.soft(releaseMock).toHaveBeenCalledTimes(1) + expect + .soft(consoleWarnSpy) + .toHaveBeenCalledWith( + 'mergePR stack metadata probe failed for stablyai/orca#202:', + scenario.expectedDiagnostic + ) + expect.soft(consoleWarnSpy).toHaveBeenCalledTimes(1) + }) + + it.each([ + { stackShape: 'omits stack', stackField: {} }, + { stackShape: 'sets stack to null', stackField: { stack: null } } + ])('keeps legacy merge when an ordinary GitHub response $stackShape', async (scenario) => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 13866, + title: 'Fail closed on unavailable stack metadata', + state: 'open', + head: { + ref: 'sta-3924-stack-merge-fail-closed', + sha: validStackHeadSha + }, + base: { ref: 'main', sha: validStackBaseSha }, + ...scenario.stackField + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 13866, + title: 'Fail closed on unavailable stack metadata', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/13866', + statusCheckRollup: [], + updatedAt: '2026-08-11T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + baseRefOid: validStackBaseSha, + headRefName: 'sta-3924-stack-merge-fail-closed', + headRefOid: validStackHeadSha + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect( + mergePR('/repo-root', 13866, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['pr', 'merge', '13866', '--squash', '--repo', 'stablyai/orca'], + expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) + ) + expect(acquireMock).toHaveBeenCalledTimes(1) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) + + it('keeps legacy merge for unregistered dependent PR chains', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Dependent API', + state: 'open', + head: { ref: 'feature/api', sha: 'api-sha' }, + base: { ref: 'feature/models', sha: 'models-sha' }, + stack: null + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Dependent API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'feature/models', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['pr', 'merge', '202', '--squash', '--repo', 'stablyai/orca'], + expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) + ) + }) + it('uses explicit PR repo for merge and title mutations', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'open', + head: { ref: 'feature', sha: 'head-oid' }, + base: { ref: 'main', sha: 'base-oid' }, + stack: null + }) + }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 7, @@ -4117,8 +5074,12 @@ describe('GitHub GraphQL rate-limit guard', () => { ).resolves.toBe(true) expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], { + cwd: '/repo-root', + host: 'github.com' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, + 2, [ 'pr', 'view', @@ -4131,7 +5092,7 @@ describe('GitHub GraphQL rate-limit guard', () => { { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', @@ -4140,7 +5101,7 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'], { cwd: '/repo-root', host: 'github.com' } ) @@ -4148,6 +5109,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4168,13 +5130,16 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], { + host: 'github.com' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, + 2, ['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'], { host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, expect.arrayContaining([ 'api', 'graphql', @@ -4191,7 +5156,7 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), @@ -4203,6 +5168,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('enables auto-merge without invoking the direct merge command', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4229,8 +5195,39 @@ describe('GitHub GraphQL rate-limit guard', () => { ).toBe(false) }) + it('rejects auto-merge for GitHub-registered stacks', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + head: { ref: 'stack/api', sha: 'head-oid' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + + await expect( + setPRAutoMerge('/repo-root', 202, true, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ + ok: false, + error: 'GitHub does not support auto-merge for stacked pull requests.' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + it('translates the GitHub clean-status rejection into an actionable message', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4250,6 +5247,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' }) }) @@ -4267,12 +5265,12 @@ describe('GitHub GraphQL rate-limit guard', () => { ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']), { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', @@ -4288,24 +5286,26 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('blocks direct merge when GitHub reports required approval', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: 'REVIEW_REQUIRED', - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'REVIEW_REQUIRED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) }) - }) await expect( mergePR('/repo-root', 7, 'squash', undefined, { @@ -4318,8 +5318,8 @@ describe('GitHub GraphQL rate-limit guard', () => { error: 'This pull request requires review approval before it can be merged.' }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toContain('graphql') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toContain('graphql') }) it('detects merge queues once per base branch and blocks direct merges', async () => { @@ -4340,10 +5340,12 @@ describe('GitHub GraphQL rate-limit guard', () => { headRefOid: 'head-oid' } ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) await expect( @@ -4364,10 +5366,10 @@ describe('GitHub GraphQL rate-limit guard', () => { expect( ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) ).toHaveLength(1) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toEqual( + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toEqual( expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true']) ) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).not.toContain('-F') + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).not.toContain('-F') }) it('caches unknown merge queue probes after GraphQL failures', async () => { @@ -4391,7 +5393,9 @@ describe('GitHub GraphQL rate-limit guard', () => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) .mockRejectedValueOnce(new Error('network is down')) + .mockResolvedValueOnce({ stdout: '{}' }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockResolvedValueOnce({ stdout: '{}' }) await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ mergeQueueRequired: null @@ -4517,21 +5521,23 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'CONFLICTING', - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'CONFLICTING', + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) }) - }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) @@ -4554,11 +5560,12 @@ describe('GitHub GraphQL rate-limit guard', () => { '- src/conflict.ts' }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('does not run merge conflict preflight for SSH-backed repos', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 7, @@ -4584,9 +5591,9 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ).resolves.toEqual({ ok: true }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 07472e5c096..068d3ccc419 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -12,6 +12,7 @@ import type { PRCheckDetail, PRCheckRunDetails, GitHubCommentResult, + GitHubReactionContent, GitHubPRReviewCommentInput, PRComment, GitHubViewer, @@ -19,7 +20,8 @@ import type { GitHubPullRequestStateUpdate, GitHubRerunPRChecksResult, GitHubPRMergeMethod, - GitHubPRMergeMethodSettings + GitHubPRMergeMethodSettings, + GitHubPRStack } from '../../shared/types' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { @@ -110,7 +112,11 @@ import { mapPRState, deriveCheckStatus } from './mappers' -import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions' +import { + mapGraphQLReactionGroups, + toGraphQLReactionContent, + type GitHubGraphQLReactionGroup +} from './comment-reactions' import { getRateLimit, noteRepositoryRateLimitSpend, @@ -118,8 +124,13 @@ import { spendsSharedGitHubComQuota, type RateLimitBucketKind } from './rate-limit' +import { + GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS, + GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE +} from '../../shared/github-check-details-deadline' +import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack' -type GhExecOptions = GitHubRepoExecOptions +type GhExecOptions = GitHubRepoExecOptions & { signal?: AbortSignal } type HostedReviewLocalGitOptions = ReturnType const ORCA_REPO = 'stablyai/orca' @@ -144,6 +155,30 @@ function setPrCheckLogTailCache(cacheKey: string, logTail: string | null): void prCheckLogTailCache.delete(oldestKey) } } + +function rethrowCheckDetailsAbort(signal: AbortSignal | undefined, error: unknown): void { + if (signal?.aborted) { + throw error + } +} + +function waitForCheckDetailsResolution(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason) + } + return new Promise((resolve, reject) => { + const finish = (settle: () => void): void => { + signal.removeEventListener('abort', onAbort) + settle() + } + const onAbort = (): void => finish(() => reject(signal.reason)) + signal.addEventListener('abort', onAbort, { once: true }) + void operation.then( + (value) => finish(() => resolve(value)), + (error) => finish(() => reject(error)) + ) + }) +} const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000 const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000 const MERGE_QUEUE_CACHE_MAX_ENTRIES = 256 @@ -156,11 +191,32 @@ const repositoryMergeMetadataCache = new Map< string, { value: GitHubRepositoryMergeMetadata; expiresAt: number } >() +const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000 +const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512 +const STACK_METADATA_UNAVAILABLE_ERROR = + 'Could not verify GitHub pull request stack metadata. Refresh and try again.' +const prStackSummaryCache = new Map< + string, + { value: GitHubPRStack | undefined; expiresAt: number } +>() +const prStackSummaryInFlight = new Map>() + +function githubPRStackExecutionScope( + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` +} export function _resetMergeQueueCacheForTests(): void { repositoryMergeMetadataCache.clear() } +export function _resetPRStackSummaryCacheForTests(): void { + prStackSummaryCache.clear() + prStackSummaryInFlight.clear() +} + export function _getMergeQueueCacheSizeForTests(): number { return repositoryMergeMetadataCache.size } @@ -1664,7 +1720,8 @@ export async function getRepoSlug( /** * Resolve a fork's upstream/parent owner/repo, or null when not a fork. - * Why: a fork's `origin` is the personal copy, so repo identity (avatar) should prefer upstream. + * Why: drives the fork indicator, and a same-name fork's avatar prefers the + * upstream owner (a renamed fork keeps its own owner). * Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null. */ export async function getRepoUpstream( @@ -2156,6 +2213,8 @@ type PullRequestLookupData = { headRefName?: string baseRefOid?: string headRefOid?: string + stack?: GitHubPRStack + stackMetadataChecked?: boolean } type RestPullRequest = { @@ -2171,6 +2230,12 @@ type RestPullRequest = { mergeable_state?: string | null base?: { ref?: string; sha?: string } head?: { ref?: string; sha?: string } + stack?: { + number?: number + position?: number + size?: number + base?: { ref?: string; sha?: string } + } | null } const PR_LOOKUP_JSON_FIELDS = @@ -2210,6 +2275,19 @@ function derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableSta } function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { + const stack = + typeof pr.stack?.number === 'number' && + typeof pr.stack.position === 'number' && + typeof pr.stack.size === 'number' && + typeof pr.stack.base?.ref === 'string' + ? { + number: pr.stack.number, + position: pr.stack.position, + size: pr.stack.size, + baseRefName: pr.stack.base.ref, + ...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {}) + } + : undefined return { number: pr.number, title: pr.title, @@ -2222,7 +2300,9 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { baseRefName: pr.base?.ref, headRefName: pr.head?.ref, baseRefOid: pr.base?.sha, - headRefOid: pr.head?.sha + headRefOid: pr.head?.sha, + stackMetadataChecked: true, + ...(stack ? { stack } : {}) } } @@ -2292,9 +2372,10 @@ function cacheRepositoryMergeMetadata( async function detectRepositoryMergeMetadata( ownerRepo: GitHubApiRepository, branchName: string | undefined, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope = 'default' ): Promise { - const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` + const cacheKey = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) if (cached) { @@ -2305,7 +2386,7 @@ async function detectRepositoryMergeMetadata( return { mergeQueueRequired: null, autoMergeAllowed: null } } const query = branchName - ? `query($owner: String!, $repo: String!, $branch: String!) { + ? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) { repository(owner: $owner, name: $repo) { viewerDefaultMergeMethod mergeCommitAllowed @@ -2313,6 +2394,9 @@ async function detectRepositoryMergeMetadata( squashMergeAllowed autoMergeAllowed mergeQueue(branch: $branch) { id } + ref(qualifiedName: $qualified) { + rules(first: 50) { nodes { type } } + } } }` : `query($owner: String!, $repo: String!) { @@ -2338,6 +2422,7 @@ async function detectRepositoryMergeMetadata( ] if (branchName) { args.push('-f', `branch=${branchName}`) + args.push('-f', `qualified=refs/heads/${branchName}`) } const { stdout } = await ghExecFileAsync(args, { ...ghOptions, @@ -2352,6 +2437,7 @@ async function detectRepositoryMergeMetadata( squashMergeAllowed?: unknown autoMergeAllowed?: unknown mergeQueue?: { id?: unknown } | null + ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null } | null } } @@ -2365,7 +2451,10 @@ async function detectRepositoryMergeMetadata( }) : undefined const value: GitHubRepositoryMergeMetadata = { - mergeQueueRequired: branchName ? Boolean(repository?.mergeQueue) : null, + mergeQueueRequired: branchName + ? Boolean(repository?.mergeQueue) || + Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE')) + : null, autoMergeAllowed: typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null, ...(mergeMethodSettings ? { mergeMethodSettings } : {}) @@ -2386,13 +2475,19 @@ async function detectRepositoryMergeMetadata( async function hydratePullRequestLookupData( ownerRepo: OwnerRepo, data: PullRequestLookupData, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope: string ): Promise { const normalized = normalizePullRequestLookupData(data) const hasRichMergeFields = 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data const mergeMetadata = hasRichMergeFields - ? await detectRepositoryMergeMetadata(ownerRepo, normalized.baseRefName, ghOptions) + ? await detectRepositoryMergeMetadata( + ownerRepo, + normalized.stack?.baseRefName ?? normalized.baseRefName, + ghOptions, + executionScope + ) : undefined return { ...normalized, @@ -2407,13 +2502,17 @@ async function hydratePullRequestLookupData( async function hydrateBranchLookupWithExactPR( ownerRepo: OwnerRepo, branchData: PullRequestLookupData | null, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope: string ): Promise { if (!branchData) { return null } try { - return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData + return ( + (await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ?? + branchData + ) } catch { return branchData } @@ -2766,6 +2865,7 @@ async function lookupPRByBranchName(args: { headRepo: OwnerRepo | null branchName: string ghOptions: GhExecOptions + executionScope: string }): Promise<{ data: PullRequestLookupData | null dataRepo: OwnerRepo | null @@ -2785,7 +2885,12 @@ async function lookupPRByBranchName(args: { ) : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions) // Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state. - const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) if (data) { return { data, dataRepo: candidate } } @@ -2804,7 +2909,12 @@ async function lookupPRByBranchName(args: { args.branchName, args.ghOptions ) - const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) if (data) { return { data, dataRepo: candidate } } @@ -2839,22 +2949,136 @@ async function lookupPRByBranchName(args: { } } +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + +function isGitObjectId(value: unknown): value is string { + return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) +} + +function isUsableRestStackMetadata(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const stack = value as { + number?: unknown + position?: unknown + size?: unknown + base?: unknown + } + if (!stack.base || typeof stack.base !== 'object' || Array.isArray(stack.base)) { + return false + } + const base = stack.base as { ref?: unknown; sha?: unknown } + return ( + isPositiveSafeInteger(stack.number) && + isPositiveSafeInteger(stack.position) && + isPositiveSafeInteger(stack.size) && + stack.position <= stack.size && + typeof base.ref === 'string' && + base.ref.trim().length > 0 && + (base.sha === undefined || isGitObjectId(base.sha)) + ) +} + async function getRestPRByNumber( ownerRepo: GitHubApiRepository, number: number, - ghOptions: ReturnType -): Promise { + ghOptions: ReturnType, + options: { requireUsableStackMetadata?: boolean } = {} +): Promise { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) - return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest) + const parsed = JSON.parse(stdout) as unknown + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('invalid response shape') + } + const restData = parsed as RestPullRequest + const mapped = mapRestPullRequest(restData) + if ( + options.requireUsableStackMetadata && + restData.stack !== undefined && + restData.stack !== null + ) { + // Why: GitHub omits stack for ordinary PRs; only unusable non-null metadata is unsafe. + if (!isUsableRestStackMetadata(restData.stack) || !mapped.stack) { + throw new Error('malformed stack') + } + if (!isGitObjectId(restData.head?.sha)) { + throw new Error('missing head SHA') + } + } + return mapped +} + +function prunePRStackSummaryCache(now = Date.now()): void { + for (const [key, cached] of prStackSummaryCache) { + if (cached.expiresAt <= now) { + prStackSummaryCache.delete(key) + } + } + while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) { + const oldestKey = prStackSummaryCache.keys().next().value + if (oldestKey === undefined) { + return + } + prStackSummaryCache.delete(oldestKey) + } +} + +async function getCachedGitHubPRStackSummary( + ownerRepo: GitHubApiRepository, + number: number, + ghOptions: ReturnType, + executionScope: string +): Promise { + const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}` + const now = Date.now() + prunePRStackSummaryCache(now) + const cached = prStackSummaryCache.get(key) + if (cached && cached.expiresAt > now) { + return cached.value + } + const existing = prStackSummaryInFlight.get(key) + if (existing) { + return existing + } + const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr.stack) + prStackSummaryInFlight.set(key, request) + try { + const value = await request + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + value, + expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + return value + } catch (err) { + // Why: avoid repeating a failed REST probe on every review poll. + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + value: undefined, + expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + throw err + } finally { + if (prStackSummaryInFlight.get(key) === request) { + prStackSummaryInFlight.delete(key) + } + } } async function getPRByNumber( ownerRepo: GitHubApiRepository, number: number, - ghOptions: ReturnType + ghOptions: ReturnType, + executionScope: string, + knownPullRequestData?: PullRequestLookupData | null ): Promise { try { const { stdout } = await ghExecFileAsync( @@ -2869,10 +3093,16 @@ async function getPRByNumber( ], { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) + const exactData = JSON.parse(stdout) as PullRequestLookupData return hydratePullRequestLookupData( ownerRepo, - JSON.parse(stdout) as PullRequestLookupData, - ghOptions + { + ...knownPullRequestData, + ...exactData, + ...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {}) + }, + ghOptions, + executionScope ) } catch (err) { // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup. @@ -2880,8 +3110,13 @@ async function getPRByNumber( return null } try { - const restData = await getRestPRByNumber(ownerRepo, number, ghOptions) - return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null + const restData = + knownPullRequestData === undefined + ? await getRestPRByNumber(ownerRepo, number, ghOptions) + : knownPullRequestData + return restData + ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope) + : null } catch (restErr) { if (isNotFoundGhError(restErr)) { return null @@ -2898,10 +3133,16 @@ async function lookupPRByNumber(args: { candidates: OwnerRepo[] number: number ghOptions: ReturnType + executionScope: string }): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> { for (const candidate of args.candidates) { try { - const linkedData = await getPRByNumber(candidate, args.number, args.ghOptions) + const linkedData = await getPRByNumber( + candidate, + args.number, + args.ghOptions, + args.executionScope + ) if (!linkedData) { continue } @@ -3000,6 +3241,7 @@ export async function getPRForBranchOutcome( const localGitOptions = localGitArgs[0] ?? {} const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) + const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions) await acquire() try { @@ -3026,6 +3268,7 @@ export async function getPRForBranchOutcome( let pendingBranchLookupError: unknown let hasPendingBranchLookupError = false let currentHeadOidForMergedImplicit: string | null | undefined + let usedExactNumberLookup = false const explicitCurrentHeadOid = typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0 @@ -3098,10 +3341,12 @@ export async function getPRForBranchOutcome( } if (typeof linkedPRNumber === 'number') { + usedExactNumberLookup = true const exactLookup = await lookupPRByNumber({ candidates, number: linkedPRNumber, - ghOptions + ghOptions, + executionScope }) data = exactLookup.data dataRepo = exactLookup.dataRepo @@ -3111,7 +3356,8 @@ export async function getPRForBranchOutcome( candidates, headRepo, branchName, - ghOptions + ghOptions, + executionScope }) data = branchLookup.data dataRepo = branchLookup.dataRepo @@ -3143,7 +3389,8 @@ export async function getPRForBranchOutcome( candidates, headRepo: upstreamHeadRepo, branchName: upstreamBranch.branchName, - ghOptions + ghOptions, + executionScope }) data = upstreamLookup.data dataRepo = upstreamLookup.dataRepo @@ -3166,10 +3413,12 @@ export async function getPRForBranchOutcome( dataHeadRepo = headRepo } if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') { + usedExactNumberLookup = true const fallbackLookup = await lookupPRByNumber({ candidates, number: fallbackPRNumber, - ghOptions + ghOptions, + executionScope }) data = fallbackLookup.data dataRepo = fallbackLookup.dataRepo @@ -3215,7 +3464,42 @@ export async function getPRForBranchOutcome( return { kind: 'no-pr', fetchedAt: Date.now() } } + if (!data.stackMetadataChecked && dataRepo && usedExactNumberLookup) { + try { + data.stack = await getCachedGitHubPRStackSummary( + dataRepo, + data.number, + ghOptions, + executionScope + ) + data.stackMetadataChecked = true + } catch { + // Stack metadata is additive; exact PR lookup remains usable without it. + } + } const mergeable = derivePullRequestMergeable(data) + const stack = + data.stack && dataRepo + ? await hydrateGitHubPRStack( + dataRepo, + data.number, + data.stack, + ghOptions, + data.updatedAt, + executionScope + ) + : data.stack + const stackMergeQueueRequired = + stack && dataRepo + ? ( + await detectRepositoryMergeMetadata( + dataRepo, + stack.baseRefName, + ghOptions, + executionScope + ) + ).mergeQueueRequired + : undefined const conflictSummary = !connectionId && mergeable === 'CONFLICTING' && @@ -3245,13 +3529,19 @@ export async function getPRForBranchOutcome( ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}), ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}), ...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}), - ...(data.mergeQueueRequired !== undefined - ? { mergeQueueRequired: data.mergeQueueRequired } + ...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined + ? { + mergeQueueRequired: + stackMergeQueueRequired !== undefined + ? stackMergeQueueRequired + : data.mergeQueueRequired + } : {}), ...(data.mergeMethodSettings !== undefined ? { mergeMethodSettings: data.mergeMethodSettings } : {}), ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), + ...(stack ? { stack } : {}), headSha: data.headRefOid, ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}), ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}), @@ -3887,6 +4177,7 @@ async function attachFailedJobLogTails( ) job.logTail = sliceCheckLogTail(stdout) } catch (err) { + rethrowCheckDetailsAbort(ghOptions.signal, err) console.warn('getPRCheckDetails workflow job log fetch failed:', err) job.logTail = null } @@ -3919,20 +4210,34 @@ export async function getPRCheckDetails( prRepo?: GitHubApiRepository | null }, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + callerSignal?: AbortSignal ): Promise { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - args.prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return null + const controller = new AbortController() + let hostDeadlineExpired = false + const forwardCallerAbort = (): void => controller.abort(callerSignal?.reason) + if (callerSignal?.aborted) { + forwardCallerAbort() + } else { + callerSignal?.addEventListener('abort', forwardCallerAbort, { once: true }) } - - await acquire() + const hostDeadline = setTimeout(() => { + hostDeadlineExpired = true + controller.abort(new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE)) + }, GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS) + let acquired = false try { + const resolved = await waitForCheckDetailsResolution( + resolveGitHubRepoExecution(repoPath, args.prRepo, connectionId, localGitOptions), + controller.signal + ) + if (!resolved.ownerRepo) { + return null + } + const ownerRepo = resolved.ownerRepo + const ghOptions: GhExecOptions = { ...resolved.ghOptions, signal: controller.signal } + await acquire(controller.signal) + acquired = true let checkRun: Record | null = null let annotations: PRCheckRunDetails['annotations'] = [] if (args.checkRunId) { @@ -3951,6 +4256,7 @@ export async function getPRCheckDetails( ) annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout)) } catch (err) { + rethrowCheckDetailsAbort(controller.signal, err) console.warn('getPRCheckDetails annotations fetch failed:', err) } } @@ -3969,6 +4275,7 @@ export async function getPRCheckDetails( jobs = mapWorkflowJobs(JSON.parse(stdout), args.checkName) await attachFailedJobLogTails(jobs, ownerRepo, ghOptions) } catch (err) { + rethrowCheckDetailsAbort(controller.signal, err) console.warn('getPRCheckDetails workflow jobs fetch failed:', err) } } @@ -3993,9 +4300,16 @@ export async function getPRCheckDetails( } } catch (err) { console.warn('getPRCheckDetails failed:', err) - return null + if (hostDeadlineExpired && !callerSignal?.aborted) { + throw new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE) + } + throw err } finally { - release() + clearTimeout(hostDeadline) + callerSignal?.removeEventListener('abort', forwardCallerAbort) + if (acquired) { + release() + } } } @@ -4117,6 +4431,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { originalStartLine comments(first: 100) { nodes { + id databaseId author { __typename login avatarUrl(size: 48) } body @@ -4125,6 +4440,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { path reactionGroups { content + viewerHasReacted reactors { totalCount } @@ -4135,6 +4451,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { } comments(first: 100) { nodes { + id databaseId author { __typename login avatarUrl(size: 48) } body @@ -4142,6 +4459,24 @@ query($owner: String!, $repo: String!, $pr: Int!) { url reactionGroups { content + viewerHasReacted + reactors { + totalCount + } + } + } + } + reviews(first: 100) { + nodes { + id + databaseId + author { __typename login avatarUrl(size: 48) } + body + createdAt + url + reactionGroups { + content + viewerHasReacted reactors { totalCount } @@ -4255,6 +4590,7 @@ export async function getPRComments( originalStartLine: number | null comments: { nodes: { + id: string databaseId: number author: { __typename?: string; login: string; avatarUrl: string } | null body: string @@ -4266,6 +4602,7 @@ export async function getPRComments( } } type GQLIssueComment = { + id: string databaseId: number author: { __typename?: string; login: string; avatarUrl: string } | null body: string @@ -4273,6 +4610,7 @@ export async function getPRComments( url: string reactionGroups?: GitHubGraphQLReactionGroup[] | null } + let graphQLReviewSummaries: PRComment[] | undefined const reviewComments: PRComment[] = [] if (threadsResult.status === 'fulfilled' && threadsResult.value) { const threadsData = JSON.parse(threadsResult.value.stdout) as { @@ -4281,6 +4619,7 @@ export async function getPRComments( pullRequest: { reviewThreads: { nodes: GQLThread[] } comments?: { nodes: GQLIssueComment[] } + reviews?: { nodes: GQLIssueComment[] } } } } @@ -4295,12 +4634,28 @@ export async function getPRComments( createdAt: c.createdAt, url: c.url, isBot: c.author?.__typename === 'Bot', + reactionSubjectId: c.id, reactions: mapGraphQLReactionGroups(c.reactionGroups) }) ) if (graphQLIssueComments.length > 0) { issueComments = graphQLIssueComments } + graphQLReviewSummaries = (pullRequest.reviews?.nodes ?? []) + .filter((review) => review.body?.trim()) + .map( + (review): PRComment => ({ + id: review.databaseId, + author: review.author?.login ?? 'ghost', + authorAvatarUrl: review.author?.avatarUrl ?? '', + body: review.body, + createdAt: review.createdAt, + url: review.url, + isBot: review.author?.__typename === 'Bot', + reactionSubjectId: review.id, + reactions: mapGraphQLReactionGroups(review.reactionGroups) + }) + ) const threads = pullRequest.reviewThreads.nodes for (const thread of threads) { @@ -4313,6 +4668,7 @@ export async function getPRComments( createdAt: c.createdAt, url: c.url, isBot: c.author?.__typename === 'Bot', + reactionSubjectId: c.id, reactions: mapGraphQLReactionGroups(c.reactionGroups), path: c.path, threadId: thread.id, @@ -4340,7 +4696,9 @@ export async function getPRComments( html_url: string } let reviewSummaries: PRComment[] = [] - if (reviewsResult.status === 'fulfilled') { + if (graphQLReviewSummaries) { + reviewSummaries = graphQLReviewSummaries + } else if (reviewsResult.status === 'fulfilled') { reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[]) .filter((r) => r.body?.trim()) .map( @@ -4393,6 +4751,62 @@ export async function getPRComments( } } +export async function setPRCommentReaction( + repoPath: string, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const mutation = reacted ? 'addReaction' : 'removeReaction' + const query = `mutation($subjectId: ID!, $content: ReactionContent!) { + ${mutation}(input: { subjectId: $subjectId, content: $content }) { + subject { id } + } + }` + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) + if (guard.blocked) { + console.warn( + `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` + ) + return false + } + await acquire() + try { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `subjectId=${reactionSubjectId}`, + '-f', + `content=${toGraphQLReactionContent(content)}` + ], + ghOptions + ) + return true + } catch (err) { + console.warn(`${mutation} failed:`, err) + return false + } finally { + release() + } +} + /** * Mark or unmark a PR file as viewed via GitHub's GraphQL API. */ @@ -4492,6 +4906,7 @@ export async function resolveReviewThread( function mapReviewCommentResponse( data: { id?: number + node_id?: string | null user: { login: string; avatar_url: string; type?: string } | null body?: string created_at?: string @@ -4507,6 +4922,7 @@ function mapReviewCommentResponse( ): PRComment { return { id: data.id ?? Date.now(), + reactionSubjectId: data.node_id?.trim() || undefined, author: data.user?.login ?? 'You', authorAvatarUrl: data.user?.avatar_url ?? '', body: data.body ?? body, @@ -4652,7 +5068,44 @@ export async function mergePR( return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } await acquire() + let concurrencySlotHeld = true try { + let restData: PullRequestLookupData + try { + restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions, { + requireUsableStackMetadata: true + }) + } catch (err) { + const diagnostic = + err instanceof SyntaxError + ? 'invalid JSON response' + : err instanceof Error + ? err.message + : String(err) + console.warn( + `mergePR stack metadata probe failed for ${ownerRepo.owner}/${ownerRepo.repo}#${String(prNumber)}:`, + diagnostic + ) + return { ok: false, error: STACK_METADATA_UNAVAILABLE_ERROR } + } + if (restData.stack) { + const mergeMetadata = await detectRepositoryMergeMetadata( + ownerRepo, + restData.stack.baseRefName, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) + release() + concurrencySlotHeld = false + return await mergeGitHubPRStack({ + repository: ownerRepo, + prNumber, + method, + mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge', + headSha: restData.headRefOid, + ghOptions + }) + } const mergeBlocker = await getPRMergeBlocker( repoPath, prNumber, @@ -4680,7 +5133,9 @@ export async function mergePR( err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' return { ok: false, error: message } } finally { - release() + if (concurrencySlotHeld) { + release() + } } } @@ -4791,11 +5246,25 @@ async function enablePRAutoMerge( ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise<{ ok: true } | { ok: false; error: string }> { + if (ownerRepo) { + try { + const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) + if (restData.stack) { + return { + ok: false, + error: 'GitHub does not support auto-merge for stacked pull requests.' + } + } + } catch { + // GitHub remains authoritative when stack metadata cannot be read. + } + } const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions) if (!pr?.id) { return { ok: false, error: 'Could not resolve GitHub pull request ID' } } - if (await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)) { + const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions) + if (useMergeQueue) { await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions) return { ok: true } } @@ -4842,7 +5311,12 @@ async function getPRMergeBlocker( } try { - const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions) + const pr = await getPRByNumber( + ownerRepo, + prNumber, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) if (!pr) { return null } diff --git a/src/main/github/comment-reactions.test.ts b/src/main/github/comment-reactions.test.ts index 4478214076f..4c0366fda3f 100644 --- a/src/main/github/comment-reactions.test.ts +++ b/src/main/github/comment-reactions.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from 'vitest' -import { mapGraphQLReactionGroups } from './comment-reactions' +import { mapGraphQLReactionGroups, toGraphQLReactionContent } from './comment-reactions' describe('mapGraphQLReactionGroups', () => { it('normalizes GraphQL reaction groups into GitHub comment reactions', () => { expect( mapGraphQLReactionGroups([ - { content: 'EYES', reactors: { totalCount: 1 } }, - { content: 'THUMBS_UP', reactors: { totalCount: 2 } }, + { content: 'EYES', reactors: { totalCount: 1 }, viewerHasReacted: false }, + { content: 'THUMBS_UP', reactors: { totalCount: 2 }, viewerHasReacted: true }, { content: 'HEART', reactors: { totalCount: 0 } }, { content: 'UNKNOWN', reactors: { totalCount: 9 } } ]) ).toEqual([ - { content: '+1', count: 2 }, - { content: 'eyes', count: 1 } + { content: '+1', count: 2, viewerHasReacted: true }, + { content: 'eyes', count: 1, viewerHasReacted: false } ]) }) @@ -22,4 +22,9 @@ describe('mapGraphQLReactionGroups', () => { undefined ) }) + + it('maps REST reaction content to the GraphQL enum', () => { + expect(toGraphQLReactionContent('+1')).toBe('THUMBS_UP') + expect(toGraphQLReactionContent('hooray')).toBe('HOORAY') + }) }) diff --git a/src/main/github/comment-reactions.ts b/src/main/github/comment-reactions.ts index d93ae551c26..1bb831f30ca 100644 --- a/src/main/github/comment-reactions.ts +++ b/src/main/github/comment-reactions.ts @@ -13,6 +13,7 @@ type GitHubGraphQLReactionContent = export type GitHubGraphQLReactionGroup = { content?: string | null reactors?: { totalCount?: number | null } | null + viewerHasReacted?: boolean | null } const GRAPHQL_REACTION_CONTENT: Record = { @@ -37,10 +38,27 @@ const REACTION_ORDER: GitHubReactionContent[] = [ 'eyes' ] +const REACTION_CONTENT_TO_GRAPHQL: Record = { + '+1': 'THUMBS_UP', + '-1': 'THUMBS_DOWN', + laugh: 'LAUGH', + confused: 'CONFUSED', + heart: 'HEART', + hooray: 'HOORAY', + rocket: 'ROCKET', + eyes: 'EYES' +} + +export function toGraphQLReactionContent( + content: GitHubReactionContent +): GitHubGraphQLReactionContent { + return REACTION_CONTENT_TO_GRAPHQL[content] +} + export function mapGraphQLReactionGroups( groups?: GitHubGraphQLReactionGroup[] | null ): GitHubReaction[] | undefined { - const counts = new Map() + const reactionsByContent = new Map() for (const group of groups ?? []) { const content = group.content && group.content in GRAPHQL_REACTION_CONTENT @@ -50,12 +68,20 @@ export function mapGraphQLReactionGroups( if (!content || count <= 0) { continue } - counts.set(content, (counts.get(content) ?? 0) + count) + const existing = reactionsByContent.get(content) + const viewerHasReacted = Boolean(existing?.viewerHasReacted || group.viewerHasReacted) + reactionsByContent.set(content, { + content, + count: (existing?.count ?? 0) + count, + ...(existing?.viewerHasReacted !== undefined || group.viewerHasReacted != null + ? { viewerHasReacted } + : {}) + }) } const reactions = REACTION_ORDER.flatMap((content) => { - const count = counts.get(content) ?? 0 - return count > 0 ? [{ content, count }] : [] + const reaction = reactionsByContent.get(content) + return reaction ? [reaction] : [] }) return reactions.length > 0 ? reactions : undefined } diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index 73ec657977c..a8c52c46085 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -6,7 +6,7 @@ import { import { gitExecFileAsync } from '../git/runner' import { clearGitCapabilityStateForTests, - getLocalGitCapabilityCache + withLocalGitCapabilityCacheForExecution } from '../git/git-capability-state' import { __resetPRConflictSummaryDerivationCachesForTests, @@ -219,10 +219,6 @@ async function loadConflictingFiles( baseOid: string, localGitOptions: LocalGitExecOptions ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: localGitOptions.wslDistro - }) const modernArgs = [ 'merge-tree', '--write-tree', @@ -244,40 +240,44 @@ async function loadConflictingFiles( baseOid ] - return capabilities.runWithFallback( - 'merge-tree-write-tree', - () => + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: localGitOptions.wslDistro }, + (capabilities) => capabilities.runWithFallback( - 'merge-tree-merge-base', + 'merge-tree-write-tree', + () => + capabilities.runWithFallback( + 'merge-tree-merge-base', + async () => { + try { + const result = await gitExecFileAsync(modernArgs, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) + return parseMergeTreeNameOnlyOutput(result.stdout) + } catch (error) { + if (isUnsupportedMergeTreeWriteTreeError(error)) { + throw error + } + // Why: `git merge-tree --write-tree` exits 1 for conflicts but still + // writes the useful file list; only option rejection reaches fallback. + const stdoutFromError = getGitErrorOutput(error, 'stdout') + if (stdoutFromError) { + return parseMergeTreeNameOnlyOutput(stdoutFromError) + } + throw error + } + }, + () => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions), + isUnsupportedMergeTreeMergeBaseError + ), async () => { - try { - const result = await gitExecFileAsync(modernArgs, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - return parseMergeTreeNameOnlyOutput(result.stdout) - } catch (error) { - if (isUnsupportedMergeTreeWriteTreeError(error)) { - throw error - } - // Why: `git merge-tree --write-tree` exits 1 for conflicts but still - // writes the useful file list; only option rejection reaches fallback. - const stdoutFromError = getGitErrorOutput(error, 'stdout') - if (stdoutFromError) { - return parseMergeTreeNameOnlyOutput(stdoutFromError) - } - throw error - } + // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; + // fail closed without respawning the same rejected command every refresh. + throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') }, - () => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions), - isUnsupportedMergeTreeMergeBaseError - ), - async () => { - // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; - // fail closed without respawning the same rejected command every refresh. - throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') - }, - isUnsupportedMergeTreeWriteTreeError + isUnsupportedMergeTreeWriteTreeError + ) ) } diff --git a/src/main/github/gh-utils-concurrency.test.ts b/src/main/github/gh-utils-concurrency.test.ts new file mode 100644 index 00000000000..de6389d260a --- /dev/null +++ b/src/main/github/gh-utils-concurrency.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { acquire, release } from './gh-utils' + +describe('GitHub concurrency', () => { + it('removes aborted waiters without consuming a permit', async () => { + await Promise.all([acquire(), acquire(), acquire(), acquire()]) + const controller = new AbortController() + const queued = acquire(controller.signal) + + controller.abort() + + await expect(queued).rejects.toMatchObject({ name: 'AbortError' }) + release() + await expect(acquire()).resolves.toBeUndefined() + + release() + release() + release() + release() + }) +}) diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index d6770189924..ffeb2ca22c7 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -40,25 +40,52 @@ export type { PRRepositoryCandidates, ResolvedIssueSource } from './github-owner const MAX_CONCURRENT = 4 let running = 0 -const queue: (() => void)[] = [] +type QueueEntry = { + signal?: AbortSignal + start: () => void + reject: (error: Error) => void +} +const queue: QueueEntry[] = [] -export function acquire(): Promise { +function githubOperationAbortError(): Error { + const error = new Error('GitHub operation aborted') + error.name = 'AbortError' + return error +} + +export function acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(githubOperationAbortError()) + } if (running < MAX_CONCURRENT) { running += 1 return Promise.resolve() } - return new Promise((resolve) => - queue.push(() => { - running += 1 - resolve() - }) - ) + return new Promise((resolve, reject) => { + const entry: QueueEntry = { + signal, + reject, + start: () => { + signal?.removeEventListener('abort', onAbort) + running += 1 + resolve() + } + } + const onAbort = (): void => { + const index = queue.indexOf(entry) + if (index === -1) { + return + } + queue.splice(index, 1) + reject(githubOperationAbortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + queue.push(entry) + }) } export function release(): void { running -= 1 const next = queue.shift() - if (next) { - next() - } + next?.start() } diff --git a/src/main/github/github-pr-stack-async-merge.ts b/src/main/github/github-pr-stack-async-merge.ts new file mode 100644 index 00000000000..1b3909af035 --- /dev/null +++ b/src/main/github/github-pr-stack-async-merge.ts @@ -0,0 +1,145 @@ +import type { GitHubPRMergeMethod } from '../../shared/types' +import { ghExecFileAsync } from '../git/runner' +import { acquire, release } from './gh-utils' +import { + githubHostExecOptions, + type GitHubApiRepository, + type GitHubRepoExecOptions +} from './github-api-repository' + +const POLL_INTERVAL_MS = 1_000 +const MAX_POLLS = 180 + +type AsyncMergeResponse = { + status?: unknown + details?: { + message?: unknown + uuid?: unknown + } +} + +type AsyncMergeResult = + | { kind: 'pending'; uuid: string } + | { kind: 'success' } + | { kind: 'failure'; message: string } + +export type GitHubPRStackMergeAction = 'direct_merge' | 'merge_queue' + +function parseResponse(value: string): AsyncMergeResponse | null { + try { + return JSON.parse(value) as AsyncMergeResponse + } catch { + return null + } +} + +function errorResponseBody(error: unknown): AsyncMergeResponse | null { + if (!error || typeof error !== 'object' || !('stdout' in error)) { + return null + } + const stdout = (error as { stdout?: unknown }).stdout + return parseResponse(Buffer.isBuffer(stdout) ? stdout.toString('utf8') : String(stdout)) +} + +function classifyResult(response: AsyncMergeResponse): AsyncMergeResult { + if (response.status === 'merged' || response.status === 'enqueued') { + return { kind: 'success' } + } + if (response.status === 'pending' && typeof response.details?.uuid === 'string') { + return { kind: 'pending', uuid: response.details.uuid } + } + const message = + typeof response.details?.message === 'string' + ? response.details.message + : 'GitHub could not merge this stack.' + return { kind: 'failure', message } +} + +function waitForNextPoll(): Promise { + return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) +} + +async function runStackMergeCommand( + command: string[], + options: NonNullable[1]> +) { + await acquire() + try { + return await ghExecFileAsync(command, options) + } finally { + release() + } +} + +export async function mergeGitHubPRStack(args: { + repository: GitHubApiRepository + prNumber: number + method: GitHubPRMergeMethod + mergeAction: GitHubPRStackMergeAction + headSha?: string + ghOptions: GitHubRepoExecOptions +}): Promise<{ ok: true } | { ok: false; error: string }> { + const endpoint = `repos/${args.repository.owner}/${args.repository.repo}/pulls/${args.prNumber}/merge-async` + const command = ['api', '-X', 'PUT', endpoint, '-f', `merge_action=${args.mergeAction}`] + if (args.mergeAction === 'direct_merge') { + command.push('-f', `merge_method=${args.method}`) + } + if (args.headSha) { + command.push('-f', `sha=${args.headSha}`) + } + + let submitted: AsyncMergeResponse | null + try { + const { stdout } = await runStackMergeCommand(command, { + ...args.ghOptions, + ...githubHostExecOptions(args.repository), + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + submitted = parseResponse(stdout) + } catch (error) { + submitted = errorResponseBody(error) + if (!submitted) { + throw error + } + } + if (!submitted) { + return { ok: false, error: 'GitHub returned an invalid stack merge response.' } + } + let result = classifyResult(submitted) + if (result.kind === 'success') { + return { ok: true } + } + if (result.kind === 'failure') { + return { ok: false, error: result.message } + } + + const uuid = encodeURIComponent(result.uuid) + for (let poll = 0; poll < MAX_POLLS; poll++) { + await waitForNextPoll() + let response: AsyncMergeResponse | null + try { + const { stdout } = await runStackMergeCommand(['api', `${endpoint}/${uuid}`], { + ...args.ghOptions, + ...githubHostExecOptions(args.repository) + }) + response = parseResponse(stdout) + } catch (error) { + // Why: the merge was submitted; a transient poll failure cannot make it fail. + response = errorResponseBody(error) + if (!response) { + continue + } + } + if (!response) { + return { ok: false, error: 'GitHub returned an invalid stack merge response.' } + } + result = classifyResult(response) + if (result.kind === 'success') { + return { ok: true } + } + if (result.kind === 'failure') { + return { ok: false, error: result.message } + } + } + return { ok: false, error: 'GitHub is still merging this stack. Refresh to check its status.' } +} diff --git a/src/main/github/github-pr-stack.test.ts b/src/main/github/github-pr-stack.test.ts new file mode 100644 index 00000000000..17cd6475941 --- /dev/null +++ b/src/main/github/github-pr-stack.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubPRStack } from '../../shared/types' + +const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + rateLimitGuardMock: vi.fn(() => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: rateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRateLimitSpendMock +})) + +import { + _resetGitHubPRStackCacheForTests, + hydrateGitHubPRStack, + mergeGitHubPRStack +} from './github-pr-stack' + +const repository = { owner: 'stablyai', repo: 'orca', host: 'github.com' } +const summary: GitHubPRStack = { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + baseSha: 'base-sha' +} + +beforeEach(() => { + vi.useRealTimers() + ghExecFileAsyncMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + _resetGitHubPRStackCacheForTests() +}) + +describe('hydrateGitHubPRStack', () => { + it('maps and caches every entry in one GraphQL request', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 3, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 2, + pullRequest: { + number: 202, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/202', + updatedAt: '2026-08-10T00:00:00Z', + state: 'OPEN', + isDraft: false, + headRefName: 'stack/api', + headRefOid: 'api-sha', + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + statusCheckRollup: { state: 'SUCCESS' } + } + }, + { + position: 1, + pullRequest: { + number: 201, + title: 'Models', + url: 'https://github.com/stablyai/orca/pull/201', + updatedAt: '2026-08-10T00:00:00Z', + state: 'OPEN', + isDraft: false, + mergeable: 'UNKNOWN', + statusCheckRollup: { state: 'PENDING' } + } + } + ] + } + } + } + } + } + }) + }) + + const first = await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }) + const sibling = await hydrateGitHubPRStack( + repository, + 201, + { ...summary, position: 1 }, + { cwd: '/other-worktree' } + ) + + expect(first).toMatchObject({ + number: 51, + position: 2, + baseSha: 'base-sha', + entries: [ + { number: 201, position: 1, checksStatus: 'pending' }, + { + number: 202, + position: 2, + checksStatus: 'success', + reviewDecision: 'APPROVED' + } + ] + }) + expect(sibling.position).toBe(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(noteRateLimitSpendMock).toHaveBeenCalledWith( + repository, + 'graphql', + 1, + expect.any(Object) + ) + }) + + it('refreshes cached stack details when the current PR changed', async () => { + const response = (updatedAt: string, isDraft: boolean) => ({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 3, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 2, + pullRequest: { + number: 202, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/202', + updatedAt, + state: 'OPEN', + isDraft, + mergeable: 'MERGEABLE' + } + } + ] + } + } + } + } + } + }) + }) + ghExecFileAsyncMock + .mockResolvedValueOnce(response('2026-08-10T00:00:00Z', true)) + .mockResolvedValueOnce(response('2026-08-10T00:01:00Z', false)) + + const draft = await hydrateGitHubPRStack( + repository, + 202, + summary, + { cwd: '/repo' }, + '2026-08-10T00:00:00Z' + ) + const ready = await hydrateGitHubPRStack( + repository, + 202, + summary, + { cwd: '/repo' }, + '2026-08-10T00:01:00Z' + ) + + expect(draft.entries?.[0]?.state).toBe('draft') + expect(ready.entries?.[0]?.state).toBe('open') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('keeps the REST summary when GraphQL is unavailable', async () => { + ghExecFileAsyncMock.mockRejectedValue(new Error('field is unavailable')) + + await expect(hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' })).resolves.toEqual( + summary + ) + }) + + it('isolates cached details across execution scopes', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { number: 51, size: 3, baseRefName: 'main', entries: { nodes: [] } } + } + } + } + }) + }) + + await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }, undefined, 'local:host') + await hydrateGitHubPRStack(repository, 202, summary, {}, undefined, 'ssh:ssh-1') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('mergeGitHubPRStack', () => { + it('submits the expected head and polls pending merges to completion', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'merged', details: { message: 'Merged' } }) + }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + headSha: 'api-sha', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(result).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + 'PUT', + 'repos/stablyai/orca/pulls/202/merge-async', + 'merge_method=squash', + 'merge_action=direct_merge', + 'sha=api-sha' + ]), + expect.objectContaining({ cwd: '/repo', host: 'github.com' }) + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['api', 'repos/stablyai/orca/pulls/202/merge-async/merge-uuid'], + expect.objectContaining({ cwd: '/repo', host: 'github.com' }) + ) + }) + + it('returns GitHub atomic failure details', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + status: 'failed', + details: { message: 'A pull request has merge conflicts.' } + }) + }) + + await expect( + mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'rebase', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + ).resolves.toEqual({ ok: false, error: 'A pull request has merge conflicts.' }) + }) + + it('omits the unsupported merge method when queueing a stack', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) + }) + + await expect( + mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'merge_queue', + ghOptions: { cwd: '/repo' } + }) + ).resolves.toEqual({ ok: true }) + + const command = ghExecFileAsyncMock.mock.calls[0]?.[0] + expect(command).toContain('merge_action=merge_queue') + expect(command).not.toContain('merge_method=squash') + }) + + it('continues polling after a transient transport failure', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + .mockRejectedValueOnce(new Error('temporary gateway failure')) + .mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'merged' }) }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(1_000) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(result).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + }) + + it('reports an in-progress merge after polling is exhausted', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(180_000) + + await expect(result).resolves.toEqual({ + ok: false, + error: 'GitHub is still merging this stack. Refresh to check its status.' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(181) + }) +}) diff --git a/src/main/github/github-pr-stack.ts b/src/main/github/github-pr-stack.ts new file mode 100644 index 00000000000..24d0773a91c --- /dev/null +++ b/src/main/github/github-pr-stack.ts @@ -0,0 +1,282 @@ +import type { + CheckStatus, + GitHubPRStack, + GitHubPRStackEntry, + PRMergeableState, + PRReviewDecision, + PRState +} from '../../shared/types' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { ghExecFileAsync } from '../git/runner' +import { + githubHostExecOptions, + type GitHubApiRepository, + type GitHubRepoExecOptions +} from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const STACK_CACHE_TTL_MS = 30_000 +const STACK_CACHE_MAX_ENTRIES = 256 + +type CachedStackDetails = { + value: Omit | null + expiresAt: number +} + +type GraphQLStackEntry = { + position?: unknown + pullRequest?: { + number?: unknown + title?: unknown + url?: unknown + updatedAt?: unknown + state?: unknown + isDraft?: unknown + headRefName?: unknown + headRefOid?: unknown + mergeable?: unknown + reviewDecision?: unknown + mergeStateStatus?: unknown + statusCheckRollup?: { state?: unknown } | null + } | null +} + +type GraphQLStackResponse = { + data?: { + repository?: { + pullRequest?: { + stack?: { + number?: unknown + size?: unknown + baseRefName?: unknown + entries?: { nodes?: (GraphQLStackEntry | null)[] | null } | null + } | null + } | null + } | null + } +} + +const stackDetailsCache = new Map() +const stackDetailsInFlight = new Map | null>>() + +export function _resetGitHubPRStackCacheForTests(): void { + stackDetailsCache.clear() + stackDetailsInFlight.clear() +} + +function stackCacheKey( + repository: GitHubApiRepository, + stackNumber: number, + executionScope: string +): string { + return `${executionScope}\0${githubRepoIdentityKey(repository)}:${stackNumber}` +} + +function pruneStackCache(now = Date.now()): void { + for (const [key, cached] of stackDetailsCache) { + if (cached.expiresAt <= now) { + stackDetailsCache.delete(key) + } + } + while (stackDetailsCache.size > STACK_CACHE_MAX_ENTRIES) { + const oldestKey = stackDetailsCache.keys().next().value + if (oldestKey === undefined) { + return + } + stackDetailsCache.delete(oldestKey) + } +} + +function mapStackPRState(value: unknown, isDraft: unknown): PRState { + if (value === 'MERGED') { + return 'merged' + } + if (value === 'CLOSED') { + return 'closed' + } + return isDraft === true ? 'draft' : 'open' +} + +function mapStackCheckStatus(value: unknown): CheckStatus { + if (value === 'SUCCESS') { + return 'success' + } + if (value === 'FAILURE' || value === 'ERROR') { + return 'failure' + } + if (value === 'PENDING' || value === 'EXPECTED') { + return 'pending' + } + return 'neutral' +} + +function mapStackMergeable(value: unknown): PRMergeableState { + return value === 'MERGEABLE' || value === 'CONFLICTING' ? value : 'UNKNOWN' +} + +function mapReviewDecision(value: unknown): PRReviewDecision | null | undefined { + if (value === null) { + return null + } + if (value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED') { + return value + } + return undefined +} + +function mapStackEntry(entry: GraphQLStackEntry): GitHubPRStackEntry | null { + const pr = entry.pullRequest + if ( + typeof entry.position !== 'number' || + typeof pr?.number !== 'number' || + typeof pr.title !== 'string' || + typeof pr.url !== 'string' + ) { + return null + } + const reviewDecision = mapReviewDecision(pr.reviewDecision) + return { + position: entry.position, + number: pr.number, + title: pr.title, + url: pr.url, + ...(typeof pr.updatedAt === 'string' ? { updatedAt: pr.updatedAt } : {}), + state: mapStackPRState(pr.state, pr.isDraft), + checksStatus: mapStackCheckStatus(pr.statusCheckRollup?.state), + mergeable: mapStackMergeable(pr.mergeable), + ...(reviewDecision !== undefined ? { reviewDecision } : {}), + ...(typeof pr.mergeStateStatus === 'string' ? { mergeStateStatus: pr.mergeStateStatus } : {}), + ...(typeof pr.headRefName === 'string' ? { headRefName: pr.headRefName } : {}), + ...(typeof pr.headRefOid === 'string' ? { headSha: pr.headRefOid } : {}) + } +} + +function parseStackDetails( + response: GraphQLStackResponse, + expectedStackNumber: number, + fallbackBaseRefName: string, + fallbackSize: number +): Omit | null { + const stack = response.data?.repository?.pullRequest?.stack + if (!stack || stack.number !== expectedStackNumber) { + return null + } + const entries = (stack.entries?.nodes ?? []) + .flatMap((entry) => (entry ? [mapStackEntry(entry)] : [])) + .filter((entry): entry is GitHubPRStackEntry => entry !== null) + .sort((a, b) => a.position - b.position) + return { + number: expectedStackNumber, + size: typeof stack.size === 'number' ? stack.size : fallbackSize, + baseRefName: typeof stack.baseRefName === 'string' ? stack.baseRefName : fallbackBaseRefName, + ...(entries.length > 0 ? { entries } : {}) + } +} + +const STACK_DETAILS_QUERY = ` +query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + stack { + number + size + baseRefName + entries(first: 100) { + nodes { + position + pullRequest { + number + title + url + updatedAt + state + isDraft + headRefName + headRefOid + mergeable + reviewDecision + mergeStateStatus + statusCheckRollup { state } + } + } + } + } + } + } +}` + +async function fetchStackDetails( + repository: GitHubApiRepository, + prNumber: number, + summary: GitHubPRStack, + ghOptions: GitHubRepoExecOptions +): Promise | null> { + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${STACK_DETAILS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `pr=${prNumber}` + ], + { ...ghOptions, ...githubHostExecOptions(repository) } + ) + return parseStackDetails( + JSON.parse(stdout) as GraphQLStackResponse, + summary.number, + summary.baseRefName, + summary.size + ) +} + +export async function hydrateGitHubPRStack( + repository: GitHubApiRepository, + prNumber: number, + summary: GitHubPRStack, + ghOptions: GitHubRepoExecOptions, + prUpdatedAt?: string, + executionScope = 'local:host' +): Promise { + const key = stackCacheKey(repository, summary.number, executionScope) + const now = Date.now() + pruneStackCache(now) + const cached = stackDetailsCache.get(key) + const cachedPRUpdatedAt = cached?.value?.entries?.find( + (entry) => entry.number === prNumber + )?.updatedAt + const cachedMatchesPR = !prUpdatedAt || !cachedPRUpdatedAt || cachedPRUpdatedAt === prUpdatedAt + if (cached && cached.expiresAt > now && cachedMatchesPR) { + return cached.value + ? { ...cached.value, position: summary.position, baseSha: summary.baseSha } + : summary + } + const existing = stackDetailsInFlight.get(key) + if (existing) { + const value = await existing + return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary + } + const request = fetchStackDetails(repository, prNumber, summary, ghOptions).catch(() => null) + stackDetailsInFlight.set(key, request) + try { + const value = await request + stackDetailsCache.delete(key) + stackDetailsCache.set(key, { value, expiresAt: Date.now() + STACK_CACHE_TTL_MS }) + pruneStackCache() + return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary + } finally { + if (stackDetailsInFlight.get(key) === request) { + stackDetailsInFlight.delete(key) + } + } +} + +export { mergeGitHubPRStack } from './github-pr-stack-async-merge' diff --git a/src/main/github/github-stack-api-responses.ts b/src/main/github/github-stack-api-responses.ts new file mode 100644 index 00000000000..727d72331ef --- /dev/null +++ b/src/main/github/github-stack-api-responses.ts @@ -0,0 +1,52 @@ +import type { HostedReviewSummary } from '../../shared/hosted-review' + +export type NumberedHostedReviewSummary = Omit & { number: number } + +export type GitHubStackPullRequest = NumberedHostedReviewSummary & { + headRefName: string + baseRefName: string +} + +export type GitHubStack = { + number: number + open: boolean + pull_requests: { number: number }[] +} + +export function parseGitHubStackPullRequests(stdout: string): GitHubStackPullRequest[] { + const pullRequests = JSON.parse(stdout) as { + number?: unknown + html_url?: unknown + head?: { ref?: unknown } + base?: { ref?: unknown } + }[] + return pullRequests.flatMap((pullRequest) => { + const number = Number(pullRequest.number) + const url = typeof pullRequest.html_url === 'string' ? pullRequest.html_url : '' + const headRefName = typeof pullRequest.head?.ref === 'string' ? pullRequest.head.ref : '' + const baseRefName = typeof pullRequest.base?.ref === 'string' ? pullRequest.base.ref : '' + return Number.isInteger(number) && number > 0 && url && headRefName && baseRefName + ? [{ number, url, headRefName, baseRefName }] + : [] + }) +} + +export function parseGitHubStacks(stdout: string): GitHubStack[] { + const stacks = JSON.parse(stdout) as { + number?: unknown + open?: unknown + pull_requests?: { number?: unknown }[] + }[] + return stacks.flatMap((stack) => { + const number = Number(stack.number) + const pullRequests = (stack.pull_requests ?? []).flatMap((pullRequest) => { + const pullRequestNumber = Number(pullRequest.number) + return Number.isInteger(pullRequestNumber) && pullRequestNumber > 0 + ? [{ number: pullRequestNumber }] + : [] + }) + return Number.isInteger(number) && number > 0 + ? [{ number, open: stack.open === true, pull_requests: pullRequests }] + : [] + }) +} diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index e9b8a10282c..23a7242810c 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -195,6 +195,7 @@ describe('issue source operations', () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ id: 9, + node_id: 'IC_enterprise_9', user: { login: 'octo', avatar_url: '', type: 'User' }, body: 'Enterprise comment', created_at: '2026-07-16T00:00:00.000Z', @@ -208,7 +209,10 @@ describe('issue source operations', () => { repo: 'orca', host: 'github.acme-corp.com' }) - ).resolves.toMatchObject({ ok: true }) + ).resolves.toMatchObject({ + ok: true, + comment: { reactionSubjectId: 'IC_enterprise_9' } + }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 3ffa6dc662a..e2a4dd52677 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -473,6 +473,7 @@ export async function addIssueComment( ) const data = JSON.parse(stdout) as { id?: number + node_id?: string | null user: { login: string; avatar_url: string; type?: string } | null body?: string created_at?: string @@ -483,6 +484,7 @@ export async function addIssueComment( } const comment: PRComment = { id: data.id, + reactionSubjectId: data.node_id?.trim() || undefined, author: data.user?.login ?? 'You', authorAvatarUrl: data.user?.avatar_url ?? '', body: data.body ?? body, diff --git a/src/main/github/stacked-pr-creation.test.ts b/src/main/github/stacked-pr-creation.test.ts new file mode 100644 index 00000000000..e97ea14f2a4 --- /dev/null +++ b/src/main/github/stacked-pr-creation.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock, repositoryMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + repositoryMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + ghRepoExecOptions: (context: { repoPath: string; connectionId?: string | null }) => + context.connectionId ? {} : { cwd: context.repoPath }, + githubRepoContext: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: Record + ) => ({ repoPath, connectionId, localGitOptions }) +})) + +vi.mock('./github-api-repository', () => ({ + getOriginGitHubApiRepository: repositoryMock, + githubHostExecOptions: (repository: { host?: string }) => ({ host: repository.host }) +})) + +import { + prepareGitHubStackedPullRequest, + registerGitHubStackedPullRequest +} from './stacked-pr-creation' + +const repository = { owner: 'acme', repo: 'orca', host: 'github.com' } +const parentReview = { number: 41, url: 'https://github.com/acme/orca/pull/41' } +const currentReview = { number: 42, url: 'https://github.com/acme/orca/pull/42' } + +function pullRequest(number: number, head: string, base: string) { + return { + number, + html_url: `https://github.com/acme/orca/pull/${number}`, + head: { ref: head }, + base: { ref: base } + } +} + +function stack(number: number, pullRequests: number[]) { + return { + number, + open: true, + pull_requests: pullRequests.map((pullRequestNumber) => ({ number: pullRequestNumber })) + } +} + +beforeEach(() => { + ghExecFileAsyncMock.mockReset() + repositoryMock.mockReset() + repositoryMock.mockResolvedValue(repository) +}) + +describe('prepareGitHubStackedPullRequest', () => { + it('resolves an open parent PR and an existing current PR', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([pullRequest(42, 'stack/child', 'stack/parent')]) + }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [40, 41])]) }) + .mockResolvedValueOnce({ stdout: '[]' }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'origin/stack/parent', + head: 'refs/heads/stack/child', + title: 'Child' + }) + + expect(result).toMatchObject({ + ok: true, + parentReview: { number: 41 }, + currentReview: { number: 42 } + }) + expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual([ + 'api', + 'repos/acme/orca/pulls?head=acme%3Astack%2Fparent&state=open&per_page=2' + ]) + expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([ + 'api', + 'repos/acme/orca/pulls?head=acme%3Astack%2Fchild&base=stack%2Fparent&state=open&per_page=2' + ]) + }) + + it('allows an idempotent retry after the child was already registered', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([pullRequest(42, 'stack/child', 'stack/parent')]) + }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + + expect(result).toMatchObject({ ok: true, currentReview: { number: 42 } }) + }) + + it('requires an open PR for the selected parent branch', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'feature/parent', + head: 'feature/child', + title: 'Child' + }) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + if (!result.ok) { + expect(result.error).toContain('does not have an open pull request') + } + }) + + it('rejects a parent that is not the top of its stack', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 45])]) }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + if (!result.ok) { + expect(result.error).toContain('top pull request') + } + }) + + it('does not offer stacks on GitHub Enterprise Server', async () => { + repositoryMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme.test' + }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) + +describe('registerGitHubStackedPullRequest', () => { + it('creates a new stack with the parent and current PR', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 50 }) }) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview + }) + + expect(result).toMatchObject({ ok: true, number: 42, stackNumber: 50 }) + expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual([ + 'api', + '-X', + 'POST', + 'repos/acme/orca/stacks', + '-F', + 'pull_requests[]=41', + '-F', + 'pull_requests[]=42' + ]) + }) + + it('appends the current PR when the parent is the existing top', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [40, 41])]) }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 50 }) }) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview, + connectionId: 'ssh-1' + }) + + expect(result).toMatchObject({ ok: true, stackNumber: 50 }) + expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual([ + 'api', + '-X', + 'POST', + 'repos/acme/orca/stacks/50/add', + '-F', + 'pull_requests[]=42' + ]) + expect(ghExecFileAsyncMock.mock.calls[2][1]).not.toHaveProperty('cwd') + }) + + it('treats an already registered parent-child pair as success', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) }) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview + }) + + expect(result).toMatchObject({ ok: true, stackNumber: 50 }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not claim registration when the stack no longer holds the parent', async () => { + // A concurrent stack edit can drop the parent while the child sits at index 0. + // Reading index 0 off a findIndex miss would report that pair as registered. + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) }) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview + }) + + expect(result).toMatchObject({ + ok: false, + error: 'The pull request already belongs to a different GitHub stack.', + createdReview: currentReview + }) + }) + + it('preserves the created PR when registration fails', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockRejectedValueOnce(new Error('HTTP 422')) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview + }) + + expect(result).toMatchObject({ + ok: false, + createdReview: currentReview + }) + }) +}) diff --git a/src/main/github/stacked-pr-creation.ts b/src/main/github/stacked-pr-creation.ts new file mode 100644 index 00000000000..e34752df6d9 --- /dev/null +++ b/src/main/github/stacked-pr-creation.ts @@ -0,0 +1,310 @@ +import type { + CreateStackedHostedReviewInput, + CreateStackedHostedReviewResult +} from '../../shared/hosted-review' +import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' +import { + normalizeHostedReviewBaseRef, + normalizeHostedReviewHeadRef +} from '../../shared/hosted-review-refs' +import { acquire, ghExecFileAsync, ghRepoExecOptions, githubRepoContext, release } from './gh-utils' +import { + getOriginGitHubApiRepository, + githubHostExecOptions, + type GitHubApiRepository +} from './github-api-repository' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { + parseGitHubStackPullRequests, + parseGitHubStacks, + type GitHubStack, + type GitHubStackPullRequest, + type NumberedHostedReviewSummary +} from './github-stack-api-responses' + +type StackedPullRequestPlan = + | { + ok: true + repository: GitHubApiRepository + parentReview: GitHubStackPullRequest + currentReview: GitHubStackPullRequest | null + } + | Extract + +function creationError(error: string): Extract { + return { ok: false, code: 'validation', error } +} + +function isStacksUnavailableError(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + return message.includes('http 404') || message.includes('feature not supported') +} + +function ghOptions( + repoPath: string, + repository: GitHubApiRepository, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +) { + return { + ...ghRepoExecOptions( + githubRepoContext(repoPath, connectionId, getHostedReviewLocalGitOptions(options)) + ), + ...githubHostExecOptions(repository), + timeout: 60_000 + } +} + +async function findOpenPullRequestsForBranch( + repoPath: string, + repository: GitHubApiRepository, + branch: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {}, + base?: string +): Promise { + const head = encodeURIComponent(`${repository.owner}:${branch}`) + const baseQuery = base ? `&base=${encodeURIComponent(base)}` : '' + const endpoint = `repos/${repository.owner}/${repository.repo}/pulls?head=${head}${baseQuery}&state=open&per_page=2` + const { stdout } = await ghExecFileAsync( + ['api', endpoint], + ghOptions(repoPath, repository, connectionId, options) + ) + return parseGitHubStackPullRequests(stdout) +} + +async function getStacksForPullRequest( + repoPath: string, + repository: GitHubApiRepository, + pullRequestNumber: number, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const endpoint = `repos/${repository.owner}/${repository.repo}/stacks?pull_request=${pullRequestNumber}` + const { stdout } = await ghExecFileAsync( + ['api', endpoint], + ghOptions(repoPath, repository, connectionId, options) + ) + return parseGitHubStacks(stdout) +} + +function validateParentStack( + parentReview: NumberedHostedReviewSummary, + stacks: GitHubStack[] +): Extract | null { + if (stacks.length > 1) { + return creationError('The selected parent pull request belongs to multiple stacks.') + } + const stack = stacks[0] + if (!stack) { + return null + } + if (!stack.open) { + return creationError('The selected parent belongs to a closed stack.') + } + if (stack.pull_requests.at(-1)?.number !== parentReview.number) { + return creationError( + 'Choose the top pull request in the stack as the base branch before adding another layer.' + ) + } + return null +} + +export async function prepareGitHubStackedPullRequest( + repoPath: string, + input: CreateStackedHostedReviewInput, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + if (input.provider !== 'github') { + return creationError('Stacked pull request creation is available only for GitHub repositories.') + } + const repository = await getOriginGitHubApiRepository( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) + if (!repository || !isDefaultGitHubHost(repository.host)) { + return creationError('GitHub stacked pull requests are available only on GitHub.com.') + } + const base = normalizeHostedReviewBaseRef(input.base).trim() + const head = input.head ? normalizeHostedReviewHeadRef(input.head).trim() : '' + if (!base || !head || base.toLowerCase() === head.toLowerCase()) { + return creationError('Choose a different parent branch before creating a stacked pull request.') + } + + await acquire() + try { + const [parentPullRequests, currentPullRequests] = await Promise.all([ + findOpenPullRequestsForBranch(repoPath, repository, base, connectionId, options), + findOpenPullRequestsForBranch(repoPath, repository, head, connectionId, options, base) + ]) + if (parentPullRequests.length === 0) { + return creationError( + `The parent branch ${base} does not have an open pull request. Create that pull request first.` + ) + } + if (parentPullRequests.length !== 1) { + return creationError(`Orca found multiple open pull requests for the parent branch ${base}.`) + } + if (currentPullRequests.length > 1) { + return creationError(`Orca found multiple open pull requests for the current branch ${head}.`) + } + const parentReview = parentPullRequests[0] + const currentReview = currentPullRequests[0] ?? null + const [parentStacks, currentStacks] = await Promise.all([ + getStacksForPullRequest(repoPath, repository, parentReview.number, connectionId, options), + currentReview + ? getStacksForPullRequest(repoPath, repository, currentReview.number, connectionId, options) + : Promise.resolve([]) + ]) + if ( + currentReview && + registeredStackNumber(parentReview, currentReview, parentStacks, currentStacks) + ) { + return { ok: true, repository, parentReview, currentReview } + } + if (currentStacks.length > 0) { + return creationError('The pull request already belongs to a different GitHub stack.') + } + const parentError = validateParentStack(parentReview, parentStacks) + return ( + parentError ?? { + ok: true, + repository, + parentReview, + currentReview + } + ) + } catch (error) { + console.warn('GitHub stack creation preflight failed:', error) + return { + ok: false, + code: isStacksUnavailableError(error) ? 'validation' : 'unknown', + error: isStacksUnavailableError(error) + ? 'GitHub stacked pull requests are not available for this repository.' + : 'Orca could not verify the parent pull request. Retry in a moment.' + } + } finally { + release() + } +} + +function registeredStackNumber( + parentReview: NumberedHostedReviewSummary, + currentReview: NumberedHostedReviewSummary, + parentStacks: GitHubStack[], + currentStacks: GitHubStack[] +): number | null { + const parentStack = parentStacks[0] + const currentStack = currentStacks[0] + if (!parentStack || !currentStack || parentStack.number !== currentStack.number) { + return null + } + const parentPosition = parentStack.pull_requests.findIndex( + (pullRequest) => pullRequest.number === parentReview.number + ) + // Why: a miss is -1, and -1 + 1 reads the first entry — which reports "already + // registered" whenever the current PR heads a stack the parent has left. + if (parentPosition === -1) { + return null + } + return parentStack.pull_requests[parentPosition + 1]?.number === currentReview.number + ? parentStack.number + : null +} + +export async function registerGitHubStackedPullRequest(args: { + repoPath: string + repository: GitHubApiRepository + parentReview: NumberedHostedReviewSummary + currentReview: NumberedHostedReviewSummary + connectionId?: string | null + options?: HostedReviewExecutionOptions +}): Promise { + const options = args.options ?? {} + await acquire() + try { + const [parentStacks, currentStacks] = await Promise.all([ + getStacksForPullRequest( + args.repoPath, + args.repository, + args.parentReview.number, + args.connectionId, + options + ), + getStacksForPullRequest( + args.repoPath, + args.repository, + args.currentReview.number, + args.connectionId, + options + ) + ]) + const existingStackNumber = registeredStackNumber( + args.parentReview, + args.currentReview, + parentStacks, + currentStacks + ) + if (existingStackNumber) { + return { + ok: true, + ...args.currentReview, + stackNumber: existingStackNumber, + parentReview: args.parentReview + } + } + if (currentStacks.length > 0) { + return { + ...creationError('The pull request already belongs to a different GitHub stack.'), + createdReview: args.currentReview + } + } + const parentError = validateParentStack(args.parentReview, parentStacks) + if (parentError) { + return { ...parentError, createdReview: args.currentReview } + } + + const parentStack = parentStacks[0] + const endpoint = parentStack + ? `repos/${args.repository.owner}/${args.repository.repo}/stacks/${parentStack.number}/add` + : `repos/${args.repository.owner}/${args.repository.repo}/stacks` + const pullRequests = parentStack + ? [args.currentReview.number] + : [args.parentReview.number, args.currentReview.number] + const command = ['api', '-X', 'POST', endpoint] + for (const pullRequest of pullRequests) { + command.push('-F', `pull_requests[]=${pullRequest}`) + } + const { stdout } = await ghExecFileAsync(command, { + ...ghOptions(args.repoPath, args.repository, args.connectionId, options), + idempotent: false + }) + const stackNumber = Number((JSON.parse(stdout) as { number?: unknown }).number) + if (!Number.isInteger(stackNumber) || stackNumber <= 0) { + throw new Error('GitHub returned an invalid stack response.') + } + return { + ok: true, + ...args.currentReview, + stackNumber, + parentReview: args.parentReview + } + } catch (error) { + console.warn('GitHub stack registration failed:', error) + return { + ok: false, + code: isStacksUnavailableError(error) ? 'validation' : 'unknown', + error: isStacksUnavailableError(error) + ? 'The pull request was created, but GitHub stacks are not available for this repository.' + : 'The pull request was created, but GitHub could not add it to the stack. Retry to finish stack registration.', + createdReview: args.currentReview + } + } finally { + release() + } +} diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index 089de41d877..e82b82d3360 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -911,6 +911,57 @@ describe('gitlab client — MR operations', () => { expect(result.error?.type).toBe('permission_denied') expect(result.items).toEqual([]) }) + + // Why: the title carries a classifier keyword, so this also pins that a wrapped payload stays + // out of the substring matcher — classifying it would swap the body for "check your connection". + it('reports the body instead of ".map is not a function" when the API returns a non-array', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify({ data: [{ iid: 7, title: 'fix network timeout' }] }), + headers: {} + }) + const result = await listMergeRequests('/repo', 'opened') + expect(result.items).toEqual([]) + expect(result.error?.type).toBe('unknown') + expect(result.error?.message).toContain('fix network timeout') + expect(result.error?.message).not.toContain('is not a function') + }) + + it('reports the body instead of ".map is not a function" when the cwd fallback returns a non-array', async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ data: [], total: 0 }) + }) + const result = await listMergeRequests('/repo', 'opened') + expect(result.items).toEqual([]) + expect(result.error?.message).toContain('{"data":[],"total":0}') + expect(result.error?.message).not.toContain('is not a function') + }) + + // Why: the whole point of surfacing the body — a GitLab error envelope now + // classifies like any other glab failure instead of collapsing to 'unknown'. + it('classifies a GitLab error envelope returned on exit 0', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify({ message: '403 Forbidden' }), + headers: {} + }) + const result = await listMergeRequests('/repo', 'opened') + expect(result.items).toEqual([]) + expect(result.error?.type).toBe('permission_denied') + }) + + // Why: the sibling title matches an earlier classifier branch than the envelope does, so this + // fails if the payload leaks into classification instead of only the envelope's own message. + it('classifies an error envelope by its message, not its sibling payload', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify({ + message: '404 Project Not Found', + data: [{ iid: 7, title: '403 forbidden in CI' }] + }), + headers: {} + }) + const result = await listMergeRequests('/repo', 'opened') + expect(result.error?.type).toBe('not_found') + }) }) describe('updateMR', () => { diff --git a/src/main/gitlab/client-work-items.test.ts b/src/main/gitlab/client-work-items.test.ts index 5de8522d4e7..80e412862d8 100644 --- a/src/main/gitlab/client-work-items.test.ts +++ b/src/main/gitlab/client-work-items.test.ts @@ -191,4 +191,16 @@ describe('gitlab client — combined listWorkItems', () => { expect(result.items[0].title).toBe('live issue') expect(result.error).toBeDefined() }) + + it('reports the body instead of ".map is not a function" when the issues fetch returns a non-array', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ data: [], total: 0 }) + }) + + const result = await listWorkItems('/repo', 'opened', 1, 20) + expect(result.items).toEqual([]) + expect(result.error?.message).toContain('{"data":[],"total":0}') + expect(result.error?.message).not.toContain('is not a function') + }) }) diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 2e25ebb6dec..138b2cac93d 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -26,7 +26,7 @@ import { acquire, classifyGlabError, classifyJobLogError, - classifyListIssuesError, + classifyListFetchError, isMissingJobLogError, getGlabKnownHosts, getProjectRef, @@ -36,6 +36,7 @@ import { glabApiWithHeaders, glabExecFileAsync, parseGlabAuthStatusHosts, + parseGlabJsonList, release, resolveIssueSource, type LocalGitExecOptions, @@ -468,7 +469,7 @@ export async function listMergeRequests( ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) - const data = JSON.parse(stdout) as Parameters[0][] + const data = parseGlabJsonList[0]>(stdout) return { items: data.map((d) => mapMRToWorkItem(d, 'unknown')), page, @@ -478,14 +479,13 @@ export async function listMergeRequests( totalPages: data.length < perPage ? page : page + 1 } } catch (err) { - const stderr = err instanceof Error ? err.message : String(err) return { items: [], page, perPage, totalCount: 0, totalPages: 0, - error: classifyListIssuesError(stderr) + error: classifyListFetchError(err) } } finally { release() @@ -505,7 +505,7 @@ export async function listMergeRequests( [...glabHostnameArgs(projectRef, connectionId), path], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) - const data = JSON.parse(body) as Parameters[0][] + const data = parseGlabJsonList[0]>(body) return { items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), page, @@ -517,14 +517,13 @@ export async function listMergeRequests( Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) } } catch (err) { - const stderr = err instanceof Error ? err.message : String(err) return { items: [], page, perPage, totalCount: 0, totalPages: 0, - error: classifyListIssuesError(stderr) + error: classifyListFetchError(err) } } finally { release() @@ -688,7 +687,7 @@ export async function fetchIssuesAsWorkItems( ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) - const data = JSON.parse(stdout) as Parameters[0][] + const data = parseGlabJsonList[0]>(stdout) return { items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)), error: undefined @@ -696,7 +695,7 @@ export async function fetchIssuesAsWorkItems( } catch (err) { return { items: [], - error: classifyListIssuesError(err instanceof Error ? err.message : String(err)) + error: classifyListFetchError(err) } } finally { release() diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index c2396c9aa7d..4ffd0d11ed5 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -17,8 +17,10 @@ import { _resetProjectRefCache, classifyGlabError, classifyJobLogError, + classifyListFetchError, classifyListIssuesError, getIssueProjectRef, + parseGlabJsonList, isMissingJobLogError, getGlabKnownHosts, getProjectRef, @@ -27,6 +29,7 @@ import { parseGlabAuthStatusHosts, resolveIssueSource } from './gl-utils' +import { GlabNonListResponseError } from './glab-api-response' import { rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' @@ -501,6 +504,69 @@ gitlab.example.com:8080: }) }) +describe('parseGlabJsonList', () => { + it('returns the parsed list unchanged', () => { + expect(parseGlabJsonList<{ iid: number }>('[{"iid":1}]')).toEqual([{ iid: 1 }]) + }) + + it.each([ + ['null', 'null'], + ['a number', '0'], + ['a string', '"nope"'], + ['an object', '{"data":[]}'] + ])('reports the raw payload for %s as an unclassifiable body', (_label, payload) => { + expect(() => parseGlabJsonList(payload)).toThrow(GlabNonListResponseError) + expect(() => parseGlabJsonList(payload)).toThrow(payload) + }) + + // Why: glab allows a 10MB body, and the renderer's error banner has no length guard of its own. + it.each([ + ['an opaque body', `{"data":"${'x'.repeat(50_000)}"}`], + ['an error envelope', `{"message":"${'x'.repeat(50_000)}"}`] + ])('bounds the reported payload for %s', (_label, payload) => { + expect(() => parseGlabJsonList(payload)).toThrow( + /^GitLab returned (?:a non-list response|an error): .{300}$/ + ) + }) + + it.each([ + ['message', '{"message":"403 Forbidden"}', '403 Forbidden'], + ['error', '{"error":"insufficient_scope"}', 'insufficient_scope'], + ['error when message is blank', '{"message":" ","error":"real_error"}', 'real_error'], + // Why: GitLab sends both on some endpoints; `message` is the human-facing one. + [ + 'message when both are set', + '{"message":"404 Project Not Found","error":"insufficient_scope"}', + '404 Project Not Found' + ] + ])('reports a GitLab error envelope by its %s field', (_label, payload, reported) => { + // Why: an envelope is GitLab's own diagnostic, so it stays classifiable — unlike a raw body. + expect(() => parseGlabJsonList(payload)).toThrow(`GitLab returned an error: ${reported}`) + expect(() => parseGlabJsonList(payload)).not.toThrow(GlabNonListResponseError) + }) +}) + +describe('classifyListFetchError', () => { + it('keeps opaque payload text away from the classifier', () => { + // Why: the title would otherwise substring-match as a network failure and replace the payload. + const payload = '{"data":[{"title":"fix network timeout"}]}' + let thrown: unknown + try { + parseGlabJsonList(payload) + } catch (err) { + thrown = err + } + expect(thrown).toBeInstanceOf(GlabNonListResponseError) + const classified = classifyListFetchError(thrown) + expect(classified.type).toBe('unknown') + expect(classified.message).toContain('fix network timeout') + }) + + it('still classifies ordinary glab failures by their stderr', () => { + expect(classifyListFetchError(new Error('HTTP 403 Forbidden')).type).toBe('permission_denied') + }) +}) + describe('parseGlabApiResponse', () => { it('splits headers and body at the first blank line (LF)', () => { const stdout = 'HTTP/2.0 200 OK\nX-Total: 42\nX-Total-Pages: 3\n\n[{"iid":1}]' diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 6531793dac6..0d361356692 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -5,6 +5,7 @@ export { glabExecFileAsync, gitExecFileAsync } export { classifyGlabError, classifyJobLogError, + classifyListFetchError, classifyListIssuesError, isMissingJobLogError } from './glab-error-classification' @@ -28,7 +29,7 @@ export type { ProjectRef, ResolvedIssueSource } from './gitlab-project-ref-resolution' -export { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response' +export { parseGlabApiResponse, parseGlabJsonList, type GlabApiResponse } from './glab-api-response' const MAX_CONCURRENT = 4 let running = 0 diff --git a/src/main/gitlab/glab-api-response.ts b/src/main/gitlab/glab-api-response.ts index a8ea6380769..fc7c946aad1 100644 --- a/src/main/gitlab/glab-api-response.ts +++ b/src/main/gitlab/glab-api-response.ts @@ -25,6 +25,48 @@ export function parseGlabApiResponse(stdout: string): GlabApiResponse { return { body, headers } } +/** A non-list body carrying no GitLab error text — opaque data, so there is nothing to classify. */ +export class GlabNonListResponseError extends Error {} + +// Why: glab allows a 10MB body; this is what keeps a proxy's whole response out of the error banner. +const REPORTED_PAYLOAD_LIMIT = 300 + +/** + * Parse a glab list response, failing readably when GitLab answers with a JSON object. + * + * Why: glab exits 0 on error envelopes and proxy wrappers, so `JSON.parse(...).map` threw an + * opaque `.map is not a function` that the caller's classifier could only report as "unknown". + */ +export function parseGlabJsonList(payload: string): T[] { + const parsed: unknown = JSON.parse(payload) + if (Array.isArray(parsed)) { + return parsed as T[] + } + const reported = gitlabErrorText(parsed) + if (reported) { + throw new Error(`GitLab returned an error: ${reported}`) + } + // Why: slice the raw payload rather than re-serializing `parsed` — same text, without + // stringifying a multi-megabyte body just to keep the preview. + throw new GlabNonListResponseError( + `GitLab returned a non-list response: ${payload.trim().slice(0, REPORTED_PAYLOAD_LIMIT)}` + ) +} + +/** GitLab reports API failures as `{ message }` or `{ error }`; anything else is opaque data. */ +function gitlabErrorText(parsed: unknown): string | null { + if (typeof parsed !== 'object' || parsed === null) { + return null + } + const { message, error } = parsed as { message?: unknown; error?: unknown } + for (const value of [message, error]) { + if (typeof value === 'string' && value.trim()) { + return value.trim().slice(0, REPORTED_PAYLOAD_LIMIT) + } + } + return null +} + function findHeaderBodySeparator(stdout: string): { index: number; bodyStart: number } | null { let lineStart = 0 for (let index = 0; index < stdout.length; index++) { diff --git a/src/main/gitlab/glab-error-classification.ts b/src/main/gitlab/glab-error-classification.ts index d769bdd108c..75af66ca4ea 100644 --- a/src/main/gitlab/glab-error-classification.ts +++ b/src/main/gitlab/glab-error-classification.ts @@ -1,4 +1,5 @@ import type { ClassifiedError } from '../../shared/types' +import { GlabNonListResponseError } from './glab-api-response' // Why: glab CLI surfaces API errors as unstructured stderr. Map known // patterns to typed errors so callers can show user-friendly messages. @@ -33,6 +34,8 @@ export function classifyGlabError(stderr: string): ClassifiedError { return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` } } +const LIST_READ_FAILURE = 'Failed to load issues' + // Why: classifyGlabError's copy is phrased for edit/update operations; list // issues is a read op, so rewrite messages for read-context banners. export function classifyListIssuesError(stderr: string): ClassifiedError { @@ -46,11 +49,20 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { validation_error: `Invalid request — ${trimmed}`, rate_limited: 'GitLab rate limit hit. Try again in a few minutes.', network_error: 'Network error — check your connection.', - unknown: `Failed to load issues: ${trimmed}` + unknown: `${LIST_READ_FAILURE}: ${trimmed}` } return { type: c.type, message: readMessages[c.type] } } +// Why: an opaque response body is content, not a diagnostic — substring-matching it would render +// an MR titled "fix network timeout" as "check your connection" and discard the body. +export function classifyListFetchError(err: unknown): ClassifiedError { + if (err instanceof GlabNonListResponseError) { + return { type: 'unknown', message: `${LIST_READ_FAILURE}: ${err.message}` } + } + return classifyListIssuesError(err instanceof Error ? err.message : String(err)) +} + // Why: a job trace is a read on a pipeline job, so classifyGlabError's issue-edit // copy ("permission to edit this issue") would land verbatim on a Checks row. export function classifyJobLogError(stderr: string): ClassifiedError { diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index cde9d4f9e01..221041356ec 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -186,6 +186,32 @@ describe('gitlab issue operations', () => { expect(result.error?.type).toBe('permission_denied') }) + it('reports the body instead of ".map is not a function" when the API returns a non-array', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ data: [], total: 0 }) + }) + + const result = await listIssues('/repo-root', 5) + + expect(result.items).toEqual([]) + expect(result.error?.type).toBe('unknown') + expect(result.error?.message).toContain('{"data":[],"total":0}') + expect(result.error?.message).not.toContain('is not a function') + }) + + it('reports a GitLab error envelope by its own message', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ message: '403 Forbidden' }) + }) + + const result = await listIssues('/repo-root', 5) + + expect(result.items).toEqual([]) + expect(result.error?.type).toBe('permission_denied') + }) + it('returns an isolated not_found error (never a cwd-inferred glab call) when the project is unresolved', async () => { // Why: a cwd-inferred `glab issue list` would hit `git: exit status 128` // on an SSH connection and, in an "All projects" aggregate, sink the diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index 970f9ff1f03..8635805337d 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -13,7 +13,7 @@ import type { } from '../../shared/types' import { mapGitLabIssueInfo } from './mappers' // prettier-ignore -import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, type LocalGitExecOptions, type ProjectRef } from './gl-utils' +import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, type LocalGitExecOptions, type ProjectRef } from './gl-utils' // Why: parallel to GitHub's IssueListResult — distinguishes a successful- // empty listing from a failed fetch. @@ -128,7 +128,7 @@ export async function listIssues( ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) - const data = JSON.parse(stdout) as Record[] + const data = parseGlabJsonList>(stdout) // Why: GitLab's project issues endpoint returns true issues only // (MRs are a separate endpoint), so no equivalent of GitHub's // pull_request filter is needed here. @@ -136,10 +136,9 @@ export async function listIssues( items: data.map((d) => mapGitLabIssueInfo(d as Parameters[0])) } } catch (err) { - const stderr = err instanceof Error ? err.message : String(err) return { items: [], - error: classifyListIssuesError(stderr) + error: classifyListFetchError(err) } } finally { release() diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 625d9fa2b04..b46c1b793be 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -18,6 +18,7 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/artifacts/artifact-cloud-request.ts', 1], ['main/azure-devops/azure-devops-api-request.ts', 1], ['main/bitbucket/client.ts', 1], + ['main/bitbucket/user-request.ts', 1], ['main/gitea/client.ts', 1], ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 51a6150eab6..1b6e8bba165 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -14,6 +14,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -182,6 +183,10 @@ function buildInstalledConfig( } export class GrokHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/index.ts b/src/main/index.ts index a8153952c02..f91a531ce7b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,7 +39,10 @@ import { shutdownDaemon } from './daemon/daemon-init' import { - hasRecordedLegacySharedCodexPane, + type CodexPaneHomeRoute, + getCodexPaneAccount, + hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys } from './codex/codex-pane-account-registry' import { closeAllWatchers } from './ipc/filesystem-watcher' @@ -116,6 +119,7 @@ import { configureElectronNetworkCompatibility, configureDevUserDataPath, configureOrcaUserDataPathEnv, + disableUnsupportedChromiumFeatures, enableMainProcessGpuFeatures, installDevParentDisconnectQuit, installDevParentSignalQuit, @@ -159,6 +163,11 @@ import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' +import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration' +import { + startWindowsDesktopBeforeShellPathReady, + type WindowsDesktopStartupServices +} from './startup/windows-desktop-shell-path-startup' import { acquireSingleInstanceLock, logSingleInstanceLockBypass, @@ -215,6 +224,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' +import { reconcileRetainedCodexHookHomes } from './codex/retained-codex-hook-state' import { ensureRealHomeCodexHookState, isRealHomeCodexHookLaneUsable @@ -252,7 +262,10 @@ import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branc import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' import { moveWorktree } from './git/worktree' -import { setDefaultWslDistroOverride } from './git/runner' +import { + configureWindowsHostGitEnvironmentReadiness, + setDefaultWslDistroOverride +} from './git/runner' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' import { parseWorkspaceKey } from '../shared/workspace-scope' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' @@ -266,6 +279,7 @@ import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' import { AgentAwakeService } from './agent-awake-service' +import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { settleTeardownWithinDeadline } from './quit-teardown-deadline' import { quitTeardownStartGate } from './quit-teardown-start-gate' @@ -398,10 +412,23 @@ function handleCodexHomePtySpawned(args: { id: string codexHomePath: string | null reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null launchEnv?: NodeJS.ProcessEnv startedAt?: Date startedSequence?: number }): void { + // Why: only shared or ambiguous retained shells can create rollout logs that still need publication. + if (args.reattached && args.startedSequence !== undefined) { + const paneAccount = getCodexPaneAccount(args.id) + const homeRoute = + args.reattachedHomeRoute !== undefined + ? (args.reattachedHomeRoute ?? undefined) + : paneAccount?.homeRoute + if (codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { + codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) + return + } + } const fullScanRequired = codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { reattached: args.reattached, @@ -820,6 +847,7 @@ if (hasSingleInstanceLock) { platform: process.platform, ...getMainProcessLifecycleIdentity() }) + disableUnsupportedChromiumFeatures() configureElectronNetworkCompatibility() enableRendererHeapHeadroom() maybeApplyGpuFallbackForThisLaunch() @@ -904,7 +932,7 @@ async function reapRestoredSubagentsWithoutLiveAgent(): Promise { }) } -function startTerminalRuntimeStartupServices(): Promise { +function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { logStartupMilestone('first-window-startup-services-start') const startupServices = startFirstWindowStartupServices({ // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. @@ -915,10 +943,19 @@ function startTerminalRuntimeStartupServices(): Promise { await initDaemonPtyProvider(signal, { macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode }) - if (codexRuntimeHome?.isHostSystemDefaultRealHome() && hasRecordedLegacySharedCodexPane()) { + // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. + if (codexRuntimeHome && hasRecordedManagedHostCodexPane()) { const livePtyIds = await listLiveDaemonPtyIds() if (livePtyIds) { reconcileCodexPaneAccountsWithLivePtys(livePtyIds) + const settings = store?.getSettings() + reconcileRetainedCodexHookHomes({ + hookService: codexHookService, + hooksEnabled: + isAgentStatusHooksEnabled(settings) && + settings?.disabledTuiAgents.includes('codex') !== true, + runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) + }) } } // Why: retained shells can invoke Codex immediately after the startup gate. @@ -952,19 +989,24 @@ function startTerminalRuntimeStartupServices(): Promise { console.error('[agent-hooks] Failed to start local hook server:', error) } }) - firstWindowStartupServicesReady = startupServices.firstWindowReady - localPtyStartupReady = startupServices.localPtyReady - localPtyProviderStartupReady = startupServices.localPtyProviderReady - void firstWindowStartupServicesReady.then(() => { + void startupServices.firstWindowReady.then(() => { logStartupMilestone('first-window-startup-services-ready') }) - void localPtyStartupReady.then(() => { + void startupServices.localPtyReady.then(() => { logStartupMilestone('local-pty-startup-ready') void reapRestoredSubagentsWithoutLiveAgent().catch((error) => { console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) }) }) - return firstWindowStartupServicesReady + return startupServices +} + +function bindTerminalRuntimeStartupServices( + services: Promise +): void { + firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) + localPtyStartupReady = services.then((value) => value.localPtyReady) + localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) } function prepareCodexRuntimeHomeForLaunch( @@ -1220,7 +1262,7 @@ function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null } -function openMainWindow(): BrowserWindow { +function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { logStartupMilestone('open-main-window-start') if (!store) { throw new Error('Store must be initialized before opening the main window') @@ -1309,6 +1351,7 @@ function openMainWindow(): BrowserWindow { void presentRendererRecoveryPrompt(recentRecoveryCount) }, deferLoad: true, + ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), title: devInstanceIdentity.name, getKeybindings: () => keybindings?.getOverrides(), onBeforeReload: ({ ignoreCache, webContentsId }) => { @@ -1348,6 +1391,9 @@ function openMainWindow(): BrowserWindow { logStartupMilestone('ready-to-show') setImmediate(createSystemTrayDeferred) }) + window.once('show', () => { + logStartupMilestone('window-shown') + }) const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS) trayCreateFallback.unref?.() @@ -2124,6 +2170,24 @@ void app.whenReady().then(async () => { const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) + const windowsShellPathHydration = createWindowsShellPathHydration() + configureWindowsHostGitEnvironmentReadiness( + process.platform === 'win32' ? windowsShellPathHydration.whenReady : null + ) + if (process.platform === 'win32') { + const settings = store.getSettings() + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null) logStartupMilestone('store-loaded') // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. @@ -2133,6 +2197,22 @@ void app.whenReady().then(async () => { // Why: synchronize fallback WSL distro updates to runner. setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) } + if ( + ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && + process.platform === 'win32' + ) { + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } if ('showMenuBarIcon' in updates) { // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. syncMacMenuBarIcon(settings.showMenuBarIcon !== false) @@ -2184,7 +2264,12 @@ void app.whenReady().then(async () => { }) unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() agentAwakeService = new AgentAwakeService() - agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) + agentAwakeService.setMode( + normalizeComputerAwakeMode( + store.getSettings().computerAwakeMode, + store.getSettings().keepComputerAwakeWhileAgentsRun + ) + ) // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. agentAwakeService.setStatuses([]) const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() @@ -2742,7 +2827,7 @@ void app.whenReady().then(async () => { if (isAgentStatusHooksEnabled(store.getSettings())) { const managedHookStore = store void applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), { - shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32', + shouldHydrateShellPath: app.isPackaged, onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const settings = managedHookStore.getSettings() @@ -2916,7 +3001,20 @@ void app.whenReady().then(async () => { } }) - startTerminalRuntimeStartupServices() + const shellPathReady = windowsShellPathHydration.whenReady() + let desktopWindow: BrowserWindow | null = null + if (process.platform === 'win32' && app.isPackaged && !serveOptions) { + const desktopStartup = startWindowsDesktopBeforeShellPathReady({ + openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }), + shellPathReady, + startServices: startTerminalRuntimeStartupServices + }) + desktopWindow = desktopStartup.window + bindTerminalRuntimeStartupServices(desktopStartup.services) + } else { + await shellPathReady + bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) + } app.on('activate', handleMacAppActivation) if (serveOptions) { @@ -3000,15 +3098,21 @@ void app.whenReady().then(async () => { } // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. + const desktopRuntimeRpc = runtimeRpc + if (!desktopRuntimeRpc) { + throw new Error('runtime_rpc_unavailable') + } const [win, runtimeRpcStartResult] = await Promise.all([ - Promise.resolve(openMainWindow()), - runtimeRpc.start().then( - () => ({ ok: true as const }), - (error: unknown) => { - recordRuntimeRpcStartFailure(error) - return { ok: false as const, error } - } - ) + Promise.resolve(desktopWindow ?? openMainWindow()), + shellPathReady + .then(() => desktopRuntimeRpc.start()) + .then( + () => ({ ok: true as const }), + (error: unknown) => { + recordRuntimeRpcStartFailure(error) + return { ok: false as const, error } + } + ) ]) if (!runtimeRpcStartResult.ok) { void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) diff --git a/src/main/integration-credential-file.ts b/src/main/integration-credential-file.ts index 2ad90492f1b..96823a77a65 100644 --- a/src/main/integration-credential-file.ts +++ b/src/main/integration-credential-file.ts @@ -1,4 +1,13 @@ -import { statSync } from 'node:fs' +import { + chmodSync, + closeSync, + fsyncSync, + openSync, + renameSync, + statSync, + unlinkSync, + writeSync +} from 'node:fs' import { safeStorage } from 'electron' import { credentialDecryptionMessage, @@ -15,6 +24,75 @@ export function credentialFileHasContent(path: string): boolean { } } +// Falls back to 0600 plaintext when the OS keyring is unavailable, matching the +// Linear/Jira token writers so a keyring-less box still connects. +export function writeEncryptedCredential( + service: IntegrationCredentialService, + path: string, + value: string +): void { + if (safeStorage.isEncryptionAvailable()) { + writeCredentialFileAtomic(path, safeStorage.encryptString(value)) + return + } + console.warn( + `[${service.toLowerCase()}] safeStorage encryption unavailable — storing credential in plaintext` + ) + writeCredentialFileAtomic(path, Buffer.from(value, 'utf-8')) +} + +// Why (STA-3941): a direct write can truncate the previous credential if it +// fails or the app dies mid-write, leaving nothing to authenticate with. Write +// a sibling temp file, fsync it, then rename — readers only ever observe the +// old bytes or the complete new ones. +export function writeCredentialFileAtomic(path: string, data: Buffer): void { + const tempPath = `${path}.tmp` + let handle: number | null = null + try { + handle = openSync(tempPath, 'w', 0o600) + // Why: write(2) is allowed to return a short count, so a single writeSync + // could publish a truncated credential — the very corruption this avoids. + let written = 0 + while (written < data.length) { + const bytes = writeSync(handle, data, written, data.length - written) + if (bytes <= 0) { + throw new Error(`Credential write stalled at ${written}/${data.length} bytes`) + } + written += bytes + } + fsyncSync(handle) + closeSync(handle) + handle = null + renameSync(tempPath, path) + restrictCredentialFileToOwner(path) + } catch (error) { + if (handle !== null) { + try { + closeSync(handle) + } catch { + // Already closed or invalid; the unlink below is what matters. + } + } + try { + unlinkSync(tempPath) + } catch { + // Nothing to clean up. + } + throw error + } +} + +// Why: writeFileSync's `mode` only applies when it creates the file, so +// rewriting an existing credential would silently keep looser permissions. +// Best-effort: Windows has no POSIX modes, so chmod is a documented no-op there. +export function restrictCredentialFileToOwner(path: string): void { + try { + chmodSync(path, 0o600) + } catch { + // Best-effort hardening; the write itself already succeeded. + } +} + export class CredentialDecryptionError extends Error { constructor(service: IntegrationCredentialService) { super(credentialDecryptionMessage(service)) diff --git a/src/main/ipc/ai-vault-delete.ts b/src/main/ipc/ai-vault-delete.ts index 3f3a1c8abd4..110786d11d7 100644 --- a/src/main/ipc/ai-vault-delete.ts +++ b/src/main/ipc/ai-vault-delete.ts @@ -5,22 +5,18 @@ import { } from '../ai-vault/cached-session-list' import { deleteAiVaultSessionFile } from '../ai-vault/session-delete' import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-parse-cache' +import { invalidateAiVaultBackgroundCache } from '../ai-vault/session-scanner-background' import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { AiVaultDeleteSessionArgs, - AiVaultDeleteSessionResult, - AiVaultSessionLiveness + AiVaultDeleteSessionResult } from '../../shared/ai-vault-session-deletion' // Which cache backs the multi-host list is ai-vault.ts's concern, so its // invalidation is injected rather than reached into from here. type AiVaultDeleteDeps = { invalidateMultiHostListCache: () => void - getSessionLiveness: (target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string - }) => Promise + invalidateBackgroundCache?: (paths: string[]) => Promise } // Binds the delete orchestration to the caller's cache-invalidation seam. @@ -40,16 +36,13 @@ export async function deleteAiVaultSession( // The validator tolerates a malformed agent/filePath but destructures `args`, // so an absent payload is defaulted here to keep the never-throws boundary. const wslHomeDirs = await getAiVaultWslHomeDirs() - const result = await deleteAiVaultSessionFile( - { - agent: args?.agent as AiVaultAgent, - sessionId: args?.sessionId, - filePath: args?.filePath ?? '', - executionHostId: args?.executionHostId, - wslHomeDirs - }, - deps - ) + const result = await deleteAiVaultSessionFile({ + agent: args?.agent as AiVaultAgent, + sessionId: args?.sessionId, + filePath: args?.filePath ?? '', + executionHostId: args?.executionHostId, + wslHomeDirs + }) if (result.outcome === 'deleted') { // Three caches could otherwise resurrect it: the desktop per-host leg @@ -61,6 +54,11 @@ export async function deleteAiVaultSession( // what the renderer echoes back as filePath), so invalidate with that exact // key — resolve() could normalise it away from the stored key and miss. invalidateSessionParseCacheEntry(args?.filePath ?? '') + await (deps.invalidateBackgroundCache ?? invalidateAiVaultBackgroundCache)([ + args?.filePath ?? '' + ]).catch((error) => { + console.warn('[ai-vault] background cache invalidation failed:', error) + }) } return result diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index aaf4bb67cd6..a7ba5835611 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -169,14 +169,22 @@ describe('Agent Session History scan coalescing', () => { expect(result).not.toHaveProperty('cancelled') }) - it('still rejects the handler when a scan fails for a non-cancellation reason', async () => { + it('reports a failed local scan as a host issue rather than rejecting', async () => { mocks.scanAiVaultSessionsInWorker.mockRejectedValue(new Error('transcript root is unreadable')) registerAiVaultHandlers() const list = ipcHandler('aiVault:listSessions') - await expect( - list({ sender: { id: 1 } }, { executionHostScope: 'local', requestToken: 'scan' }) - ).rejects.toThrow('transcript root is unreadable') + // The local leg degrades like the SSH legs above: a rejection reaches the + // renderer as a raw string painted over the list instead of an issue row. + const result = await list( + { sender: { id: 1 } }, + { executionHostScope: 'local', requestToken: 'scan' } + ) + expect(result).toMatchObject({ + sessions: [], + issues: [expect.objectContaining({ message: 'transcript root is unreadable', kind: 'host' })] + }) + expect(result).not.toHaveProperty('cancelled') }) it('re-joins a preempted same-scope caller onto the forced refresh', async () => { diff --git a/src/main/ipc/ai-vault-subagent-list.ts b/src/main/ipc/ai-vault-subagent-list.ts index 49f609577c7..64a579c1550 100644 --- a/src/main/ipc/ai-vault-subagent-list.ts +++ b/src/main/ipc/ai-vault-subagent-list.ts @@ -1,7 +1,6 @@ import { resolve } from 'node:path' import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' -import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' -import { listOmpSubagentSessions } from '../ai-vault/session-scanner-omp-subagent-listing' +import { listAiVaultSubagentSessionsInBackground } from '../ai-vault/session-scanner-background' import { claudeProjectsRootDirs, ompSessionsRootDirs } from '../ai-vault/session-scanner-roots' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -45,7 +44,5 @@ export async function listAiVaultSubagentSessions( if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) { return { sessions: [], issues: [] } } - return args.agent === 'claude' - ? listClaudeSubagentSessions({ parentFilePath }) - : listOmpSubagentSessions({ parentFilePath }) + return listAiVaultSubagentSessionsInBackground({ agent: args.agent, parentFilePath }) } diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index e36708e18b0..13b2bc51926 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -93,6 +93,7 @@ vi.mock('./ssh', () => ({ const { OMP_SESSIONS_DIR } = await import('../ai-vault/session-scanner-roots') const { _internals, registerAiVaultHandlers } = await import('./ai-vault') +const { deleteAiVaultSession: deleteAiVaultSessionWithDeps } = await import('./ai-vault-delete') const provider = {} as IFilesystemProvider @@ -789,9 +790,14 @@ describe('deleteAiVaultSession', () => { } it('invalidates every AI Vault cache after a real delete', async () => { + const invalidateMultiHostListCache = vi.fn() + const invalidateBackgroundCache = vi.fn().mockResolvedValue(undefined) mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) - const result = await _internals.deleteAiVaultSession(args) + const result = await deleteAiVaultSessionWithDeps(args, { + invalidateMultiHostListCache, + invalidateBackgroundCache + }) expect(result).toEqual({ outcome: 'deleted' }) expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( @@ -800,11 +806,12 @@ describe('deleteAiVaultSession', () => { sessionId: args.sessionId, filePath: args.filePath, executionHostId: 'local' - }), - expect.objectContaining({ getSessionLiveness: expect.any(Function) }) + }) ) + expect(invalidateMultiHostListCache).toHaveBeenCalledTimes(1) expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1) expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath) + expect(invalidateBackgroundCache).toHaveBeenCalledWith([args.filePath]) }) it('does not invalidate any cache when the executor rejects (e.g. non-local host)', async () => { @@ -850,8 +857,7 @@ describe('deleteAiVaultSession', () => { reason: 'invalid-path' }) expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '' }), - expect.objectContaining({ getSessionLiveness: expect.any(Function) }) + expect.objectContaining({ filePath: '' }) ) expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 3f4e2d49af6..8f4b411cb6f 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -24,7 +24,7 @@ import { type AiVaultSubagentListArgs, type AiVaultSubagentListResult } from '../../shared/ai-vault-types' -import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read' +import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-handler' import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' import { LOCAL_EXECUTION_HOST_ID, @@ -71,7 +71,6 @@ type AiVaultHandlerOptions = AiVaultSessionSources & getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver - getSessionLiveness?: Parameters[1]['getSessionLiveness'] } let scanCoordinator = new AiVaultScanCoordinator() @@ -80,10 +79,7 @@ const listCancellations = createSenderScopedRequestCancellations() // Shared by the IPC registration and the test internals: a delete must drop // the multi-host leg cache, which this module owns the only caller of. const aiVaultDeleteDeps = { - invalidateMultiHostListCache: invalidateAiVaultHostLegCache, - getSessionLiveness: ( - target: Parameters>[0] - ) => handlerOptions.getSessionLiveness?.(target) ?? Promise.resolve('unknown' as const) + invalidateMultiHostListCache: invalidateAiVaultHostLegCache } const resolveAiVaultSessionTitles = ( @@ -143,7 +139,7 @@ async function scanAiVaultSessionsByHostScope( const depth = requestedAiVaultSessionDepth(args) const scopePaths = args?.scopePaths ?? [] if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { - return scanLocalAiVaultSessions(args, signal) + return scanLocalAiVaultSessionsAsIssue(args, signal) } if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() @@ -153,7 +149,7 @@ async function scanAiVaultSessionsByHostScope( ...(sshHosts.issue ? [sshHosts.issue] : []) ] const scannedResults = await Promise.all([ - scanLocalAiVaultSessionsForAllScope(args, signal), + scanLocalAiVaultSessionsAsIssue(args, signal), ...sshHosts.hostInfos.map((hostInfo) => scanHostLegWithCache({ cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`, @@ -230,8 +226,10 @@ function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targ // Why: the SSH legs already degrade to an issue row so one bad host can't take // the shared Promise.all down; the local leg can throw too (parse-cache load, -// WSL home resolution) and would otherwise discard every host's sessions. -async function scanLocalAiVaultSessionsForAllScope( +// WSL home resolution, scanner service supervision) and would otherwise discard +// every host's sessions under 'all', or replace the list with a raw error string +// under single-host scope. +async function scanLocalAiVaultSessionsAsIssue( args: AiVaultListArgs | undefined, signal: AbortSignal | undefined ): Promise { diff --git a/src/main/ipc/bitbucket.ts b/src/main/ipc/bitbucket.ts new file mode 100644 index 00000000000..2b9b030c691 --- /dev/null +++ b/src/main/ipc/bitbucket.ts @@ -0,0 +1,59 @@ +import { ipcMain } from 'electron' +import { + connectBitbucket, + disconnectBitbucket, + getBitbucketConnectionStatus, + type BitbucketConnectArgs, + type BitbucketConnectResult, + type BitbucketConnectionStatus +} from '../bitbucket/credential-connection' +import { _resetPreflightCache } from './preflight' + +function optionalString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function normalizeConnectInput(value: unknown): BitbucketConnectArgs | null { + if (!value || typeof value !== 'object') { + return null + } + const raw = value as Record + if (raw.authMode !== 'token' && raw.authMode !== 'basic') { + return null + } + return { + authMode: raw.authMode, + accessToken: optionalString(raw.accessToken), + email: optionalString(raw.email), + apiToken: optionalString(raw.apiToken), + baseUrl: optionalString(raw.baseUrl) + } +} + +export function registerBitbucketHandlers(): void { + ipcMain.handle( + 'bitbucket:connect', + async (_event, args: unknown): Promise => { + const input = normalizeConnectInput(args) + if (!input) { + return { ok: false, error: 'Invalid Bitbucket credentials' } + } + const result = await connectBitbucket(input) + if (result.ok) { + // Preflight caches source-control status per session; reset so the card + // reflects the new connection without a relaunch. + _resetPreflightCache() + } + return result + } + ) + + ipcMain.handle('bitbucket:disconnect', async (): Promise => { + disconnectBitbucket() + _resetPreflightCache() + }) + + ipcMain.handle('bitbucket:status', async (): Promise => { + return getBitbucketConnectionStatus() + }) +} diff --git a/src/main/ipc/claude-usage.ts b/src/main/ipc/claude-usage.ts deleted file mode 100644 index 3496706e8e3..00000000000 --- a/src/main/ipc/claude-usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ipcMain } from 'electron' -import type { ClaudeUsageStore } from '../claude-usage/store' -import type { - ClaudeUsageBreakdownKind, - ClaudeUsageRange, - ClaudeUsageScope -} from '../../shared/claude-usage-types' - -export function registerClaudeUsageHandlers(claudeUsage: ClaudeUsageStore): void { - ipcMain.handle('claudeUsage:getScanState', () => claudeUsage.getScanState()) - ipcMain.handle('claudeUsage:setEnabled', (_event, args: { enabled: boolean }) => - claudeUsage.setEnabled(args.enabled) - ) - ipcMain.handle('claudeUsage:refresh', (_event, args?: { force?: boolean }) => - claudeUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'claudeUsage:getSnapshot', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; limit?: number }) => - claudeUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'claudeUsage:getSummary', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange }) => - claudeUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'claudeUsage:getDaily', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange }) => - claudeUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'claudeUsage:getBreakdown', - ( - _event, - args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; kind: ClaudeUsageBreakdownKind } - ) => claudeUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'claudeUsage:getRecentSessions', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; limit?: number }) => - claudeUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/codex-usage.ts b/src/main/ipc/codex-usage.ts deleted file mode 100644 index 3db15a92076..00000000000 --- a/src/main/ipc/codex-usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ipcMain } from 'electron' -import type { CodexUsageStore } from '../codex-usage/store' -import type { - CodexUsageBreakdownKind, - CodexUsageRange, - CodexUsageScope -} from '../../shared/codex-usage-types' - -export function registerCodexUsageHandlers(codexUsage: CodexUsageStore): void { - ipcMain.handle('codexUsage:getScanState', () => codexUsage.getScanState()) - ipcMain.handle('codexUsage:setEnabled', (_event, args: { enabled: boolean }) => - codexUsage.setEnabled(args.enabled) - ) - ipcMain.handle('codexUsage:refresh', (_event, args?: { force?: boolean }) => - codexUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'codexUsage:getSnapshot', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange; limit?: number }) => - codexUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'codexUsage:getSummary', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange }) => - codexUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'codexUsage:getDaily', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange }) => - codexUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'codexUsage:getBreakdown', - ( - _event, - args: { scope: CodexUsageScope; range: CodexUsageRange; kind: CodexUsageBreakdownKind } - ) => codexUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'codexUsage:getRecentSessions', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange; limit?: number }) => - codexUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts index 8df189a4abb..bbccfb41e93 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -6,7 +6,7 @@ import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' import { listEnvironments } from '../../shared/runtime-environment-store' import { upsertEphemeralVmRuntime } from '../../shared/ephemeral-vm-runtime-store' -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() const { handleMock, removeHandlerMock, diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index fcd004dcd06..bb143987c96 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -645,7 +645,7 @@ describe('fs:importExternalPaths', () => { ) const mib = 1024 * 1024 const regularSize = 25 * mib - const overflowSize = 1 * mib + const overflowSize = Number(mib) const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) lstatMock.mockImplementation(async (p: string) => { @@ -683,7 +683,7 @@ describe('fs:importExternalPaths', () => { ) openMock.mockImplementation(async (p: string) => { const fileIndex = filePaths.indexOf(p) - if (fileIndex >= 0 && fileIndex < filePaths.length - 1) { + if (fileIndex !== -1 && fileIndex < filePaths.length - 1) { return { stat: vi.fn().mockResolvedValue({ size: regularSize, diff --git a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts index 5d5cf163746..5d7507a5615 100644 --- a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts +++ b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts @@ -10,7 +10,7 @@ const { gitSpawnMock } = vi.hoisted(() => ({ })) vi.mock('../git/runner', () => ({ - gitSpawn: gitSpawnMock + gitSpawnAfterWindowsEnvironmentReady: gitSpawnMock })) import { listFilesWithGit } from './filesystem-list-files-git-fallback' @@ -56,11 +56,13 @@ describe('main Quick Open git directory expansion', () => { const primary = createMockProcess() const ignored = createMockProcess() gitSpawnMock - .mockReturnValueOnce(revParse) - .mockReturnValueOnce(primary) - .mockReturnValueOnce(ignored) + .mockResolvedValueOnce(revParse) + .mockResolvedValueOnce(primary) + .mockResolvedValueOnce(ignored) const promise = listFilesWithGit(root, [], {}) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) revParse.emit('close', 0, null) await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) ;(primary.stdout as unknown as EventEmitter).emit( @@ -86,12 +88,14 @@ describe('main Quick Open git directory expansion', () => { const primary = createMockProcess() const ignored = createMockProcess() gitSpawnMock - .mockReturnValueOnce(revParse) - .mockReturnValueOnce(primary) - .mockReturnValueOnce(ignored) + .mockResolvedValueOnce(revParse) + .mockResolvedValueOnce(primary) + .mockResolvedValueOnce(ignored) const controller = new AbortController() const promise = listFilesWithGit(root, [], {}, controller.signal) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) revParse.emit('close', 0, null) await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) controller.abort() @@ -100,4 +104,92 @@ describe('main Quick Open git directory expansion', () => { expect(primary.kill).toHaveBeenCalled() expect(ignored.kill).toHaveBeenCalled() }) + + it('kills a repository probe returned after cancellation', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-main-git-probe-race-')) + tempDirs.push(root) + const revParse = createMockProcess() + let resolveRevParse!: (child: ChildProcess) => void + gitSpawnMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveRevParse = resolve + }) + ) + + const controller = new AbortController() + const promise = listFilesWithGit(root, [], {}, controller.signal) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledOnce()) + controller.abort() + resolveRevParse(revParse) + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(revParse.kill).toHaveBeenCalled() + expect(revParse.listenerCount('close')).toBe(0) + }) + + it('kills file scans returned after cancellation', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-main-git-scan-race-')) + tempDirs.push(root) + const revParse = createMockProcess() + const primary = createMockProcess() + const ignored = createMockProcess() + let resolvePrimary!: (child: ChildProcess) => void + let resolveIgnored!: (child: ChildProcess) => void + gitSpawnMock + .mockResolvedValueOnce(revParse) + .mockReturnValueOnce( + new Promise((resolve) => { + resolvePrimary = resolve + }) + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveIgnored = resolve + }) + ) + + const controller = new AbortController() + const promise = listFilesWithGit(root, [], {}, controller.signal) + await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) + revParse.emit('close', 0, null) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) + controller.abort() + resolvePrimary(primary) + resolveIgnored(ignored) + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(primary.kill).toHaveBeenCalled() + expect(ignored.kill).toHaveBeenCalled() + }) + + it('cancels a pending sibling spawn when the primary scan fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-main-git-sibling-failure-')) + tempDirs.push(root) + const revParse = createMockProcess() + const primary = createMockProcess() + let pendingSignal: AbortSignal | undefined + gitSpawnMock + .mockResolvedValueOnce(revParse) + .mockResolvedValueOnce(primary) + .mockImplementationOnce( + (_args: string[], options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + pendingSignal = options.signal + options.signal?.addEventListener( + 'abort', + () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + { once: true } + ) + }) + ) + + const promise = listFilesWithGit(root, [], {}) + await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) + revParse.emit('close', 0, null) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) + primary.emit('error', new Error('primary failed')) + + await expect(promise).rejects.toThrow('primary failed') + expect(pendingSignal?.aborted).toBe(true) + }) }) diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 0cf672fa30f..69dde7afaf3 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -1,17 +1,19 @@ -import { execFile as execFileCallback } from 'node:child_process' +import { execFile as execFileCallback, spawn, type SpawnOptions } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { promisify } from 'node:util' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' -const { checkRgAvailableMock } = vi.hoisted(() => ({ - checkRgAvailableMock: vi.fn() +const { wslAwareSpawnMock } = vi.hoisted(() => ({ + wslAwareSpawnMock: vi.fn() })) -vi.mock('./rg-availability', () => ({ - checkRgAvailable: checkRgAvailableMock +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock })) import { listQuickOpenFiles } from './filesystem-list-files' @@ -50,6 +52,16 @@ async function initRepo(repoPath: string): Promise { describe('filesystem-list-files real git fallback', () => { let tempDir: string | null = null + beforeEach(() => { + wslAwareSpawnMock.mockImplementation( + (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => + spawn('orca-definitely-missing-rg', [], { + cwd: options.cwd, + stdio: options.stdio + }) + ) + }) + afterEach(async () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }) @@ -59,7 +71,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('returns real paths for UTF-8 filenames from the git fallback', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-git-fallback-')) const repoPath = join(tempDir, 'repo') await execFile('git', ['init', '-q', repoPath]) @@ -71,7 +82,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('fills nested git repos from gitlink and untracked embedded-repo entries', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-monorepo-')) const repoPath = join(tempDir, 'parent') const appPath = join(repoPath, 'packages', 'app') @@ -117,7 +127,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('walks a non-git root instead of returning an empty git fallback result', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-non-git-')) await writeRel(tempDir, 'folder/file.ts') @@ -127,7 +136,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('bounds a non-git readdir fallback without treating the limit as an error', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-')) await writeRel(tempDir, 'a.ts') await writeRel(tempDir, 'b.ts') @@ -139,7 +147,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) const repoPath = join(tempDir, 'repo') await initRepo(repoPath) @@ -151,7 +158,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('resolves an empty repo as an empty list', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-empty-repo-')) const repoPath = join(tempDir, 'repo') await initRepo(repoPath) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index ff390d13caf..bebab27808d 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -1,5 +1,9 @@ import type { ChildProcess } from 'node:child_process' -import { gitSpawn } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../git/wsl-linked-worktree-git-routing' import { buildGitLsFilesArgsForQuickOpen, shouldExcludeQuickOpenRelPath, @@ -28,12 +32,26 @@ async function isInsideGitWorkTree( if (signal?.aborted) { throw fileListingCancellationError(signal) } + if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { + try { + await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro, { signal }) + } catch (error) { + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + throw error + } + } + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + const child = await gitSpawnAfterWindowsEnvironmentReady(['rev-parse', '--is-inside-work-tree'], { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(signal ? { signal } : {}), + stdio: ['ignore', 'ignore', 'ignore'] + }) return new Promise((resolve, reject) => { - const child = gitSpawn(['rev-parse', '--is-inside-work-tree'], { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'ignore', 'ignore'] - }) let done = false let timer: ReturnType const cleanup = (): void => { @@ -71,6 +89,9 @@ async function isInsideGitWorkTree( child.kill() finish(false) }, 10_000) + if (signal?.aborted) { + cancel() + } }) } @@ -104,8 +125,17 @@ export async function listFilesWithGit( reject: (error: Error) => void resolve: () => void }[] = [] + const scanController = new AbortController() - const runGitLsFiles = (args: string[]): Promise => { + const runGitLsFiles = async (args: string[]): Promise => { + // Why: git ls-files outputs paths relative to cwd, so we set cwd to + // rootPath and use the output directly — no prefix stripping needed. + const child = await gitSpawnAfterWindowsEnvironmentReady(['ls-files', ...args], { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + signal: scanController.signal, + stdio: ['ignore', 'pipe', 'pipe'] + }) return new Promise((resolve, reject) => { let buf = '' let done = false @@ -138,13 +168,6 @@ export async function listFilesWithGit( return maxResults !== undefined && directFileCandidates.size >= maxResults } - // Why: git ls-files outputs paths relative to cwd, so we set cwd to - // rootPath and use the output directly — no prefix stripping needed. - const child = gitSpawn(['ls-files', ...args], { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) let timer: ReturnType const cleanup = (): void => { clearTimeout(timer) @@ -229,6 +252,10 @@ export async function listFilesWithGit( child.kill() rejectPass(new Error('git ls-files timed out')) }, 10000) + if (scanController.signal.aborted) { + child.kill() + rejectPass(fileListingCancellationError(scanController.signal)) + } }) } @@ -258,14 +285,17 @@ export async function listFilesWithGit( } } - const onAbort = (): void => killSurvivors('git ls-files cancelled') + const onAbort = (): void => { + scanController.abort(signal?.reason) + killSurvivors('git ls-files cancelled') + } signal?.addEventListener('abort', onAbort, { once: true }) try { const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { - if (!signal?.aborted) { + if (!scanController.signal.aborted) { console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) } }) @@ -280,6 +310,7 @@ export async function listFilesWithGit( } } } catch (err) { + scanController.abort(err) killSurvivors() if (signal?.aborted) { throw fileListingCancellationError(signal) diff --git a/src/main/ipc/filesystem-list-files-install-rg.test.ts b/src/main/ipc/filesystem-list-files-install-rg.test.ts index 7f4c34f5eda..f0221d8e24e 100644 --- a/src/main/ipc/filesystem-list-files-install-rg.test.ts +++ b/src/main/ipc/filesystem-list-files-install-rg.test.ts @@ -1,16 +1,21 @@ +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' const { listFilesWithGitMock, resolveAuthorizedPathMock, checkRgAvailableMock, - getLocalGitOptionsForRegisteredWorktreeMock + getLocalGitOptionsForRegisteredWorktreeMock, + wslAwareSpawnMock } = vi.hoisted(() => ({ listFilesWithGitMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), checkRgAvailableMock: vi.fn(), - getLocalGitOptionsForRegisteredWorktreeMock: vi.fn() + getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + wslAwareSpawnMock: vi.fn() })) vi.mock('./filesystem-list-files-git-fallback', () => ({ @@ -29,14 +34,39 @@ vi.mock('./local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock })) +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock +})) + import { listQuickOpenFiles } from './filesystem-list-files' +function createStartedRipgrepProcess(): ChildProcess { + const child = new EventEmitter() as ChildProcess + ;(child as unknown as Record).stdout = new EventEmitter() + ;( + (child as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(child as unknown as Record).stderr = new EventEmitter() + ;(child as unknown as Record).kill = vi.fn() + ;(child as unknown as Record).exitCode = null + ;(child as unknown as Record).signalCode = null + Object.defineProperty(child, 'pid', { value: 1 }) + return child +} + describe('filesystem-list-files ripgrep guidance', () => { beforeEach(() => { vi.clearAllMocks() resolveAuthorizedPathMock.mockImplementation(async (path) => path) - checkRgAvailableMock.mockResolvedValue(false) + checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) + wslAwareSpawnMock.mockImplementation( + (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => + spawn('orca-definitely-missing-rg', [], { stdio: options.stdio }) + ) }) it('turns only a readdir budget failure into install guidance', async () => { @@ -51,6 +81,31 @@ describe('filesystem-list-files ripgrep guidance', () => { ) }) + it('keeps the WSL preflight and falls back before starting real rg', async () => { + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + listFilesWithGitMock.mockResolvedValue(['src/index.ts']) + + await expect(listQuickOpenFiles('C:\\repo', {} as Store)).resolves.toEqual(['src/index.ts']) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + + it("falls back when a native launcher exits outside ripgrep's contract", async () => { + const primary = createStartedRipgrepProcess() + const ignored = createStartedRipgrepProcess() + let callIndex = 0 + wslAwareSpawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) + listFilesWithGitMock.mockResolvedValue(['src/index.ts']) + + const listing = listQuickOpenFiles('/workspace', {} as Store) + await Promise.resolve() + primary.emit('close', 127, null) + + await expect(listing).resolves.toEqual(['src/index.ts']) + expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) + }) + it('keeps cancellation and Git errors unchanged', async () => { const cancellation = new Error('File listing cancelled') listFilesWithGitMock.mockRejectedValueOnce(cancellation) diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 946f8653dd6..7debd516760 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -36,6 +36,7 @@ import { listQuickOpenFiles } from './filesystem-list-files' import { EventEmitter } from 'node:events' import type { Store } from '../persistence' import type { ChildProcess } from 'node:child_process' +import { FileListingCancelledError } from '../../shared/file-listing-cancellation' const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -55,10 +56,24 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } +function createMissingRipgrepProcess(): ChildProcess { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: undefined }) + void Promise.resolve().then(() => child.emit('close', -2, null)) + return child +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 12; index++) { + await Promise.resolve() + } +} + function isIgnoredRgPass(args: string[]): boolean { return args.includes('--no-ignore-vcs') } @@ -93,6 +108,8 @@ describe('filesystem-list-files', () => { expect(p1.kill).toHaveBeenCalled() expect(p2.kill).not.toHaveBeenCalled() expect(spawnMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version') }) it('merges normal files and ignored files and filters correctly', async () => { @@ -108,6 +125,8 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) + await flushMicrotasks() + expect(spawnMock).toHaveBeenCalledTimes(2) // Simulate stdout output for normal files setTimeout(() => { @@ -192,6 +211,24 @@ describe('filesystem-list-files', () => { await expect(promise).resolves.toEqual(['src/index.ts']) }) + it('does not mistake a WSL-routed executable exit 127 for missing rg', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + Object.defineProperty(p1, 'pid', { value: 1 }) + Object.defineProperty(p2, 'pid', { value: 2 }) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + + const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store) + setTimeout(() => { + p1.emit('close', 127, null) + p2.emit('close', 0, null) + }, 0) + + await expect(promise).rejects.toThrow('rg exited with code 127') + expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) + }) + it('rejects rg failures instead of resolving a false-empty list', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -330,16 +367,90 @@ describe('filesystem-list-files', () => { expect(result).toEqual(['valid.ts']) }) - describe('git ls-files fallback', () => { - it('falls back to git ls-files when rg is not available', async () => { - checkRgAvailableMock.mockResolvedValue(false) + it('lets cancellation win a native-unavailable race before Git starts', async () => { + const first = createMockProcess() + Object.defineProperty(first, 'pid', { value: undefined }) + spawnMock.mockReturnValue(first) + const controller = new AbortController() + const cancellation = new FileListingCancelledError('superseded') + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + controller.signal + ) + await flushMicrotasks() + controller.abort(cancellation) + first.emit('close', -2, null) + + await expect(promise).rejects.toBe(cancellation) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => first.emit('error', error)).not.toThrow() + expect(first.listenerCount('error')).toBe(0) + }) + + describe('git ls-files fallback', () => { + it('kills only the admitted pass when ignored rg fails before spawn', async () => { + const primary = createMockProcess() + const missingIgnored = createMockProcess() + const revParse = createMockProcess() + const gitPrimary = createMockProcess() + const gitIgnored = createMockProcess() + Object.defineProperty(missingIgnored, 'pid', { value: undefined }) + let gitPassIndex = 0 + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return isIgnoredRgPass(args) ? missingIgnored : primary + } + if (args.includes('rev-parse')) { + return revParse + } + if (args.includes('ls-files')) { + return gitPassIndex++ === 0 ? gitPrimary : gitIgnored + } + return createMockProcess() + }) + + const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) + await flushMicrotasks() + expect(spawnMock.mock.calls.filter((call) => call[0] === 'rg')).toHaveLength(2) + missingIgnored.emit('close', -2, null) + + await vi.waitFor(() => expect(primary.kill).toHaveBeenCalled()) + expect(missingIgnored.kill).not.toHaveBeenCalled() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => missingIgnored.emit('error', error)).not.toThrow() + await vi.waitFor(() => + expect( + spawnMock.mock.calls.some((call) => (call[1] as string[]).includes('rev-parse')) + ).toBe(true) + ) + revParse.emit('close', 0, null) + await vi.waitFor(() => + expect( + spawnMock.mock.calls.filter((call) => (call[1] as string[]).includes('ls-files')) + ).toHaveLength(2) + ) + gitPrimary.emit('close', 0, null) + gitIgnored.emit('close', 0, null) + + await expect(promise).resolves.toEqual([]) + expect(missingIgnored.listenerCount('error')).toBe(0) + }) + + it('falls back to git ls-files when rg is not available', async () => { let callIndex = 0 const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -378,9 +489,10 @@ describe('filesystem-list-files', () => { const result = await promise - // Verify rg was never called + // The primary real command doubles as the availability check. const rgCalls = spawnMock.mock.calls.filter((call) => call[0] === 'rg') - expect(rgCalls.length).toBe(0) + expect(rgCalls.length).toBe(1) + expect(rgCalls.every((call) => !(call[1] as string[]).includes('--version'))).toBe(true) // Verify git ls-files was called const gitCalls = spawnMock.mock.calls.filter( @@ -402,13 +514,15 @@ describe('filesystem-list-files', () => { }) it('stops after primary Git files fill the result budget', async () => { - checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -439,10 +553,12 @@ describe('filesystem-list-files', () => { }) it('does not let a discarded Git directory placeholder consume the result budget', async () => { - checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() const primary = createMockProcess() spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -469,14 +585,15 @@ describe('filesystem-list-files', () => { }) it('git fallback applies hidden dir blocklist', async () => { - checkRgAvailableMock.mockResolvedValue(false) - const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -518,7 +635,6 @@ describe('filesystem-list-files', () => { }) it('settles and detaches git fallback scans that ignore timeout kills', async () => { - checkRgAvailableMock.mockResolvedValue(false) vi.useFakeTimers() try { @@ -528,6 +644,9 @@ describe('filesystem-list-files', () => { let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -541,13 +660,9 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() revParseProc.emit('close', 0, null) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0partial') @@ -567,7 +682,6 @@ describe('filesystem-list-files', () => { }) it('keeps primary results when only the ignored pass times out', async () => { - checkRgAvailableMock.mockResolvedValue(false) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.useFakeTimers() @@ -578,6 +692,9 @@ describe('filesystem-list-files', () => { let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -591,13 +708,9 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() revParseProc.emit('close', 0, null) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() ;(gitP1.stdout as unknown as EventEmitter).emit( 'data', diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 7edb688de72..c1666234cd0 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -17,6 +17,12 @@ import { import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' import { listFilesWithGit } from './filesystem-list-files-git-fallback' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../../shared/ripgrep-process-availability' export async function listQuickOpenFiles( rootPath: string, @@ -37,13 +43,9 @@ export async function listQuickOpenFiles( // every worktree instead of just the active one. The shared helper // normalizes, validates, and root-relativizes every input. const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, excludePaths) + const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - // Why: checking rg availability upfront avoids a race condition where - // spawn('rg') emits 'close' before 'error' on some platforms, causing - // the handler to resolve with empty results before the git fallback - // can run. - const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + const listWithoutRipgrep = async (): Promise => { try { return await listFilesWithGit( authorizedRootPath, @@ -59,6 +61,12 @@ export async function listQuickOpenFiles( throw new Error(await buildInstallRgMessage(err)) } } + if ( + wslDistroForOutput && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { + return listWithoutRipgrep() + } const files = new Set() const children: { @@ -68,8 +76,6 @@ export async function listQuickOpenFiles( }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. - const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - const { primary, ignoredPass } = buildRgArgsForQuickOpen({ // Why: rg evaluates root-relative exclude globs against cwd only when the // search target is cwd-relative. With an absolute target, `!packages/app` @@ -86,6 +92,8 @@ export async function listQuickOpenFiles( let buf = '' let done = false let parseablePathCount = 0 + let processErrorObserved = false + let unavailableExitObserved = false const processLine = (rawLine: string): boolean => { const translated = @@ -138,12 +146,27 @@ export async function listQuickOpenFiles( /* drain */ } const handleError = (): void => { + processErrorObserved = true // Why: treat spawn errors like an abnormal exit — discard residual // buffer so a truncated final byte sequence cannot leak as a path. buf = '' + if (isRipgrepUnavailableExit(child, null, null)) { + finish(new RipgrepUnavailableError()) + return + } finish(new Error('rg failed to start')) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + buf = '' + finish(new RipgrepUnavailableError()) + return + } if (signal) { // Why: a signal exit means timeout/OOM/external kill. Returning the // already-streamed prefix would recreate the false-empty bug this @@ -179,6 +202,10 @@ export async function listQuickOpenFiles( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) if (err) { reject(err) } else { @@ -197,7 +224,7 @@ export async function listQuickOpenFiles( // Why: on timeout, the buffer is likely truncated mid-path. Discard // it so Quick Open never displays a malformed entry. buf = '' - child.kill() + killSpawnedRipgrepProcess(child) finish(new Error('rg list timed out')) }, 10000) }) @@ -213,7 +240,7 @@ export async function listQuickOpenFiles( } entry.finish() if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } } } @@ -225,24 +252,30 @@ export async function listQuickOpenFiles( } entry.finish() if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } } } - try { + const primaryRun = runRg(primary) if (maxResults === undefined) { - await Promise.all([runRg(primary), runRg(ignoredPass)]) + // Why: a pid-less primary proves launch failure; avoid doubling the failed spawn. + await (children[0]?.child.pid === undefined + ? primaryRun + : Promise.all([primaryRun, runRg(ignoredPass)])) } else { // Why: ignored-file output can be much larger and faster than the primary // pass; let source files claim the bounded autocomplete budget first. - await runRg(primary) + await primaryRun if (files.size < maxResults) { await runRg(ignoredPass) } } } catch (err) { killSurvivors() + if (err instanceof RipgrepUnavailableError) { + return listWithoutRipgrep() + } throw err } return Array.from(files).slice(0, maxResults) diff --git a/src/main/ipc/filesystem-search-git.test.ts b/src/main/ipc/filesystem-search-git.test.ts index e6d29acb3cd..ae73b0aa2e2 100644 --- a/src/main/ipc/filesystem-search-git.test.ts +++ b/src/main/ipc/filesystem-search-git.test.ts @@ -184,6 +184,9 @@ describe('filesystem-search-git', () => { const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100) + await vi.waitFor(() => + expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBeGreaterThan(0) + ) ;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x001\x00ok\npartial') await vi.runOnlyPendingTimersAsync() diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 065dbd11dad..e997ddd2e02 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -7,7 +7,11 @@ import { ingestGitGrepLine, SEARCH_TIMEOUT_MS } from '../../shared/text-search' -import { gitSpawn } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../git/wsl-linked-worktree-git-routing' /** * Fallback text search using git grep. Used when rg is not available. @@ -16,24 +20,27 @@ import { gitSpawn } from '../git/runner' * is launched from a desktop entry (which inherits a minimal system PATH). * git grep is always available since this is a git-focused app. */ -export function searchWithGitGrep( +export async function searchWithGitGrep( rootPath: string, args: SearchOptions, maxResults: number, localGitOptions: { wslDistro?: string } = {} ): Promise { + if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro) + } + const gitArgs = buildGitGrepArgs(args.query, args) + const child = await gitSpawnAfterWindowsEnvironmentReady(gitArgs, { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + stdio: ['ignore', 'pipe', 'pipe'] + }) return new Promise((resolve) => { - const gitArgs = buildGitGrepArgs(args.query, args) const matchRegex = buildSubmatchRegex(args.query, args) const acc = createAccumulator() let stdoutBuffer = '' let done = false - const child = gitSpawn(gitArgs, { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) let killTimeout: ReturnType function resolveOnce(): void { diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 8d9881b2261..6c677c73d6d 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -7,6 +7,7 @@ const { resolveAuthorizedPathMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, + searchWithGitGrepMock, wslAwareSpawnMock, toWindowsWslPathMock } = vi.hoisted(() => ({ @@ -14,11 +15,12 @@ const { resolveAuthorizedPathMock: vi.fn(), checkRgAvailableMock: vi.fn(), getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), wslAwareSpawnMock: vi.fn(), toWindowsWslPathMock: vi.fn((value: string) => value) })) -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() vi.mock('electron', () => ({ ipcMain: { @@ -56,7 +58,7 @@ vi.mock('./filesystem-mutations', () => ({ })) vi.mock('./filesystem-search-git', () => ({ - searchWithGitGrep: vi.fn() + searchWithGitGrep: searchWithGitGrepMock })) vi.mock('./local-worktree-runtime-options', () => ({ @@ -87,6 +89,12 @@ function createMockProcess(): ChildProcess { return p } +async function flushMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + describe('filesystem rg search timeout', () => { beforeEach(() => { handlers.clear() @@ -120,6 +128,7 @@ describe('filesystem rg search timeout', () => { const result = await promise expect(result.truncated).toBe(true) + expect(checkRgAvailableMock).not.toHaveBeenCalled() expect(child.kill).toHaveBeenCalled() expect((child.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((child.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) @@ -130,8 +139,77 @@ describe('filesystem rg search timeout', () => { } }) + it.each(['error-first', 'close-first'] as const)( + 'falls back once when a native launch failure is %s', + async (order) => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: undefined }) + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise + await flushMicrotasks() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => child.emit('error', error)).not.toThrow() + child.emit('close', -2, null) + } else { + child.emit('close', -2, null) + expect(() => child.emit('error', error)).not.toThrow() + } + + await expect(promise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } + ) + + it('keeps post-spawn errors on the existing empty-result path', async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise<{ files: unknown[] }> + await flushMicrotasks() + child.emit('error', new Error('post-spawn failure')) + + await expect(promise).resolves.toMatchObject({ files: [] }) + expect(searchWithGitGrepMock).not.toHaveBeenCalled() + }) + + it("falls back when a native launcher exits outside ripgrep's contract", async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise + await flushMicrotasks() + child.emit('close', 127, null) + + await expect(promise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + }) + it('routes rg through the registered WSL project runtime for Windows-path worktrees', async () => { const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) wslAwareSpawnMock.mockReturnValue(child) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) registerFilesystemHandlers({} as never) @@ -142,12 +220,13 @@ describe('filesystem rg search timeout', () => { ) as Promise setTimeout(() => { - child.emit('close') + child.emit('close', 127, null) }, 10) await promise expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(searchWithGitGrepMock).not.toHaveBeenCalled() expect(wslAwareSpawnMock).toHaveBeenCalledWith( 'rg', expect.any(Array), @@ -158,6 +237,23 @@ describe('filesystem rg search timeout', () => { ) }) + it('keeps the WSL search preflight and falls back before starting real rg', async () => { + const fallback = { files: [], totalMatches: 0, truncated: false } + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'ok' } + ) as Promise + + await expect(promise).resolves.toBe(fallback) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + it('translates WSL rg output for Windows-path project search results', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) diff --git a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts index f557ebca379..7d27d4ca780 100644 --- a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts +++ b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts @@ -22,7 +22,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> const WORKTREE_PATH = '/home/me/repo' const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' } diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index 80d45c90e43..e605cd6167c 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -38,7 +38,7 @@ import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('local filesystem watcher large batches', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index 0e15ad43fc7..eb161c1ba30 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -47,7 +47,7 @@ import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' import { subscribeViaWatcherProcess } from './parcel-watcher-process' import { WatcherProcessFailure } from './parcel-watcher-process-failure' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('local filesystem watcher unsubscribe cleanup', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-native-capacity.test.ts b/src/main/ipc/filesystem-watcher-native-capacity.test.ts index 41db2d35e52..670b7eb2af3 100644 --- a/src/main/ipc/filesystem-watcher-native-capacity.test.ts +++ b/src/main/ipc/filesystem-watcher-native-capacity.test.ts @@ -28,7 +28,7 @@ import { WatcherChildCapacityError } from './parcel-watcher-child-registry' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> function fillWatcherChildCapacity(): (() => void)[] { return Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => { diff --git a/src/main/ipc/filesystem-watcher-real.test.ts b/src/main/ipc/filesystem-watcher-real.test.ts index 34f2bcc91e1..a813be69e28 100644 --- a/src/main/ipc/filesystem-watcher-real.test.ts +++ b/src/main/ipc/filesystem-watcher-real.test.ts @@ -26,7 +26,7 @@ vi.mock('electron', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> type FsChangedCall = { worktreePath: string; events: { kind: string; absolutePath: string }[] } diff --git a/src/main/ipc/filesystem-watcher-remote-batch.test.ts b/src/main/ipc/filesystem-watcher-remote-batch.test.ts index 6d964c2b2d4..8666a40c906 100644 --- a/src/main/ipc/filesystem-watcher-remote-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-batch.test.ts @@ -23,7 +23,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> type WatchCallback = (events: FsChangeEvent[]) => void const WORKTREE_PATH = '/home/me/repo' diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts index 6a67f3a8fb7..60e25db9040 100644 --- a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -19,7 +19,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('remote filesystem watcher cancellation', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts index 869c8bebf45..0eecfbd82e9 100644 --- a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts @@ -29,7 +29,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('remote filesystem watcher re-arm', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-removal-deadline.test.ts b/src/main/ipc/filesystem-watcher-removal-deadline.test.ts index 52a48e9a06d..9eabdb8964e 100644 --- a/src/main/ipc/filesystem-watcher-removal-deadline.test.ts +++ b/src/main/ipc/filesystem-watcher-removal-deadline.test.ts @@ -52,7 +52,7 @@ import { stat } from 'node:fs/promises' import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' import { subscribeViaWatcherProcess } from './parcel-watcher-process' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('local filesystem watcher removal deadline', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher-terminal-resync.test.ts b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts index 8a08c5f07d5..31e87d7a4c9 100644 --- a/src/main/ipc/filesystem-watcher-terminal-resync.test.ts +++ b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts @@ -28,7 +28,7 @@ import { restoreRemoteWatcherAfterFailedRemoval } from './filesystem-watcher' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> type TerminalErrorHandler = (error: Error) => void const WORKTREE_PATH = '/home/me/repo' diff --git a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts index d629d74dc02..3473caa8a73 100644 --- a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts +++ b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts @@ -30,7 +30,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' import { stat } from 'node:fs/promises' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> describe('filesystem watcher unwatchable root cache', () => { const handlers: HandlerMap = {} diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 1e9e3cf509a..6fff613e0d9 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -60,7 +60,7 @@ import { import { acquireWatcherRemovalGate } from './watcher-removal-gate' import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window' -type HandlerMap = Record Promise | unknown> +type HandlerMap = Record unknown> /** Remote fs:changed rides the shared debounce window, so drain it before asserting sends. */ const emitRemote = async (onEvents: (e: unknown[]) => void, events: unknown[]) => { diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index ad68010cbb7..f96667751d2 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() const { handleMock, showSaveDialogMock, @@ -3098,6 +3098,42 @@ describe('registerFilesystemHandlers', () => { ) }) + it('forwards the pinned head through SSH branch diff queries', async () => { + const result = { + kind: 'text', + originalContent: 'left', + modifiedContent: 'right', + originalIsBinary: false, + modifiedIsBinary: false + } + const getBranchDiff = vi.fn().mockResolvedValue([result]) + getSshGitProviderMock.mockReturnValue({ getBranchDiff }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:branchDiff')!(null, { + worktreePath: '/home/user/project', + compare: { + baseRef: 'origin/main', + baseOid: 'base-oid', + headOid: 'head-oid', + mergeBase: 'merge-base-oid' + }, + filePath: 'src/file.ts', + oldPath: 'src/old-file.ts', + connectionId: 'conn-1' + }) + ).resolves.toEqual(result) + + expect(getBranchDiff).toHaveBeenCalledWith('/home/user/project', 'merge-base-oid', { + includePatch: true, + headOid: 'head-oid', + filePath: 'src/file.ts', + oldPath: 'src/old-file.ts' + }) + }) + // Why: the original SSH Quick Open bug had two halves — relay-side policy // drift AND the main dispatcher silently dropping excludePaths before the // provider saw them. This test guards the second half: regardless of diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 4ca9547b77c..40701203e06 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -5,7 +5,7 @@ import type { FileHandle } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { dirname, extname, join, resolve } from 'node:path' import type { ChildProcess } from 'node:child_process' -import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' +import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import { tryDeleteWslUncPath } from '../wsl-unc-delete' import type { Store } from '../persistence' @@ -112,6 +112,11 @@ import { } from './source-control-ai-linked-issue' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../shared/ripgrep-process-availability' import { getSshFilesystemProvider, requireSshFilesystemProvider @@ -980,32 +985,35 @@ export function registerFilesystemHandlers( Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) const searchKey = `${event.sender.id}:${rootPath}` + // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. + const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro - // Why: probe rg upfront; on some platforms spawn emits 'close' before 'error', resolving empty before the git-grep fallback runs. - const rgAvailable = await checkRgAvailable(rootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) } - return new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const rgArgs = buildRgArgs(args.query, rootPath, args) // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. - activeTextSearches.get(searchKey)?.kill() + const previousChild = activeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } const acc = createAccumulator() let stdoutBuffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false let child: ChildProcess | null = null let killTimeout: ReturnType - // Why: WSL-routed rg emits Linux paths; UNC repos carry the distro in the path, Windows-path repos in project runtime. - const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro const transformAbsPath = wslDistroForOutput ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) : undefined - const resolveOnce = (): void => { + const finish = (result: SearchResult | PromiseLike): void => { if (resolved) { return } @@ -1019,13 +1027,22 @@ export function registerFilesystemHandlers( child?.stderr?.off('data', handleStderrData) child?.off('error', handleError) child?.off('close', handleClose) - resolvePromise(finalize(acc)) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + resolvePromise(result) } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) const processLine = (line: string): void => { const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) - if (verdict === 'stop') { - child?.kill() + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) } } @@ -1049,9 +1066,24 @@ export function registerFilesystemHandlers( // Drain stderr so rg cannot block on a full pipe. } const handleError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } resolveOnce() } - const handleClose = (): void => { + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } if (stdoutBuffer) { processLine(stdoutBuffer) } @@ -1067,7 +1099,9 @@ export function registerFilesystemHandlers( // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. killTimeout = setTimeout(() => { acc.truncated = true - child?.kill() + if (child) { + killSpawnedRipgrepProcess(child) + } resolveOnce() }, SEARCH_TIMEOUT_MS) }) @@ -2042,6 +2076,7 @@ export function registerFilesystemHandlers( } const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { includePatch: true, + headOid: args.compare.headOid, filePath: args.filePath, oldPath: args.oldPath }) @@ -2287,6 +2322,7 @@ export function registerFilesystemHandlers( return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) return getRemoteFileUrl(worktreePath, args.relativePath, args.line) } ) @@ -2307,6 +2343,7 @@ export function registerFilesystemHandlers( return provider.getRemoteCommitUrl(args.worktreePath, sha) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) return getRemoteCommitUrl(worktreePath, sha) } ) diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index bbd26f84d6a..c137c399480 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -22,6 +22,7 @@ const { getPRChecksMock, getPRCheckDetailsMock, getPRCommentsMock, + setPRCommentReactionMock, resolveReviewThreadMock, setPRFileViewedMock, addPRReviewCommentMock, @@ -64,6 +65,7 @@ const { getPRChecksMock: vi.fn(), getPRCheckDetailsMock: vi.fn(), getPRCommentsMock: vi.fn(), + setPRCommentReactionMock: vi.fn(), resolveReviewThreadMock: vi.fn(), setPRFileViewedMock: vi.fn(), addPRReviewCommentMock: vi.fn(), @@ -123,6 +125,7 @@ vi.mock('../github/client', () => ({ getPRChecks: getPRChecksMock, getPRCheckDetails: getPRCheckDetailsMock, getPRComments: getPRCommentsMock, + setPRCommentReaction: setPRCommentReactionMock, resolveReviewThread: resolveReviewThreadMock, setPRFileViewed: setPRFileViewedMock, addPRReviewComment: addPRReviewCommentMock, @@ -212,6 +215,7 @@ describe('registerGitHubHandlers', () => { getPRChecksMock.mockReset() getPRCheckDetailsMock.mockReset() getPRCommentsMock.mockReset() + setPRCommentReactionMock.mockReset() resolveReviewThreadMock.mockReset() setPRFileViewedMock.mockReset() addPRReviewCommentMock.mockReset() @@ -907,6 +911,7 @@ describe('registerGitHubHandlers', () => { getPRChecksMock.mockResolvedValue([]) getPRCheckDetailsMock.mockResolvedValue(null) getPRCommentsMock.mockResolvedValue([]) + setPRCommentReactionMock.mockResolvedValue(true) resolveReviewThreadMock.mockResolvedValue(true) setPRFileViewedMock.mockResolvedValue(true) addPRReviewCommentReplyMock.mockResolvedValue({ ok: true }) @@ -964,6 +969,13 @@ describe('registerGitHubHandlers', () => { prRepo, noCache: true }) + await handlers['gh:setPRCommentReaction'](null, { + repoPath: '/workspace/repo', + reactionSubjectId: ' IC_1 ', + content: '+1', + reacted: true, + prRepo + }) await handlers['gh:resolveReviewThread'](null, { repoPath: '/workspace/repo', threadId: 'thread-1', @@ -1124,6 +1136,15 @@ describe('registerGitHubHandlers', () => { null, localGitOptions ) + expect(setPRCommentReactionMock).toHaveBeenCalledWith( + '/workspace/repo', + 'IC_1', + '+1', + true, + null, + prRepo, + localGitOptions + ) expect(resolveReviewThreadMock).toHaveBeenCalledWith( '/workspace/repo', 'thread-1', diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 6fcc53bab71..91cfc94f061 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -13,6 +13,7 @@ import type { GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshReason, + GitHubReactionContent, PRRefreshOutcome, GitHubPRFile } from '../../shared/types' @@ -39,6 +40,7 @@ import { getPRChecks, getPRCheckDetails, getPRComments, + setPRCommentReaction, resolveReviewThread, setPRFileViewed, addPRReviewComment, @@ -667,6 +669,36 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + ipcMain.handle( + 'gh:setPRCommentReaction', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredRepo(args, store) + if (!args.reactionSubjectId?.trim()) { + return false + } + return setPRCommentReaction( + repo.path, + args.reactionSubjectId.trim(), + args.content, + args.reacted, + repoConnectionId(repo), + args.prRepo ?? null, + ...localGitOptionArgs(store, repo) + ) + } + ) + ipcMain.handle( 'gh:resolveReviewThread', async ( diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index cef5d7f1d66..0399446e462 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -13,6 +13,7 @@ function setPlatform(platform: NodeJS.Platform): void { const { handleMock, createHostedReviewMock, + createStackedHostedReviewMock, getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, resolveRegisteredWorktreePathMock, @@ -20,6 +21,7 @@ const { } = vi.hoisted(() => ({ handleMock: vi.fn(), createHostedReviewMock: vi.fn(), + createStackedHostedReviewMock: vi.fn(), getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), resolveRegisteredWorktreePathMock: vi.fn(), @@ -37,6 +39,10 @@ vi.mock('../source-control/hosted-review-creation', () => ({ getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock })) +vi.mock('../source-control/stacked-hosted-review-creation', () => ({ + createStackedHostedReview: createStackedHostedReviewMock +})) + vi.mock('../source-control/hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) @@ -87,6 +93,7 @@ describe('registerHostedReviewHandlers', () => { setPlatform(ORIGINAL_PLATFORM) handleMock.mockReset() createHostedReviewMock.mockReset() + createStackedHostedReviewMock.mockReset() getHostedReviewCreationEligibilityMock.mockReset() getHostedReviewForBranchMock.mockReset() resolveRegisteredWorktreePathMock.mockReset() @@ -384,6 +391,35 @@ describe('registerHostedReviewHandlers', () => { ) }) + it('routes stacked creation through its dedicated SSH-safe handler', async () => { + createStackedHostedReviewMock.mockResolvedValueOnce({ + ok: true, + number: 43, + url: 'https://github.com/acme/orca/pull/43', + stackNumber: 50, + parentReview: { number: 42, url: 'https://github.com/acme/orca/pull/42' } + }) + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:createStacked'](null, { + repoPath, + repoId: repo.id, + worktreePath, + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + + expect(createStackedHostedReviewMock).toHaveBeenCalledWith( + worktreePath, + expect.objectContaining({ base: 'stack/parent', head: 'stack/child' }), + 'ssh-1', + {} + ) + expect(createHostedReviewMock).not.toHaveBeenCalled() + }) + it('rejects creation when repoId and repoPath point at different registered repos', async () => { store.getRepo.mockImplementation((repoId: string) => repoId === repo.id ? { ...repo, path: '/other/repo' } : null diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 670064d3e54..736668e1f0d 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { posix, resolve } from 'node:path' import type { CreateHostedReviewArgs, + CreateStackedHostedReviewArgs, HostedReviewCreationEligibilityArgs, HostedReviewForBranchArgs } from '../../shared/hosted-review' @@ -12,6 +13,7 @@ import { createHostedReview, getHostedReviewCreationEligibility } from '../source-control/hosted-review-creation' +import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation' import { getHostedReviewForBranch } from '../source-control/hosted-review' import { resolveRegisteredWorktreePath } from './filesystem-auth' import { listRepoWorktrees } from '../repo-worktrees' @@ -160,4 +162,44 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector } return result }) + + ipcMain.handle( + 'hostedReview:createStacked', + async (_event, args: CreateStackedHostedReviewArgs) => { + const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) + const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const sharedLinkPaths = repo.connectionId ? [] : getWorktreeSharedLinkPaths(repo) + const executionOptions = { + ...(Object.keys(localGitOptions).length > 0 + ? { localGitExecOptions: localGitOptions } + : {}), + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + } + const input = { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft, + ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) + } + const result = await createStackedHostedReview( + worktreePath, + input, + repo.connectionId ?? null, + executionOptions + ) + if (result.ok && !stats.hasCountedPR(result.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: result.number, prUrl: result.url } + }) + } + return result + } + ) } diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 683e0271086..1c90227cfd2 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -126,6 +126,14 @@ function initialSnapshot(callIndex: number): InitialSnapshotCallback { return (call[0] as { onInitialSnapshot: InitialSnapshotCallback }).onInitialSnapshot } +function setupSignal(callIndex: number): AbortSignal { + const signal = subscribeTranscript.mock.calls[callIndex]?.[1] + if (!(signal instanceof AbortSignal)) { + throw new Error('subscribe setup signal was not provided') + } + return signal +} + function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void { const listener = listeners.get('nativeChat:unsubscribe') if (!listener) { @@ -151,8 +159,10 @@ describe('nativeChat subscribe lifecycle', () => { const renderer = createSender(1) subscribe(renderer.sender, 'unmount') + expect(setupSignal(0).aborted).toBe(false) expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) unsubscribe(renderer.sender, 'unmount') + expect(setupSignal(0).aborted).toBe(true) expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) unsubscribe(renderer.sender, 'unmount') expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) @@ -171,7 +181,9 @@ describe('nativeChat subscribe lifecycle', () => { const renderer = createSender(2) subscribe(renderer.sender, 'destroy') + expect(setupSignal(0).aborted).toBe(false) renderer.destroy() + expect(setupSignal(0).aborted).toBe(true) expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) @@ -186,7 +198,10 @@ describe('nativeChat subscribe lifecycle', () => { const renderer = createSender(3) subscribe(renderer.sender, 'same-id') + const olderSignal = setupSignal(0) subscribe(renderer.sender, 'same-id') + expect(olderSignal.aborted).toBe(true) + expect(setupSignal(1).aborted).toBe(false) expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) newer.resolve() diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 6521bef3964..4ef0f48b872 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -9,7 +9,8 @@ import type { ReadTranscriptResult } from '../native-chat/transcript-reader' import { subscribeNativeChatTranscript, readNativeChatTranscriptTail, - type NativeChatTranscriptSubscription + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs } from '../native-chat/transcript-watch' // Re-export so existing test imports of `clearNativeChatTranscriptCache` from @@ -81,17 +82,22 @@ type LiveSubscription = { subscription: NativeChatTranscriptSubscription } +type PendingSubscription = { + controller: AbortController +} + // Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the // same renderer can watch several panes, and a destroyed window tears down all // of its watchers — strict teardown to avoid fd leaks (plan U4 risk). const liveSubscriptions = new Map>() // Why: unsubscribe and renderer destruction must invalidate async watcher setup // before it can publish a late subscription into the live map. -const pendingSubscriptions = new Map>() +const pendingSubscriptions = new Map>() const senderCleanupRegistered = new Set() function teardownSubscription(senderId: number, subscriptionId: string): void { const pendingBySubId = pendingSubscriptions.get(senderId) + pendingBySubId?.get(subscriptionId)?.controller.abort() pendingBySubId?.delete(subscriptionId) if (pendingBySubId?.size === 0) { pendingSubscriptions.delete(senderId) @@ -111,6 +117,9 @@ function teardownSubscription(senderId: number, subscriptionId: string): void { function teardownAllForSender(senderId: number): void { // The destroyed event can arrive before async subscription setup stores a watcher. senderCleanupRegistered.delete(senderId) + for (const pending of pendingSubscriptions.get(senderId)?.values() ?? []) { + pending.controller.abort() + } pendingSubscriptions.delete(senderId) const bySubId = liveSubscriptions.get(senderId) if (!bySubId) { @@ -131,18 +140,22 @@ function registerSenderCleanup(sender: WebContents): void { sender.once('destroyed', () => teardownAllForSender(sender.id)) } -function beginPendingSubscription(senderId: number, subscriptionId: string): symbol { +function beginPendingSubscription(senderId: number, subscriptionId: string): PendingSubscription { teardownSubscription(senderId, subscriptionId) - const token = Symbol(subscriptionId) - const bySubId = pendingSubscriptions.get(senderId) ?? new Map() - bySubId.set(subscriptionId, token) + const pending = { controller: new AbortController() } + const bySubId = pendingSubscriptions.get(senderId) ?? new Map() + bySubId.set(subscriptionId, pending) pendingSubscriptions.set(senderId, bySubId) - return token + return pending } -function takePendingSubscription(senderId: number, subscriptionId: string, token: symbol): boolean { +function takePendingSubscription( + senderId: number, + subscriptionId: string, + pending: PendingSubscription +): boolean { const bySubId = pendingSubscriptions.get(senderId) - if (bySubId?.get(subscriptionId) !== token) { + if (bySubId?.get(subscriptionId) !== pending) { return false } bySubId.delete(subscriptionId) @@ -160,71 +173,72 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg const { subscriptionId, agent, sessionId, transcriptPath } = args const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW // Replace any prior subscription under the same id (session change/resubscribe). - const pendingToken = beginPendingSubscription(sender.id, subscriptionId) + const pending = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) + const subscribeArgs: SubscribeNativeChatTranscriptArgs = { + agent, + sessionId, + transcriptPath, + initialLimit: limit, + onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => { + if (sender.isDestroyed()) { + return + } + // Forward an initial-drain error so a watching client's first frame carries it + // instead of stranding the view at 'loading' when the read keeps throwing. + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { + type: 'snapshot', + messages, + hasMore, + ...(error ? { error } : {}), + ...(lifecycle ? { lifecycle } : {}) + } + } + sender.send('nativeChat:appended', payload) + }, + onReplace: (messages, hasMore, _beforeOffset, lifecycle) => { + if (sender.isDestroyed()) { + return + } + sender.send('nativeChat:appended', { + subscriptionId, + frame: { + type: 'replacement', + messages, + hasMore, + ...(lifecycle ? { lifecycle } : {}) + } + } satisfies NativeChatAppendedPayload) + }, + onAppend: (messages, lifecycle) => { + if (sender.isDestroyed()) { + return + } + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { + type: 'appended', + messages, + ...(lifecycle ? { lifecycle } : {}) + } + } + sender.send('nativeChat:appended', payload) + } + } let subscription: NativeChatTranscriptSubscription try { - subscription = await subscribeNativeChatTranscript({ - agent, - sessionId, - transcriptPath, - initialLimit: limit, - onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => { - if (sender.isDestroyed()) { - return - } - // Forward an initial-drain error so a watching client's first frame carries it - // instead of stranding the view at 'loading' when the read keeps throwing. - const payload: NativeChatAppendedPayload = { - subscriptionId, - frame: { - type: 'snapshot', - messages, - hasMore, - ...(error ? { error } : {}), - ...(lifecycle ? { lifecycle } : {}) - } - } - sender.send('nativeChat:appended', payload) - }, - onReplace: (messages, hasMore, _beforeOffset, lifecycle) => { - if (sender.isDestroyed()) { - return - } - sender.send('nativeChat:appended', { - subscriptionId, - frame: { - type: 'replacement', - messages, - hasMore, - ...(lifecycle ? { lifecycle } : {}) - } - } satisfies NativeChatAppendedPayload) - }, - onAppend: (messages, lifecycle) => { - if (sender.isDestroyed()) { - return - } - const payload: NativeChatAppendedPayload = { - subscriptionId, - frame: { - type: 'appended', - messages, - ...(lifecycle ? { lifecycle } : {}) - } - } - sender.send('nativeChat:appended', payload) - } - }) + subscription = await subscribeNativeChatTranscript(subscribeArgs, pending.controller.signal) } catch { - takePendingSubscription(sender.id, subscriptionId, pendingToken) + takePendingSubscription(sender.id, subscriptionId, pending) return } // Why: unmount, destruction, or a newer same-id subscribe can invalidate setup - // while path resolution is pending; only the owning token may publish its watcher. - const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pendingToken) + // while path resolution is pending; only the owning generation may publish its watcher. + const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pending) if (sender.isDestroyed() || !stillCurrent) { subscription.unsubscribe() return diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index d68668038bd..cdffcb8b913 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ChildProcessWithoutNullStreams } from 'node:child_process' -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() const { spawnMock, handleMock, resolveAuthorizedPathMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), handleMock: vi.fn((channel: string, handler: (event: unknown, args: unknown) => unknown) => { diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index d7930066ad7..b7cb9dd5a7b 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -15,6 +15,7 @@ const { notificationCtorMock, notificationIsSupportedMock, getAllWindowsMock, + getTrustedUIRendererWindowMock, shellOpenExternalMock } = vi.hoisted(() => { const removeHandlerMock = vi.fn() @@ -35,6 +36,7 @@ const { }) const notificationIsSupportedMock = vi.fn(() => true) const getAllWindowsMock = vi.fn(() => []) + const getTrustedUIRendererWindowMock = vi.fn() const shellOpenExternalMock = vi.fn() return { removeHandlerMock, @@ -47,6 +49,7 @@ const { notificationCtorMock, notificationIsSupportedMock, getAllWindowsMock, + getTrustedUIRendererWindowMock, shellOpenExternalMock } }) @@ -81,6 +84,10 @@ vi.mock('./notification-authorization-status', () => ({ readNotificationAuthorizationStatus: readAuthorizationStatusMock })) +vi.mock('./ui', () => ({ + getTrustedUIRendererWindow: getTrustedUIRendererWindowMock +})) + // Why: notifications.ts pulls in the tray module (for the minimized attention // dot), which transitively loads app-icon/electron-toolkit; stub it so this // suite stays focused on notification dispatch and avoids that import chain. @@ -121,6 +128,8 @@ describe('registerNotificationHandlers', () => { readAuthorizationStatusMock.mockResolvedValue(null) getAllWindowsMock.mockReset() getAllWindowsMock.mockReturnValue([]) + getTrustedUIRendererWindowMock.mockReset() + getTrustedUIRendererWindowMock.mockReturnValue(null) shellOpenExternalMock.mockClear() setTrayAttentionMock.mockClear() }) @@ -452,20 +461,32 @@ describe('registerNotificationHandlers', () => { } }) - it('focuses the originating terminal pane when a notification with paneKey is clicked', async () => { + it('focuses the originating terminal pane in the main window when a dashboard popout is open', async () => { + const popoutSend = vi.fn() + const popoutFocus = vi.fn() const webContentsSend = vi.fn() const restore = vi.fn() + const show = vi.fn() const focus = vi.fn() - getAllWindowsMock.mockReturnValue([ - { - isDestroyed: () => false, - isFocused: () => false, - isMinimized: () => true, - restore, - focus, - webContents: { send: webContentsSend } - } as never - ]) + const popoutWindow = { + isDestroyed: () => false, + isFocused: () => true, + isMinimized: () => false, + restore: vi.fn(), + focus: popoutFocus, + webContents: { send: popoutSend } + } + const mainWindow = { + isDestroyed: () => false, + isFocused: () => false, + isMinimized: () => true, + restore, + show, + focus, + webContents: { send: webContentsSend } + } + getAllWindowsMock.mockReturnValue([popoutWindow, mainWindow] as never) + getTrustedUIRendererWindowMock.mockReturnValue(mainWindow) registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -486,8 +507,12 @@ describe('registerNotificationHandlers', () => { getNotificationEventHandler('click')() + expect(getTrustedUIRendererWindowMock).toHaveBeenCalledTimes(1) expect(restore).toHaveBeenCalledTimes(1) + expect(show).toHaveBeenCalledTimes(1) expect(focus).toHaveBeenCalledTimes(1) + expect(popoutFocus).not.toHaveBeenCalled() + expect(popoutSend).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function)) expect(webContentsSend).toHaveBeenCalledWith('ui:activateWorktree', { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 95e9e3e3a47..7418396a199 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -28,6 +28,7 @@ import { readNotificationAuthorizationStatus } from './notification-authorizatio import { parsePaneKey } from '../../shared/stable-pane-id' import { setTrayAttention } from '../tray/system-tray' import { isMainWindowVisible } from '../window/main-window-visibility' +import { getTrustedUIRendererWindow } from './ui' const NOTIFICATION_COOLDOWN_MS = 5000 const MAX_RECENT_NOTIFICATION_KEYS = 50 @@ -512,8 +513,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime const repoId = getRepoIdFromWorktreeId(args.worktreeId) clickHandler = () => { release() - const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) - if (!win) { + const win = getTrustedUIRendererWindow() + if (!win || win.isDestroyed()) { return } if (process.platform === 'darwin') { @@ -522,6 +523,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime if (win.isMinimized()) { win.restore() } + win.show() win.focus() win.webContents.send('ui:activateWorktree', { repoId, diff --git a/src/main/ipc/opencode-usage.ts b/src/main/ipc/opencode-usage.ts deleted file mode 100644 index 47b3c95dea7..00000000000 --- a/src/main/ipc/opencode-usage.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ipcMain } from 'electron' -import type { OpenCodeUsageStore } from '../opencode-usage/store' -import type { - OpenCodeUsageBreakdownKind, - OpenCodeUsageRange, - OpenCodeUsageScope -} from '../../shared/opencode-usage-types' - -export function registerOpenCodeUsageHandlers(openCodeUsage: OpenCodeUsageStore): void { - ipcMain.handle('openCodeUsage:getScanState', () => openCodeUsage.getScanState()) - ipcMain.handle('openCodeUsage:setEnabled', (_event, args: { enabled: boolean }) => - openCodeUsage.setEnabled(args.enabled) - ) - ipcMain.handle('openCodeUsage:refresh', (_event, args?: { force?: boolean }) => - openCodeUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'openCodeUsage:getSnapshot', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => - openCodeUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'openCodeUsage:getSummary', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => - openCodeUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'openCodeUsage:getDaily', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => - openCodeUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'openCodeUsage:getBreakdown', - ( - _event, - args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - kind: OpenCodeUsageBreakdownKind - } - ) => openCodeUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'openCodeUsage:getRecentSessions', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => - openCodeUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts index 38e135d4558..916ab35ab63 100644 --- a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts +++ b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts @@ -3,45 +3,11 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -const { registryQueryAsyncMock, registryQuerySyncMock } = vi.hoisted(() => ({ - registryQueryAsyncMock: vi.fn(), - registryQuerySyncMock: vi.fn() -})) - -vi.mock('node:child_process', async (importOriginal) => { - const original = await importOriginal>() - const originalExecFile = original.execFile as ( - command: string, - commandArgs: string[], - commandOptions: unknown, - commandCallback: (error: Error | null, stdout: string, stderr: string) => void - ) => unknown - const registryAwareExecFile = ( - file: string, - args: string[], - options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ): unknown => { - if (file.toLowerCase().endsWith('\\reg.exe')) { - return registryQueryAsyncMock(file, args, options, callback) - } - return originalExecFile(file, args, options, callback) - } - const customPromisify = Symbol.for('nodejs.util.promisify.custom') - Object.defineProperty(registryAwareExecFile, customPromisify, { - value: (originalExecFile as unknown as Record)[customPromisify] - }) - return { - ...original, - execFile: registryAwareExecFile, - execFileSync: registryQuerySyncMock - } -}) - import { __resetPersistedWindowsPathCacheForTests, mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { execLocalPreflightCommand } from './preflight-command-exec' describe.runIf(process.platform === 'win32')('Windows preflight Path refresh reproduction', () => { @@ -50,8 +16,7 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep afterEach(() => { process.env.Path = originalPath - registryQueryAsyncMock.mockReset() - registryQuerySyncMock.mockReset() + __setWindowsPathRegistryLoaderForTests() __resetPersistedWindowsPathCacheForTests() for (const directory of fixtureDirs.splice(0)) { rmSync(directory, { recursive: true, force: true }) @@ -68,22 +33,13 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep ) let persistedUserPath = '' - registryQuerySyncMock.mockImplementation((_file, args: string[]) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - return ` Path REG_SZ ${value}\r\n` - }) - registryQueryAsyncMock.mockImplementation( - ( - _file: string, - args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - callback(null, ` Path REG_SZ ${value}\r\n`, '') - return {} as never - } - ) + const getRegistryKey = vi.fn((root: number) => ({ + Path: { type: 1, value: root === 2 ? persistedUserPath : '' } + })) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) __resetPersistedWindowsPathCacheForTests() await expect(execLocalPreflightCommand(command, ['/?'])).rejects.toMatchObject({ @@ -97,7 +53,6 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep await expect(execLocalPreflightCommand(command, ['/?'])).resolves.toMatchObject({ stdout: expect.any(String) }) - expect(registryQuerySyncMock).toHaveBeenCalledTimes(2) - expect(registryQueryAsyncMock).toHaveBeenCalledTimes(2) + expect(getRegistryKey).toHaveBeenCalledTimes(4) }) }) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts index b1f089e385e..43fa8567b88 100644 --- a/src/main/ipc/pty-hidden-delivery-gate.ts +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -18,8 +18,7 @@ export type HiddenPtyDeliveryGateSettings = Pick< const hiddenRendererPtys = new Set() // Why: sidecar consumers (paste-draft pacing, background agent launches, -// automation observers, and the kill-switch-off parked 2031 responder) need -// live bytes even while no visible view exists. Any +// automation observers) need live bytes even while no visible view exists. Any // registered interest suppresses the gate for that PTY. const deliveryInterestRendererPtys = new Set() // Why: reveal must restore from the model only when bytes were actually diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 05cafc81785..f3474a14b4c 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -16,6 +16,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' +import type * as Wsl from '../wsl' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -57,6 +58,7 @@ const { piBuildPtyEnvMock, piClearPtyMock, isPwshAvailableMock, + wslUncDirectoryExistsAsyncMock, trackMock, classifyErrorMock, registerPtyMock, @@ -67,6 +69,7 @@ const { clearPaneKeyAliasesForPtyMock, recordCodexPaneAccountMock, forgetCodexPaneAccountMock, + getCodexPaneAccountMock, ensureCodexBackfillRecoveryMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -86,6 +89,7 @@ const { openCodeBuildPtyEnvMock: vi.fn(), mimoCodeBuildPtyEnvMock: vi.fn(), isPwshAvailableMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), clearAgentHookPaneStateMock: vi.fn(), @@ -102,6 +106,7 @@ const { clearPaneKeyAliasesForPtyMock: vi.fn(), recordCodexPaneAccountMock: vi.fn(), forgetCodexPaneAccountMock: vi.fn(), + getCodexPaneAccountMock: vi.fn(), ensureCodexBackfillRecoveryMock: vi.fn(() => Promise.resolve()) })) @@ -183,6 +188,11 @@ vi.mock('../pwsh', () => ({ isPwshAvailableAsync: isPwshAvailableMock })) +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal()), + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) +})) + vi.mock('../telemetry/client', () => ({ track: trackMock })) @@ -210,7 +220,8 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ vi.mock('../codex/codex-pane-account-registry', () => ({ recordCodexPaneAccount: recordCodexPaneAccountMock, - forgetCodexPaneAccount: forgetCodexPaneAccountMock + forgetCodexPaneAccount: forgetCodexPaneAccountMock, + getCodexPaneAccount: getCodexPaneAccountMock })) vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ @@ -224,6 +235,7 @@ import { makePaneKey } from '../../shared/stable-pane-id' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { registerPtyHandlers, + buildPtyHostEnv, registerSshPtyProvider, clearPtyOwnershipForConnection, clearProviderPtyState, @@ -243,6 +255,8 @@ import { restorePtyIncarnation, type PrepareCodexSessionResume } from './pty' +import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell' import { _resetHiddenRendererPtyDeliveryGateForTest, @@ -333,6 +347,7 @@ describe('registerPtyHandlers', () => { const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION const savedPrimeAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR const savedOrcaPrimeAgentSourceDir = process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + const savedOrcaPrimeAgentStatusExtension = process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const savedDisableMacosLoginShell = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL @@ -360,6 +375,7 @@ describe('registerPtyHandlers', () => { delete process.env.ORCA_OMP_STATUS_EXTENSION delete process.env.PRIME_AGENT_CODING_AGENT_DIR delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -384,6 +400,8 @@ describe('registerPtyHandlers', () => { piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() + wslUncDirectoryExistsAsyncMock.mockReset() + wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) trackMock.mockReset() classifyErrorMock.mockReset() registerPtyMock.mockReset() @@ -394,6 +412,7 @@ describe('registerPtyHandlers', () => { clearPaneKeyAliasesForPtyMock.mockReset() recordCodexPaneAccountMock.mockReset() forgetCodexPaneAccountMock.mockReset() + getCodexPaneAccountMock.mockReset() ensureCodexBackfillRecoveryMock.mockReset() ensureCodexBackfillRecoveryMock.mockResolvedValue(undefined) mainWindow.webContents.on.mockReset() @@ -586,6 +605,11 @@ describe('registerPtyHandlers', () => { } else { delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR } + if (savedOrcaPrimeAgentStatusExtension !== undefined) { + process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION = savedOrcaPrimeAgentStatusExtension + } else { + delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION + } if (savedOrcaClaudeAgentStatusSettings === undefined) { delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS } else { @@ -1923,6 +1947,65 @@ describe('registerPtyHandlers', () => { } describe('spawn environment', () => { + it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: (root) => ({ + Path: { + type: 1, + value: + root === 1 + ? 'C:\\Windows\\System32' + : 'C:\\Python314;C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' + } + }) + })) + __resetPersistedWindowsPathCacheForTests() + + try { + const provider = new LocalPtyProvider({ + buildSpawnEnv: (id, baseEnv, context) => + buildPtyHostEnv(id, baseEnv, { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + githubAttributionEnabled: false, + agentStatusHooksEnabled: false, + isWsl: context?.isWsl, + wslDistro: context?.wslDistro + }) + }) + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + env: { + PATH: 'C:\\Orca\\bin;C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps', + WSLENV: 'ORCA_TERMINAL_HANDLE/u' + } + }) + const [file, , options] = spawnMock.mock.calls.at(-1)! + + expect(file).toBe('wsl.exe') + expect(options.env.PATH).toBe( + 'C:\\Orca\\bin;C:\\Windows\\System32;C:\\Python314;C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' + ) + const forwardedKeys = options.env.WSLENV.split(':').map((entry) => + entry.split('/')[0]!.toLowerCase() + ) + expect(options.env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') + expect(forwardedKeys).not.toContain('path') + } finally { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('publishes a lifecycle signal after a successful renderer spawn', async () => { await spawnAndGetEnv() @@ -4196,10 +4279,13 @@ describe('registerPtyHandlers', () => { }) }) - it('does not materialize Windows-host Prime extensions for a WSL launch', async () => { + it('does not install or inject a Prime extension for an explicit WSL launch', async () => { await withWin32Platform(async () => { const env = await daemonSpawnAndGetEnv( - { PRIME_AGENT_CODING_AGENT_DIR: 'C:\\Users\\test\\.prime\\agent' }, + { + PRIME_AGENT_CODING_AGENT_DIR: 'C:\\Users\\test\\.prime\\agent', + ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts' + }, undefined, undefined, undefined, @@ -4208,10 +4294,26 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).not.toHaveBeenCalled() expect(env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toBeUndefined() + expect(env.ORCA_WSL_HOOK_INSTANCE).toBeUndefined() expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBe('C:\\Users\\test\\.prime\\agent') }) }) + it('does not prepare a Prime extension for a typed launch in a bare WSL shell', async () => { + await withWin32Platform(async () => { + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, { + shellOverride: 'wsl.exe' + }) + + expect(piBuildPtyEnvMock.mock.calls.some(([, , kind]) => kind === 'prime-agent')).toBe( + false + ) + expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toBeUndefined() + expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBeUndefined() + }) + }) + it('points OPENCODE_CONFIG_DIR at the guest overlay when the WSL relay reports it', async () => { const guestDir = '/home/jin/.orca-relay/opencode-overlays/abc' const spy = vi.spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir').mockReturnValue(guestDir) @@ -8548,6 +8650,86 @@ describe('registerPtyHandlers', () => { }) }) + it('shuts down a split PTY when its expected source binding was retired', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId: string + tabId: string + leafId: string + persistHostSessionBinding: boolean + expectedSourceBinding: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + } + }): Promise<{ id: string }> + } + const proc = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + process: 'zsh', + pid: 12345 + } + spawnMock.mockReturnValue(proc) + const store = { persistPtyBinding: vi.fn(() => false) } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + preAllocateHandleForPty: vi.fn(() => 'term_expected'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const leafId = '22222222-2222-4222-8222-222222222222' + const expectedSourceBinding = { + worktreeId: 'wt-1', + tabId: 'tab-headless', + leafId: '11111111-1111-4111-8111-111111111111', + ptyId: 'pty-source' + } + + try { + await expect( + (controller as unknown as RuntimeSpawnController).spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-headless', + leafId, + persistHostSessionBinding: true, + expectedSourceBinding + }) + ).rejects.toThrow('terminal_split_source_not_found') + } finally { + error.mockRestore() + } + + expect(store.persistPtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ expectedSourceBinding }) + ) + expect(proc.kill).toHaveBeenCalledOnce() + }) + it('reports lower-owner commit before rejecting an early-exited runtime incarnation', async () => { const persistPtyBinding = vi.fn() const onPtySpawnCommitted = vi.fn() @@ -9048,16 +9230,20 @@ describe('registerPtyHandlers', () => { { label: 'git worktree', worktreeId: 'repo-1::/tmp/live-owner', - cwd: '/tmp/live-owner' + cwd: '/tmp/live-owner', + paneAccountAtStart: { homeRoute: 'shared-home' }, + expectedHomeRouteAtStart: 'shared-home' }, { label: 'folder workspace', worktreeId: 'folder:live-owner', - cwd: '/tmp' + cwd: '/tmp', + paneAccountAtStart: null, + expectedHomeRouteAtStart: null } ])( 'adopts a completed runtime-owned pane before replacement launch preflight ($label)', - async ({ worktreeId, cwd }) => { + async ({ worktreeId, cwd, paneAccountAtStart, expectedHomeRouteAtStart }) => { type StableAdoption = { result: { id: string; incarnationId?: string; isReattach?: boolean } owner: { handle?: string; tabId: string; leafId: string; ptyId: string } @@ -9209,13 +9395,26 @@ describe('registerPtyHandlers', () => { store.persistPtyBinding.mockClear() mainWindow.webContents.send.mockClear() + wslUncDirectoryExistsAsyncMock.mockClear() const mountArgs = { cols: 120, rows: 40, - cwd, + cwd: '/a', + cwdFallback: 'worktree', command: 'claude --resume provider-session', launchAgent: 'claude', worktreeId, + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + }, tabId, leafId, env: { @@ -9248,6 +9447,7 @@ describe('registerPtyHandlers', () => { sessionId: 'pty-live-owner' }) expect(providerSpawn.mock.calls[1]?.[0].command).toBeUndefined() + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() expect(runtime.createPreAllocatedTerminalHandle).not.toHaveBeenCalled() expect(prepareClaudeAuth).not.toHaveBeenCalled() expect(runtime.registerPreAllocatedHandleForPty).not.toHaveBeenCalled() @@ -9264,8 +9464,10 @@ describe('registerPtyHandlers', () => { runtime.beginPtyRegistration.mockImplementation(() => { runtimeSecondAdoption ??= spawnController.adoptStablePane(adoptionArgs) }) + getCodexPaneAccountMock.mockReturnValue(paneAccountAtStart) const rendererFirstMount = handlers.get('pty:spawn')!(null, mountArgs) await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(3)) + getCodexPaneAccountMock.mockReturnValue({ homeRoute: 'real-home' }) releaseAttach() await vi.waitFor(() => expect(runtimeSecondAdoption).not.toBeNull()) const pendingRuntimeAdoption = runtimeSecondAdoption @@ -9310,6 +9512,7 @@ describe('registerPtyHandlers', () => { id: 'pty-live-owner', codexHomePath: null, reattached: true, + reattachedHomeRoute: expectedHomeRouteAtStart, startedAt: expect.any(Date), startedSequence: expect.any(Number) }) @@ -12125,6 +12328,378 @@ describe('registerPtyHandlers', () => { expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) }) + it.each(['/home/alice/repo', '/a', '/c'])( + 'keeps an existing POSIX startup cwd for the selected WSL runtime (%s)', + async (startupCwd) => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) + statSyncMock.mockImplementation((target: string) => { + if (target === startupCwd) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: startupCwd, + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:\\Users\\alice\\repo', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + }) + + const expectedValidationCwd = `\\\\wsl.localhost\\Ubuntu-24.04${startupCwd.replaceAll('/', '\\')}` + expect(statSyncMock).not.toHaveBeenCalledWith(startupCwd) + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(1) + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(expectedValidationCwd) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: startupCwd, + shellOverride: 'wsl.exe' + }) + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + } + ) + + it('falls back when the selected WSL runtime reports a POSIX cwd missing', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/a', + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(1) + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: worktreePath })) + expect(providerSpawn).toHaveBeenCalledWith( + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) + ) + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('does not probe or forward local WSL cwd evidence for SSH fallback spawns', async () => { + const sshSpawn = vi.fn(async () => ({ id: 'ssh-wsl-looking-cwd' })) + registerSshPtyProvider('ssh-1', { + spawn: sshSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/a', + cwdFallback: 'worktree', + connectionId: 'ssh-1', + worktreeId: 'repo-1::C:\\Users\\alice\\repo', + shellOverride: 'wsl.exe', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + }) + + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(sshSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/a' })) + expect(sshSpawn).toHaveBeenCalledWith( + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) + ) + }) + + it('preserves a POSIX WSL cwd when the distro probe is inconclusive', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-inconclusive-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/a', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: unknown } + + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/a' })) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a POSIX cwd when WSL owns it but no distro can be resolved', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-no-distro-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + _setWslCachesForTests({ available: true, distros: [] }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/c', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: unknown } + + expect(statSyncMock).not.toHaveBeenCalledWith('/c') + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/c' })) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it.each([ + { + exists: true, + expectedCwd: '/home/alice/repo', + expectedFallback: undefined + }, + { + exists: false, + expectedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice', + expectedFallback: { + kind: 'worktree', + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice' + } + } + ])('resolves POSIX cwd existence for a WSL UNC workspace ($exists)', async (testCase) => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-unc-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(testCase.exists) + if (!testCase.exists) { + wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(true) + } + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(testCase.exists ? 1 : 2) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: testCase.expectedCwd }) + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) + ) + expect(result.startupCwdFallback).toEqual(testCase.expectedFallback) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a missing POSIX cwd when its WSL workspace root is also missing', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-root' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/c', + cwdFallback: 'worktree', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' + })) as { startupCwdFallback?: unknown } + + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2) + expect(providerSpawn).toHaveBeenCalledWith( + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) + ) + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/c' })) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('validates a /mnt drive cwd through its native Windows path', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-mnt-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/mnt/c/Users/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + }) + + expect(statSyncMock).toHaveBeenCalledWith('C:\\Users\\alice\\repo') + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/c/Users/alice/repo' }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('still falls back a missing Windows cwd when the selected runtime is WSL', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-windows-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + const missingCwd = `${worktreePath}/deleted-folder` + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + statSyncMock.mockImplementation((target: string) => { + if (target === missingCwd) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: missingCwd, + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: worktreePath, shellOverride: 'wsl.exe' }) + ) + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('probes Git Bash /c as its existing native drive root without falling back', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-git-bash-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/c', + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + shellOverride: 'C:\\Program Files\\Git\\bin\\bash.exe' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(statSyncMock).toHaveBeenCalledWith('C:\\') + expect(statSyncMock).not.toHaveBeenCalledWith('/c') + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/c', + shellOverride: expect.stringContaining('bash') + }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('keeps a missing cwd unchanged without the fallback flag', async () => { registerPtyHandlers(mainWindow as never) existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder') @@ -17167,6 +17742,87 @@ describe('registerPtyHandlers', () => { ) }) + it('pairs the daemon kitty flags with the reconciled boundary when no bytes crossed mid-spawn', async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ + id: 'pty-restored', + isReattach: true, + providerSequence: { value: 900, generation: 'continued' as const }, + snapshotKittyKeyboardFlags: 8 + })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const runtime = { + setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + getPtyOutputSequence: vi.fn().mockReturnValue(7), + synchronizePtyOutputSequenceFromProvider: vi.fn().mockReturnValue(920), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + + const reply = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { + snapshotSeq?: number + snapshotKittyKeyboardFlags?: number + } + + expect(reply.snapshotKittyKeyboardFlags).toBe(8) + expect(reply.snapshotSeq).toBe(920) + }) + + it('drops the daemon kitty flags claim when bytes crossed the data socket mid-spawn', async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ + id: 'pty-restored', + isReattach: true, + providerSequence: { value: 900, generation: 'continued' as const }, + snapshotKittyKeyboardFlags: 8 + })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const runtime = { + setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + // 7 at spawn start, 20 at reconcile: bytes arrived during the spawn RPC. + getPtyOutputSequence: vi.fn().mockReturnValueOnce(7).mockReturnValue(20), + synchronizePtyOutputSequenceFromProvider: vi.fn().mockReturnValue(920), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + + const reply = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { + snapshotSeq?: number + snapshotKittyKeyboardFlags?: number + } + + // The reconciled boundary covers bytes the daemon's flags were proven + // BEFORE — publishing both would erase a negotiation the pane scanned live. + expect(reply.snapshotKittyKeyboardFlags).toBeUndefined() + expect(reply.snapshotSeq).toBeUndefined() + }) + it('records the launch Codex account for a fresh spawn but not for a reattach', async () => { const spawn = vi .fn() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 219829c619e..5702bfc9e8f 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -13,7 +13,7 @@ import { } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { Store } from '../persistence' +import type { PtyBindingSourceExpectation, Store } from '../persistence' import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement' import type { GlobalSettings, TuiAgent } from '../../shared/types' import { toSshExecutionHostId } from '../../shared/execution-host' @@ -70,6 +70,7 @@ import { } from '../../shared/pi-agent-kind' import { isPwshAvailableAsync } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' +import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { @@ -96,7 +97,11 @@ import { import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' -import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' +import { + isDaemonEndpointGoneError, + TerminalHostGoneError, + TerminalSessionOwnerUnverifiedError +} from '../daemon/daemon-errors' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' @@ -127,7 +132,7 @@ import { import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' -import { readShellStartupEnvVar } from '../pty/shell-startup-env' +import { readSessionShellStartupEnvVar } from '../pty/shell-startup-env' import { isTerminalLeafId, makePaneKey, @@ -135,11 +140,12 @@ import { parsePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' import { resolveTerminalStartupCwdForWorkspace, type TerminalStartupCwdMissingDirFallback } from '../../shared/terminal-startup-cwd' -import { isWslUncPath } from '../../shared/wsl-paths' +import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { @@ -150,7 +156,7 @@ import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' -import { parseWslPath } from '../wsl' +import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl' import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' import { PtyProducerFlowController } from './pty-producer-flow-control' @@ -198,7 +204,9 @@ import { } from '../codex-accounts/managed-codex-auth-readiness' import { forgetCodexPaneAccount, - recordCodexPaneAccount + getCodexPaneAccount, + recordCodexPaneAccount, + type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../codex/codex-home-paths' @@ -846,6 +854,10 @@ async function attachStablePaneOwner( if (error instanceof TerminalSessionOwnerUnverifiedError) { throw new Error('terminal_pane_owner_unverified') } + // Why: translate before paired-runtime RPC strips the socket error's code and syscall. + if (isDaemonEndpointGoneError(error)) { + throw new TerminalHostGoneError() + } if (!isPtyAlreadyGoneError(error)) { throw error } @@ -1238,6 +1250,7 @@ export type CodexHomePtySpawnedLifecycleArgs = { id: string codexHomePath: string | null reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null launchEnv?: NodeJS.ProcessEnv startedAt?: Date startedSequence?: number @@ -1449,6 +1462,30 @@ function recordCodexPaneAccountForSpawn(args: { recordCodexPaneAccount(args.ptyId, record) } +function snapshotCodexPaneHomeRoutes( + ptyIds: readonly (string | null | undefined)[] +): ReadonlyMap { + const routes = new Map() + for (const ptyId of ptyIds) { + if (!ptyId || routes.has(ptyId)) { + continue + } + routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null) + } + return routes +} + +function codexReattachedHomeRouteField( + routes: ReadonlyMap, + ptyId: string, + reattached: boolean +): { reattachedHomeRoute?: CodexPaneHomeRoute | null } { + if (!reattached || !routes.has(ptyId)) { + return {} + } + return { reattachedHomeRoute: routes.get(ptyId) ?? null } +} + function readEnvWithProcessFallback( baseEnv: Record, key: string @@ -1471,11 +1508,7 @@ function resolvePiAgentSourceDir( if (kind === 'prime-agent') { return ( readEnvWithProcessFallback(baseEnv, primaryKey) ?? - readShellStartupEnvVar( - primaryKey, - baseEnv.HOME ?? process.env.HOME, - baseEnv.SHELL ?? process.env.SHELL - ) + readSessionShellStartupEnvVar(primaryKey, baseEnv) ) } @@ -1490,11 +1523,7 @@ function resolvePiAgentSourceDir( return publicDir } - return readShellStartupEnvVar( - primaryKey, - baseEnv.HOME ?? process.env.HOME, - baseEnv.SHELL ?? process.env.SHELL - ) + return readSessionShellStartupEnvVar(primaryKey, baseEnv) } function resolveScopedPiAgentSourceDir( @@ -1513,6 +1542,7 @@ function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKin } if (kind === 'prime-agent') { delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION return } delete baseEnv.ORCA_PI_CODING_AGENT_DIR @@ -1642,14 +1672,7 @@ function resolveOpenCodeSourceConfigDir(baseEnv: Record): string return undefined } - return ( - configDir ?? - readShellStartupEnvVar( - 'OPENCODE_CONFIG_DIR', - baseEnv.HOME ?? process.env.HOME, - baseEnv.SHELL ?? process.env.SHELL - ) - ) + return configDir ?? readSessionShellStartupEnvVar('OPENCODE_CONFIG_DIR', baseEnv) } /** @@ -1814,6 +1837,7 @@ export function buildPtyHostEnv( }) delete baseEnv.ORCA_OMP_STATUS_EXTENSION delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION } // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. @@ -4011,6 +4035,7 @@ export function registerPtyHandlers( rows: number seq?: number lastTitle?: string + kittyKeyboardFlags?: number } | null const pendingSerializeRequests = new Map< string, @@ -4039,6 +4064,7 @@ export function registerPtyHandlers( rows?: unknown seq?: unknown lastTitle?: unknown + kittyKeyboardFlags?: unknown } | null } ) => { @@ -4058,6 +4084,7 @@ export function registerPtyHandlers( rows: number seq?: number lastTitle?: string + kittyKeyboardFlags?: number } = { data: snapshot.data, cols: snapshot.cols, @@ -4069,6 +4096,12 @@ export function registerPtyHandlers( if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { result.lastTitle = snapshot.lastTitle } + // Why gated on seq: without a boundary the flags cannot be reconciled + // against live bytes, so they prove nothing. + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) + if (result.seq !== undefined && kittyKeyboardFlags !== undefined) { + result.kittyKeyboardFlags = kittyKeyboardFlags + } settleSerializeRequest(args.requestId, result) } else { settleSerializeRequest(args.requestId, null) @@ -4391,6 +4424,12 @@ export function registerPtyHandlers( const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined const preAdoptedStablePane = args.adoptedStablePane ?? null + const reattachedCodexHomeRoutes = !args.connectionId + ? snapshotCodexPaneHomeRoutes([ + preAdoptedStablePane?.result.id, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + : new Map() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise @@ -4424,7 +4463,8 @@ export function registerPtyHandlers( codexHomePath: null, reattached: true, startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence + startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true) }) } return result @@ -4528,6 +4568,7 @@ export function registerPtyHandlers( worktreeId: string tabId: string leafId: string + expectedSourceBinding?: PtyBindingSourceExpectation } | null = null if (shouldPersistHostSessionBinding) { if ( @@ -4546,7 +4587,10 @@ export function registerPtyHandlers( store, worktreeId: args.worktreeId, tabId: args.tabId, - leafId: args.leafId + leafId: args.leafId, + ...(args.expectedSourceBinding + ? { expectedSourceBinding: args.expectedSourceBinding } + : {}) } } const sshScopedEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) @@ -4841,6 +4885,15 @@ export function registerPtyHandlers( let stablePaneBindingPersisted = false let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null + // Why hoisted to the reply scope: main reconciles the provider sequence + // deep inside the spawn path, but the pane needs that renderer-domain + // boundary beside the daemon snapshot's kitty flags. + let reconciledSnapshotSeq: number | null = null + // False when bytes crossed the data socket during the spawn RPC: the + // reconciled boundary covers them, but the daemon proved its kitty flags + // before they existed, so the claim must not erase what the pane may + // have scanned from those bytes live. + let snapshotKittyFlagsCoverReconciledSeq = true let preparedProvisionalExecutionContext = false let releaseWorktreeSpawn: (() => void) | undefined try { @@ -4991,11 +5044,19 @@ export function registerPtyHandlers( // Why: admission precedes sequence/context state and every durable publication below. runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) if (result.providerSequence) { - runtime?.synchronizePtyOutputSequenceFromProvider?.( - result.id, - result.providerSequence, - sequenceBeforeProviderSpawn - ) + const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0 + // Why kept: this is the reattach boundary in the RENDERER's sequence + // domain, and the daemon snapshot's kitty flags mean nothing without + // the boundary they were proven at. + reconciledSnapshotSeq = + runtime?.synchronizePtyOutputSequenceFromProvider?.( + result.id, + result.providerSequence, + sequenceBeforeProviderSpawn + ) ?? null + if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { + snapshotKittyFlagsCoverReconciledSeq = false + } } ensureWslHookRelayForReattach(result, args.connectionId) runtime?.preparePtyExecutionContext?.( @@ -5099,6 +5160,7 @@ export function registerPtyHandlers( reattached: true, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true), ...(env ? { launchEnv: env } : {}) }) } @@ -5165,15 +5227,19 @@ export function registerPtyHandlers( leafId: hostSessionBinding.leafId, ptyId: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(cwd ? { startupCwd: cwd } : {}) + ...(cwd ? { startupCwd: cwd } : {}), + ...(hostSessionBinding.expectedSourceBinding + ? { expectedSourceBinding: hostSessionBinding.expectedSourceBinding } + : {}) } - if (args.connectionId) { - hostSessionBinding.store.persistPtyBinding( - binding, - toSshExecutionHostId(args.connectionId) - ) - } else { - hostSessionBinding.store.persistPtyBinding(binding) + const persisted = args.connectionId + ? hostSessionBinding.store.persistPtyBinding( + binding, + toSshExecutionHostId(args.connectionId) + ) + : hostSessionBinding.store.persistPtyBinding(binding) + if (persisted === false) { + throw new Error('terminal_split_source_not_found') } } catch (err) { console.error('[pty] failed to persist runtime PTY binding after spawn:', err) @@ -5186,6 +5252,9 @@ export function registerPtyHandlers( } clearProviderPtyState(result.id) } + if (err instanceof Error && err.message === 'terminal_split_source_not_found') { + throw err + } throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { agentSessionOperationOutcome: 'unknown' as const }) @@ -5272,6 +5341,11 @@ export function registerPtyHandlers( codexHomePath: selectedCodexHomePath, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + reattachedCodexHomeRoutes, + result.id, + result.isReattach === true + ), ...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {}) }) } @@ -5291,6 +5365,11 @@ export function registerPtyHandlers( } resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, { ...result, + ...(typeof result.snapshotKittyKeyboardFlags === 'number' && + reconciledSnapshotSeq !== null && + snapshotKittyFlagsCoverReconciledSeq + ? { snapshotSeq: reconciledSnapshotSeq } + : { snapshotKittyKeyboardFlags: undefined }), isReattach: true }) return response @@ -5656,6 +5735,7 @@ export function registerPtyHandlers( args: { id?: unknown; opts?: { scrollbackRows?: unknown } } ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -5666,6 +5746,7 @@ export function registerPtyHandlers( alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null @@ -5769,30 +5850,45 @@ export function registerPtyHandlers( ) => { const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined + const initialLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const initialPaneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + initialLeafId + ? makePaneKey(args.tabId, initialLeafId) + : null + const initialStablePanePtyId = (() => { + try { + return !args.connectionId && initialPaneKey + ? resolveStablePaneOwner( + runtime, + store, + initialPaneKey, + args.worktreeId, + args.connectionId + )?.ptyId + : undefined + } catch { + return undefined + } + })() + const reattachedCodexHomeRoutes = !args.connectionId + ? snapshotCodexPaneHomeRoutes([ + initialStablePanePtyId, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + : new Map() const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise } - // Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem. - const allowMissingCwdFallback = - !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' - let didFallbackToWorkspaceRootCwd = false - const cwd = resolvePtySpawnStartupCwd( - args.worktreeId, - args.cwd, - allowMissingCwdFallback - ? { - directoryExists: localStartupCwdDirectoryExists, - onFallbackToWorkspaceRoot: () => { - didFallbackToWorkspaceRootCwd = true - } - } - : undefined - ) - const startupCwdFallback = - didFallbackToWorkspaceRootCwd && cwd ? ({ kind: 'worktree', cwd } as const) : undefined - spawnTiming.mark('preflight') + let cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + let prevalidatedCwd: string | undefined + let startupCwdFallback: { kind: 'worktree'; cwd: string } | undefined const earlyLeafId = typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null const earlyPaneKey = @@ -5836,6 +5932,15 @@ export function registerPtyHandlers( let stablePaneBindingPersisted = false let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null + // Why hoisted to the reply scope: main reconciles the provider sequence + // deep inside the spawn path, but the pane needs that renderer-domain + // boundary beside the daemon snapshot's kitty flags. + let reconciledSnapshotSeq: number | null = null + // False when bytes crossed the data socket during the spawn RPC: the + // reconciled boundary covers them, but the daemon proved its kitty flags + // before they existed, so the claim must not erase what the pane may + // have scanned from those bytes live. + let snapshotKittyFlagsCoverReconciledSeq = true let preparedProvisionalExecutionContext = false let releaseWorktreeSpawn: (() => void) | undefined try { @@ -5843,6 +5948,7 @@ export function registerPtyHandlers( await assertFolderWorkspacePtyPathUsable(args.worktreeId) } const provider = getProvider(args.connectionId) + spawnTiming.mark('stable_adoption_setup') const preAdoptedStablePane = earlyStablePaneOwner && earlyWorktreeId ? await adoptStablePane({ @@ -5856,9 +5962,97 @@ export function registerPtyHandlers( ownsPaneSpawnReservation: true }) : null + spawnTiming.mark('stable_adoption') if (earlyStablePaneOwner && !preAdoptedStablePane) { await assertFolderWorkspacePtyPathUsable(args.worktreeId) } + if (!preAdoptedStablePane) { + // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. + const requestedMissingCwdFallback = + !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + const isPosixStartupCwd = args.cwd?.startsWith('/') === true + const startupWorkspaceCwd = + requestedMissingCwdFallback && isPosixStartupCwd + ? resolvePtySpawnStartupCwd(args.worktreeId, '.') + : undefined + const initiallyResolvedStartupCwd = + requestedMissingCwdFallback && isPosixStartupCwd ? cwd : undefined + const startupTerminalRuntimeOptions = + requestedMissingCwdFallback && process.platform === 'win32' + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : undefined + const wslRuntimeOwnsStartupCwd = + requestedMissingCwdFallback && + isPosixStartupCwd && + (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || + isWslUncPath(startupWorkspaceCwd ?? '')) + const startupWslContext = wslRuntimeOwnsStartupCwd + ? resolveWslSessionContext({ + cwd: startupWorkspaceCwd, + shellOverride: startupTerminalRuntimeOptions?.shellOverride, + terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro + }) + : undefined + let wslStartupCwdExists: boolean | null = null + let wslWorkspaceCwdExists: boolean | null = null + if (startupWslContext && initiallyResolvedStartupCwd) { + const validationCwd = toWindowsWslPath( + initiallyResolvedStartupCwd, + startupWslContext.distro + ) + wslStartupCwdExists = isWslUncPath(validationCwd) + ? await wslUncDirectoryExistsAsync(validationCwd) + : localStartupCwdDirectoryExists(validationCwd) + if (wslStartupCwdExists === true) { + prevalidatedCwd = validationCwd + } + if (wslStartupCwdExists === false && startupWorkspaceCwd) { + wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) + ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) + : localStartupCwdDirectoryExists(startupWorkspaceCwd) + if (wslWorkspaceCwdExists === true) { + prevalidatedCwd = startupWorkspaceCwd + } + } + } + const allowMissingCwdFallback = + requestedMissingCwdFallback && + (!wslRuntimeOwnsStartupCwd || + (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) + let didFallbackToWorkspaceRootCwd = false + cwd = resolvePtySpawnStartupCwd( + args.worktreeId, + args.cwd, + allowMissingCwdFallback + ? { + directoryExists: (path) => + startupWslContext && + wslStartupCwdExists === false && + path === initiallyResolvedStartupCwd + ? false + : startupWslContext && path === startupWorkspaceCwd + ? wslWorkspaceCwdExists === true + : localStartupCwdDirectoryExists( + process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path + ), + onFallbackToWorkspaceRoot: () => { + didFallbackToWorkspaceRootCwd = true + } + } + : undefined + ) + if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && cwd) { + prevalidatedCwd = cwd + } + startupCwdFallback = + didFallbackToWorkspaceRootCwd && cwd ? { kind: 'worktree', cwd } : undefined + } + spawnTiming.mark('preflight') const freshSpawnRecovery = preAdoptedStablePane ? undefined : recoverFreshSpawnProviderRouting(provider, args.connectionId, args.sessionId) @@ -6197,6 +6391,7 @@ export function registerPtyHandlers( cols: args.cols, rows: args.rows, cwd, + ...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}), env: spawnEnv, ...(isMintedSessionId ? { isNewSession: true } : {}) } @@ -6366,11 +6561,19 @@ export function registerPtyHandlers( assertSpawnReplyWasLive(result) runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) if (result.providerSequence) { - runtime?.synchronizePtyOutputSequenceFromProvider?.( - result.id, - result.providerSequence, - sequenceBeforeProviderSpawn - ) + const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0 + // Why kept: this is the reattach boundary in the RENDERER's sequence + // domain, and the daemon snapshot's kitty flags mean nothing without + // the boundary they were proven at. + reconciledSnapshotSeq = + runtime?.synchronizePtyOutputSequenceFromProvider?.( + result.id, + result.providerSequence, + sequenceBeforeProviderSpawn + ) ?? null + if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { + snapshotKittyFlagsCoverReconciledSeq = false + } } ensureWslHookRelayForReattach(result, args.connectionId) runtime?.preparePtyExecutionContext?.( @@ -6746,6 +6949,13 @@ export function registerPtyHandlers( } const response = { ...result, + // Why both or neither: a pane can only adopt proven kitty flags together + // with the sequence boundary they describe. + ...(typeof result.snapshotKittyKeyboardFlags === 'number' && + reconciledSnapshotSeq !== null && + snapshotKittyFlagsCoverReconciledSeq + ? { snapshotSeq: reconciledSnapshotSeq } + : { snapshotKittyKeyboardFlags: undefined }), ...(!result.isReattach && effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), @@ -6765,6 +6975,11 @@ export function registerPtyHandlers( codexHomePath: selectedCodexHomePath, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + reattachedCodexHomeRoutes, + result.id, + result.isReattach === true + ), ...(result.isReattach === true ? { reattached: true } : baseEnv diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 55043e2b526..9bee285bff4 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -6,9 +6,7 @@ const { callRuntimeEnvironmentMock, registerCliHandlersMock, registerPreflightHandlersMock, - registerClaudeUsageHandlersMock, - registerCodexUsageHandlersMock, - registerOpenCodeUsageHandlersMock, + registerUsageProviderHandlersMock, registerGitHubHandlersMock, registerFeedbackHandlersMock, registerStatsHandlersMock, @@ -50,6 +48,7 @@ const { registerAppHandlersMock, registerLinearHandlersMock, registerJiraHandlersMock, + registerBitbucketHandlersMock, registerGitLabHandlersMock, registerHostedReviewHandlersMock, registerExportHandlersMock, @@ -72,9 +71,7 @@ const { callRuntimeEnvironmentMock: vi.fn(), registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), - registerClaudeUsageHandlersMock: vi.fn(), - registerCodexUsageHandlersMock: vi.fn(), - registerOpenCodeUsageHandlersMock: vi.fn(), + registerUsageProviderHandlersMock: vi.fn(), registerGitHubHandlersMock: vi.fn(), registerFeedbackHandlersMock: vi.fn(), registerStatsHandlersMock: vi.fn(), @@ -116,6 +113,7 @@ const { registerAppHandlersMock: vi.fn(), registerLinearHandlersMock: vi.fn(), registerJiraHandlersMock: vi.fn(), + registerBitbucketHandlersMock: vi.fn(), registerGitLabHandlersMock: vi.fn(), registerHostedReviewHandlersMock: vi.fn(), registerExportHandlersMock: vi.fn(), @@ -180,16 +178,8 @@ vi.mock('./preflight', () => ({ registerPreflightHandlers: registerPreflightHandlersMock })) -vi.mock('./claude-usage', () => ({ - registerClaudeUsageHandlers: registerClaudeUsageHandlersMock -})) - -vi.mock('./codex-usage', () => ({ - registerCodexUsageHandlers: registerCodexUsageHandlersMock -})) - -vi.mock('./opencode-usage', () => ({ - registerOpenCodeUsageHandlers: registerOpenCodeUsageHandlersMock +vi.mock('./usage-provider-handlers', () => ({ + registerUsageProviderHandlers: registerUsageProviderHandlersMock })) vi.mock('./github', () => ({ @@ -372,6 +362,10 @@ vi.mock('./jira', () => ({ registerJiraHandlers: registerJiraHandlersMock })) +vi.mock('./bitbucket', () => ({ + registerBitbucketHandlers: registerBitbucketHandlersMock +})) + vi.mock('./gitlab', () => ({ registerGitLabHandlers: registerGitLabHandlersMock })) @@ -395,9 +389,7 @@ describe('registerCoreHandlers', () => { callRuntimeEnvironmentMock.mockReset() registerCliHandlersMock.mockReset() registerPreflightHandlersMock.mockReset() - registerClaudeUsageHandlersMock.mockReset() - registerCodexUsageHandlersMock.mockReset() - registerOpenCodeUsageHandlersMock.mockReset() + registerUsageProviderHandlersMock.mockReset() registerGitHubHandlersMock.mockReset() registerFeedbackHandlersMock.mockReset() registerStatsHandlersMock.mockReset() @@ -438,6 +430,7 @@ describe('registerCoreHandlers', () => { registerAppHandlersMock.mockReset() registerLinearHandlersMock.mockReset() registerJiraHandlersMock.mockReset() + registerBitbucketHandlersMock.mockReset() registerGitLabHandlersMock.mockReset() registerHostedReviewHandlersMock.mockReset() registerExportHandlersMock.mockReset() @@ -494,9 +487,11 @@ describe('registerCoreHandlers', () => { result: { sessions: 'bad-shape' } }) - expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) - expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) - expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) + expect(registerUsageProviderHandlersMock).toHaveBeenCalledWith({ + claudeUsage, + codexUsage, + openCodeUsage + }) expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith( codexAccounts, @@ -516,6 +511,7 @@ describe('registerCoreHandlers', () => { expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() expect(registerJiraHandlersMock).toHaveBeenCalled() + expect(registerBitbucketHandlersMock).toHaveBeenCalled() expect(registerGitLabHandlersMock).toHaveBeenCalledWith(store) expect(registerHostedReviewHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerFeedbackHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index b685f470eb0..7825e73c57e 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -8,14 +8,13 @@ import type { StatsCollector } from '../stats/collector' import { registerFilesystemHandlers } from './filesystem' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { registerFilesystemWatcherHandlers } from './filesystem-watcher' -import { registerClaudeUsageHandlers } from './claude-usage' -import { registerCodexUsageHandlers } from './codex-usage' -import { registerOpenCodeUsageHandlers } from './opencode-usage' +import { registerUsageProviderHandlers } from './usage-provider-handlers' import { registerGitHubHandlers } from './github' import { registerGitLabHandlers } from './gitlab' import { registerHostedReviewHandlers } from './hosted-review' import { registerLinearHandlers } from './linear' import { registerJiraHandlers } from './jira' +import { registerBitbucketHandlers } from './bitbucket' import { registerFeedbackHandlers } from './feedback' import { registerCrashReportingHandlers } from './crash-reporting' import { registerExportHandlers } from './export' @@ -143,9 +142,7 @@ export function registerCoreHandlers( registerAppHandlers(store, { onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch }) registerCliHandlers() registerPreflightHandlers() - registerClaudeUsageHandlers(claudeUsage) - registerCodexUsageHandlers(codexUsage) - registerOpenCodeUsageHandlers(openCodeUsage) + registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage }) registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) @@ -159,6 +156,7 @@ export function registerCoreHandlers( registerHostedReviewHandlers(store, stats) registerLinearHandlers() registerJiraHandlers() + registerBitbucketHandlers() registerFeedbackHandlers() if (crashReports) { registerCrashReportingHandlers(crashReports) @@ -227,8 +225,7 @@ export function registerCoreHandlers( resolveRuntimeAiVaultSessionTitles: async (environmentId, args) => resolveRuntimeAiVaultSessionTitles(app.getPath('userData'), environmentId, args), prepareRuntimeSessionResume: async (environmentId, args) => - prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args), - getSessionLiveness: (target) => runtime.getAiVaultSessionLiveness(target) + prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args) }) registerNativeChatHandlers() registerClipboardHandlers(store) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index b6ec0118a61..bbd639eac32 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -18,6 +18,7 @@ const { mockFilesystemProvider, mockMultiplexer, gitExecFileAsyncMock, + gitSpawnAfterWindowsEnvironmentReadyMock, gitSpawnMock, listWorktreeGraphMock, invalidateAuthorizedRootsCacheMock, @@ -69,6 +70,7 @@ const { notify: vi.fn() }, gitSpawnMock: vi.fn(), + gitSpawnAfterWindowsEnvironmentReadyMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), listWorktreeGraphMock: vi.fn(), invalidateAuthorizedRootsCacheMock: vi.fn(), @@ -104,7 +106,8 @@ vi.mock('../git/runner', async () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileAsyncBuffer: vi.fn(), gitStreamStdout: vi.fn(), - gitSpawn: gitSpawnMock + gitSpawn: gitSpawnMock, + gitSpawnAfterWindowsEnvironmentReady: gitSpawnAfterWindowsEnvironmentReadyMock })) vi.mock('../git/worktree', () => ({ @@ -1149,7 +1152,7 @@ describe('repos:addRemote', () => { gitSpawnMock.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => proc.emit('close', 0, null)) + setImmediate(() => proc.emit('close', 0, null)) return proc }) mockWindow.webContents.send.mockReset() @@ -1849,11 +1852,14 @@ describe('repos:add + repos:clone', () => { mockStore.updateProjectHostSetup.mockReset() mockWindow.webContents.send.mockReset() gitSpawnMock.mockReset() + gitSpawnAfterWindowsEnvironmentReadyMock + .mockReset() + .mockImplementation(async (...args) => gitSpawnMock(...args)) invalidateAuthorizedRootsCacheMock.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) gitSpawnMock.mockImplementation(() => { const proc = createMockCloneProcess() - queueMicrotask(() => proc.emit('close', 0, null)) + setImmediate(() => proc.emit('close', 0, null)) return proc }) @@ -2161,6 +2167,28 @@ describe('repos:add + repos:clone', () => { expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() }) + it('persists agent worktree visibility through local repos:update', () => { + const updated = { + id: 'repo-agent-visibility', + path: '/tmp/repo-agent-visibility', + displayName: 'repo-agent-visibility', + kind: 'git', + badgeColor: '#22c55e', + agentWorktreeVisibility: 'show' + } + mockStore.updateRepo.mockReturnValue(updated) + + const result = handlers.get('repos:update')!(null, { + repoId: updated.id, + updates: { agentWorktreeVisibility: 'show' } + }) + + expect(result).toBe(updated) + expect(mockStore.updateRepo).toHaveBeenCalledWith(updated.id, { + agentWorktreeVisibility: 'show' + }) + }) + it('prepares and invalidates roots when project host setup update changes worktree base path', () => { const repo = { id: 'repo-setup-update-root', @@ -2243,7 +2271,7 @@ describe('repos:add + repos:clone', () => { ) gitSpawnMock.mockImplementationOnce(() => { const proc = createMockCloneProcess() - queueMicrotask(() => { + setImmediate(() => { cloned = true proc.emit('close', 0, null) }) @@ -2390,6 +2418,84 @@ describe('repos:add + repos:clone', () => { await expect(handlers.get('repos:cloneAbort')!(null, undefined)).resolves.toBeUndefined() }) + it('cancels a local clone before Git starts when environment readiness is pending', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + gitSpawnAfterWindowsEnvironmentReadyMock.mockImplementation( + (_args: string[], options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const abort = (): void => { + reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })) + } + if (options.signal?.aborted) { + abort() + } else { + options.signal?.addEventListener('abort', abort, { once: true }) + } + }) + ) + + const clonePromise = Promise.resolve( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + ) + const rejection = expect(clonePromise).rejects.toThrow('Clone failed') + await waitForAssertion(() => + expect(gitSpawnAfterWindowsEnvironmentReadyMock).toHaveBeenCalledOnce() + ) + expect(gitSpawnMock).not.toHaveBeenCalled() + + await handlers.get('repos:cloneAbort')!(null, undefined) + await rejection + expect(gitSpawnMock).not.toHaveBeenCalled() + expect(existsSync(clonePath)).toBe(false) + }) + + it('cancels every concurrent local clone waiting for environment readiness', async () => { + const firstDestination = await createTempRoot() + const secondDestination = await createTempRoot() + gitSpawnAfterWindowsEnvironmentReadyMock.mockImplementation( + (_args: string[], options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const abort = (): void => { + reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })) + } + if (options.signal?.aborted) { + abort() + } else { + options.signal?.addEventListener('abort', abort, { once: true }) + } + }) + ) + + const firstClone = Promise.resolve( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/first.git', + destination: firstDestination + }) + ) + const secondClone = Promise.resolve( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/second.git', + destination: secondDestination + }) + ) + const firstRejection = expect(firstClone).rejects.toThrow('Clone failed') + const secondRejection = expect(secondClone).rejects.toThrow('Clone failed') + await waitForAssertion(() => + expect(gitSpawnAfterWindowsEnvironmentReadyMock).toHaveBeenCalledTimes(2) + ) + + await handlers.get('repos:cloneAbort')!(null, undefined) + + await Promise.all([firstRejection, secondRejection]) + expect(gitSpawnMock).not.toHaveBeenCalled() + expect(existsSync(join(firstDestination, 'first'))).toBe(false) + expect(existsSync(join(secondDestination, 'second'))).toBe(false) + }) + it('does not remove an existing target directory when aborting a pending clone', async () => { const destination = await createTempRoot() const clonePath = join(destination, 'orca') diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index bb1d8e6ff69..b1500963f27 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -41,10 +41,16 @@ import { isTuiAgent } from '../../shared/tui-agent-config' import { TaskSourceContextSchema } from '../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema' import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace-linked-item-source-context' +import { DiffCommentSchema } from '../../shared/diff-comment-schema' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'node:child_process' import { access, mkdir, readdir, rm } from 'node:fs/promises' -import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner' +import { + awaitWindowsHostGitEnvironmentReady, + gitExecFileAsync, + gitSpawnAfterWindowsEnvironmentReady, + nonInteractiveGitEnv +} from '../git/runner' import { isAbsolute, join, posix } from 'node:path' import { cleanupClaimedCloneTarget, @@ -283,6 +289,9 @@ async function addLocalRepoFromPath( kind: 'git' | 'folder' = 'git' ): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> { const repoKind = kind === 'folder' ? 'folder' : 'git' + if (repoKind === 'git') { + await awaitWindowsHostGitEnvironmentReady({ cwd: path }) + } if (repoKind === 'git' && !isGitRepo(path)) { return { error: `Not a valid git repository: ${path}` } } @@ -762,6 +771,7 @@ export function setRepoRemoteClientNotifier(notifier: RepoRemoteClientNotifier): // Why: module-scoped so the abort handle survives macOS window re-creation, when registerRepoHandlers re-runs. let activeClone: ActiveCloneMetadata | null = null +const pendingLocalCloneControllers = new Set() let activeRemoteClone: ActiveRemoteCloneMetadata | null = null let nextCloneGeneration = 1 const latestCloneGenerationByPath = new Map() @@ -942,7 +952,8 @@ const FolderWorkspaceUpdateArgs = z.object({ createdWithAgent: z.string().refine(isTuiAgent).optional(), pendingFirstAgentMessageRename: z.boolean().optional(), firstAgentMessageRenameError: z.string().nullable().optional(), - lastActivityAt: z.number().finite().optional() + lastActivityAt: z.number().finite().optional(), + diffComments: z.array(DiffCommentSchema).optional() }) .superRefine(assertFolderWorkspaceLinkedSourceContextMatch) }) @@ -1169,6 +1180,10 @@ async function scanNestedReposForIpc(args: { }): Promise { validateNestedRepoScanRoot(args.path, args.connectionId) if (!args.connectionId) { + await awaitWindowsHostGitEnvironmentReady({ + cwd: args.path, + ...(args.signal ? { signal: args.signal } : {}) + }) return scanNestedRepos({ path: args.path, options: args.options, @@ -1507,7 +1522,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) - const workspace = store.createFolderWorkspace(args) + const workspace = store.createFolderWorkspace({ + ...args, + creatorProvenance: { kind: 'host' } + }) notifyReposChanged(mainWindow) return workspace } @@ -1699,10 +1717,16 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v continue } importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider) - } else if (!isGitRepo(repoPath)) { - results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) - continue } else { + await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) + if (!isGitRepo(repoPath)) { + results.push({ + path: repoPath, + status: 'failed', + error: 'Not a valid git repository' + }) + continue + } importRepoPath = await importTargetResolver.resolveLocal(repoPath) } const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath) @@ -2102,6 +2126,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' | 'projectGroupId' | 'projectGroupOrder' > @@ -2172,6 +2197,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) { delete updates.externalWorktreeVisibility } + if ( + 'agentWorktreeVisibility' in updates && + updates.agentWorktreeVisibility !== undefined && + updates.agentWorktreeVisibility !== 'hide' && + updates.agentWorktreeVisibility !== 'show' + ) { + delete updates.agentWorktreeVisibility + } if ( 'externalWorktreeVisibilityPromptDismissedAt' in updates && updates.externalWorktreeVisibilityPromptDismissedAt !== undefined && @@ -2322,6 +2355,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v }) ipcMain.handle('repos:cloneAbort', async () => { + for (const controller of pendingLocalCloneControllers) { + controller.abort() + } + pendingLocalCloneControllers.clear() if (activeClone) { const clone = activeClone clone.abortRequested = true @@ -2358,24 +2395,30 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // Why: spawn (not execFile) avoids the maxBuffer limit — clone progress on stderr can exceed Node's 1 MB default. // Why: --progress forces git to emit progress even when stderr isn't a TTY. const cloneMetadataRef: { current: ActiveCloneMetadata | null } = { current: null } - await new Promise((resolve, reject) => { + let proc: Awaited> + const pendingController = new AbortController() + pendingLocalCloneControllers.add(pendingController) + try { // Why: use the parent destination as cwd so the runner detects a WSL path and routes through wsl.exe. // Why: '--' isolates the URL so a malicious URL can't be read as git flags (command injection). - let proc: ReturnType - try { - proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], { + proc = await gitSpawnAfterWindowsEnvironmentReady( + ['clone', '--progress', '--', args.url, clonePath], + { cwd: args.destination, // Why: without this, an auth-needing clone pops Git Credential Manager's OAuth window on Windows, unclosable in a restricted env (issue #7652). env: nonInteractiveGitEnv(), + signal: pendingController.signal, stdio: ['ignore', 'ignore', 'pipe'] - }) - } catch (err) { - void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => { - const message = err instanceof Error ? err.message : String(err) - reject(new Error(`Clone failed: ${message}`)) - }) - return - } + } + ) + } catch (err) { + await cleanupClaimedCloneTarget(clonePath, claimedTarget) + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Clone failed: ${message}`) + } finally { + pendingLocalCloneControllers.delete(pendingController) + } + await new Promise((resolve, reject) => { const generation = nextCloneGeneration++ latestCloneGenerationByPath.set(clonePathKey, generation) const metadata: ActiveCloneMetadata = { diff --git a/src/main/ipc/runtime-environment-federated-read-routing.test.ts b/src/main/ipc/runtime-environment-federated-read-routing.test.ts new file mode 100644 index 00000000000..09e649e73f5 --- /dev/null +++ b/src/main/ipc/runtime-environment-federated-read-routing.test.ts @@ -0,0 +1,173 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' + +const { sendRemoteRuntimeRequestMock, sendRemoteRuntimeSharedControlRequestMock } = vi.hoisted( + () => ({ + sendRemoteRuntimeRequestMock: vi.fn(), + sendRemoteRuntimeSharedControlRequestMock: vi.fn() + }) +) + +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock +})) + +vi.mock('./runtime-environment-request-connections', () => ({ + sendRemoteRuntimeConnectionRequest: vi.fn(), + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + reconnectRemoteRuntimeSharedControlConnection: vi.fn() +})) + +import { + callRuntimeEnvironment, + resetSharedControlSupport +} from './runtime-environment-transport-routing' + +describe('federated read RPC transport routing', () => { + let userDataPath: string + let environmentId: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-federated-read-routing-')) + environmentId = addEnvironmentFromPairingCode(userDataPath, { + name: 'worker', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) + }).id + resetSharedControlSupport() + sendRemoteRuntimeRequestMock.mockReset() + sendRemoteRuntimeSharedControlRequestMock.mockReset() + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('routes an enveloped federation read over shared control with its authority intact', async () => { + const envelope = { + orchestrationCapability: 'capability', + orchestrationContractVersion: 1, + orchestrationRequestId: 'request-1', + compatibilityInvocationId: 'compatibility-1', + orchestrationCompatibilityEvidence: { + terminalHandle: 'term-1', + paneKey: 'pane-1', + launchToken: 'launch-1' + } + } + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: { capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] }, + _meta: { runtimeId: 'runtime-worker' } + }) + sendRemoteRuntimeSharedControlRequestMock.mockResolvedValue({ + id: 'pull', + ok: true, + result: { runtimeEpoch: 'runtime-worker', items: [] }, + _meta: { runtimeId: 'runtime-worker' } + }) + + await expect( + callRuntimeEnvironment( + userDataPath, + environmentId, + 'orchestration.federationPull', + { dispatchId: 'dispatch-1', afterSequence: 0, limit: 50 }, + 15_000, + undefined, + envelope + ) + ).resolves.toMatchObject({ ok: true }) + + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual(['status.get']) + expect(sendRemoteRuntimeSharedControlRequestMock).toHaveBeenCalledWith( + environmentId, + expect.any(Object), + 'orchestration.federationPull', + { dispatchId: 'dispatch-1', afterSequence: 0, limit: 50 }, + 15_000, + envelope + ) + }) + + it('keeps enveloped federation mutations on the one-shot transport', async () => { + const envelope = { orchestrationContractVersion: 1, orchestrationRequestId: 'ack-1' } + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'ack', + ok: true, + result: { acknowledgedThrough: 4 }, + _meta: { runtimeId: 'runtime-worker' } + }) + + await callRuntimeEnvironment( + userDataPath, + environmentId, + 'orchestration.federationAck', + { dispatchId: 'dispatch-1', throughSequence: 4 }, + 15_000, + undefined, + envelope + ) + + expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.any(Object), + 'orchestration.federationAck', + { dispatchId: 'dispatch-1', throughSequence: 4 }, + 15_000, + envelope + ) + expect(sendRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled() + }) + + it('falls back to one-shot with the envelope when shared control is unavailable', async () => { + const envelope = { orchestrationContractVersion: 1 } + sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => + method === 'status.get' + ? { + id: 'status', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'runtime-worker' } + } + : { + id: 'pull', + ok: true, + result: { runtimeEpoch: 'runtime-worker', items: [] }, + _meta: { runtimeId: 'runtime-worker' } + } + ) + + await callRuntimeEnvironment( + userDataPath, + environmentId, + 'orchestration.federationPull', + { dispatchId: 'dispatch-1', afterSequence: 0, limit: 50 }, + 15_000, + undefined, + envelope + ) + + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([ + 'status.get', + 'orchestration.federationPull' + ]) + expect(sendRemoteRuntimeRequestMock).toHaveBeenLastCalledWith( + expect.any(Object), + 'orchestration.federationPull', + { dispatchId: 'dispatch-1', afterSequence: 0, limit: 50 }, + 15_000, + envelope + ) + expect(sendRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts b/src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts new file mode 100644 index 00000000000..2e6893ba4ea --- /dev/null +++ b/src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts @@ -0,0 +1,207 @@ +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { RemoteRuntimeSharedControlConnection } from '../../shared/remote-runtime-shared-control-connection' +import { RuntimeEnvironmentStoreSchema } from '../../shared/runtime-environments' + +const runLiveBenchmark = isFederatedReadBenchmarkEnabled(process.env.ORCA_FEDERATED_READ_BENCH) + +it('interpolates even-sized benchmark samples', () => expect(percentile([1, 3], 0.5)).toBe(2)) + +it.each([ + ['1', true], + ['true', false], + [undefined, false] +])('enables the live benchmark only for %s', (value, enabled) => { + expect(isFederatedReadBenchmarkEnabled(value)).toBe(enabled) +}) + +it('closes the retained connection after a measured request rejects', async () => { + let requestCount = 0 + const fixture = createSharedControlFixture(async () => { + requestCount += 1 + if (requestCount === 4) { + throw new Error('injected measured request failure') + } + return { ok: true } + }) + + await expect(measureSharedControlRequests(fixture.connection, {}, 1)).rejects.toThrow( + 'injected measured request failure' + ) + + expect(requestCount).toBe(4) + expect.soft(fixture.close).toHaveBeenCalledOnce() + expect.soft(fixture.socketClose).toHaveBeenCalledOnce() + expect(fixture.retainedHandleCount()).toBe(0) +}) + +it('closes the retained connection after successful measurements', async () => { + const fixture = createSharedControlFixture(async () => ({ ok: true })) + + await expect(measureSharedControlRequests(fixture.connection, {}, 1)).resolves.toMatchObject({ + sharedControl: { count: 1 }, + diagnostics: { state: 'ready', pendingRequestCount: 0 } + }) + + expect(fixture.close).toHaveBeenCalledOnce() + expect(fixture.socketClose).toHaveBeenCalledOnce() + expect(fixture.retainedHandleCount()).toBe(0) +}) + +describe.runIf(runLiveBenchmark)('federated read RPC transport benchmark', () => { + it('compares one-shot and shared-control latency on one saved runtime', async () => { + const userDataPath = process.env.ORCA_RUNTIME_USER_DATA_PATH + const environmentName = process.env.ORCA_RUNTIME_ENVIRONMENT + if (!userDataPath || !environmentName) { + throw new Error('Set ORCA_RUNTIME_USER_DATA_PATH and ORCA_RUNTIME_ENVIRONMENT.') + } + const store = RuntimeEnvironmentStoreSchema.parse( + JSON.parse(readFileSync(join(userDataPath, 'orca-environments.json'), 'utf8')) + ) + const environment = store.environments.find((entry) => entry.name === environmentName) + if (!environment) { + throw new Error(`Unknown runtime environment: ${environmentName}`) + } + const endpoint = + environment.endpoints.find((entry) => entry.id === environment.preferredEndpointId) ?? + environment.endpoints[0] + if (!endpoint) { + throw new Error(`Runtime environment ${environmentName} has no endpoint.`) + } + const pairing = { + v: 2 as const, + endpoint: endpoint.endpoint, + deviceToken: endpoint.deviceToken, + publicKeyB64: endpoint.publicKeyB64 + } + const params = { + dispatchId: 'sta3880_benchmark_missing', + afterSequence: 0, + limit: 50 + } + const oneShot = await measureRequests(() => + sendRemoteRuntimeRequest(pairing, 'orchestration.federationPull', params, 15_000, { + orchestrationContractVersion: 1 + }) + ) + const shared = new RemoteRuntimeSharedControlConnection(pairing, { + environmentId: environment.id + }) + const { sharedControl, diagnostics } = await measureSharedControlRequests(shared, params) + + expect(diagnostics).toMatchObject({ state: 'ready', pendingRequestCount: 0 }) + process.stdout.write(`${JSON.stringify({ oneShot, sharedControl }, null, 2)}\n`) + }, 120_000) +}) + +async function measureSharedControlRequests( + shared: RemoteRuntimeSharedControlConnection, + params: unknown, + count = 30 +): Promise<{ + sharedControl: Awaited> + diagnostics: ReturnType +}> { + try { + const sharedControl = await measureRequests( + () => + shared.request('orchestration.federationPull', params, 15_000, { + orchestrationContractVersion: 1 + }), + count + ) + const diagnostics = shared.getDiagnostics() + return { sharedControl, diagnostics } + } finally { + shared.close() + } +} + +async function measureRequests( + request: () => Promise<{ ok: boolean; error?: { code: string } }>, + count = 30 +): Promise<{ + count: number + meanMs: number + medianMs: number + p95Ms: number + minMs: number + maxMs: number + responseCodes: string[] +}> { + for (let index = 0; index < 3; index++) { + await request() + } + const durations: number[] = [] + const responseCodes = new Set() + for (let index = 0; index < count; index++) { + const startedAt = performance.now() + const response = await request() + durations.push(performance.now() - startedAt) + if (!response.ok && response.error) { + responseCodes.add(response.error.code) + } + } + durations.sort((left, right) => left - right) + return { + count, + meanMs: round(durations.reduce((sum, value) => sum + value, 0) / count), + medianMs: round(percentile(durations, 0.5)), + p95Ms: round(percentile(durations, 0.95)), + minMs: round(durations[0] ?? 0), + maxMs: round(durations.at(-1) ?? 0), + responseCodes: [...responseCodes] + } +} + +function percentile(sorted: number[], fraction: number): number { + if (sorted.length === 0) { + return 0 + } + const rank = (sorted.length - 1) * fraction + const lowerIndex = Math.floor(rank) + const upperIndex = Math.ceil(rank) + const lower = sorted[lowerIndex] ?? 0 + const upper = sorted[upperIndex] ?? lower + return lower + (upper - lower) * (rank - lowerIndex) +} + +function round(value: number): number { + return Math.round(value * 100) / 100 +} + +function isFederatedReadBenchmarkEnabled(value: string | undefined): boolean { + return value === '1' +} + +function createSharedControlFixture( + request: () => Promise<{ ok: boolean; error?: { code: string } }> +): { + connection: RemoteRuntimeSharedControlConnection + close: ReturnType + socketClose: ReturnType + retainedHandleCount: () => number +} { + const connection = new RemoteRuntimeSharedControlConnection({ + v: 2, + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) + const socketClose = vi.fn() + const unsafe = connection as unknown as { + state: string + ws: { readyState: number; close: () => void } | null + sharedKey: Uint8Array | null + request: typeof request + } + unsafe.state = 'ready' + unsafe.ws = { readyState: 1, close: socketClose } + unsafe.sharedKey = new Uint8Array(32).fill(2) + unsafe.request = vi.fn(request) + const close = vi.spyOn(connection, 'close') + return { connection, close, socketClose, retainedHandleCount: () => (unsafe.ws ? 1 : 0) } +} diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 946a0d30cf8..58fb95dd6e1 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -1,5 +1,8 @@ import type { PairingOffer } from '../../shared/pairing' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { + RuntimeOrchestrationEnvelope, + RuntimeRpcResponse +} from '../../shared/runtime-rpc-envelope' import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' import { RemoteRuntimeSharedControlConnection } from '../../shared/remote-runtime-shared-control-connection' import type { @@ -52,9 +55,15 @@ export function sendRemoteRuntimeSharedControlRequest( pairing: PairingOffer, method: string, params: unknown, - timeoutMs: number + timeoutMs: number, + envelope?: RuntimeOrchestrationEnvelope ): Promise> { - return getSharedControlConnection(environmentId, pairing).request(method, params, timeoutMs) + return getSharedControlConnection(environmentId, pairing).request( + method, + params, + timeoutMs, + envelope + ) } export function subscribeRemoteRuntimeSharedControlRequest( diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 852b97273c5..884b0b2874a 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,5 +1,6 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' +import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse @@ -66,7 +67,10 @@ export async function getRuntimeEnvironmentStatus( ) } if (response.ok === true) { - markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) + markEnvironmentUsed(userDataPath, environment.id, { + runtimeId: response._meta.runtimeId, + pairedDeviceId: response.result.pairedDeviceId + }) reconnectRemoteRuntimeSharedControlConnection(environment.id) } return attachRemoteControlDiagnostics( @@ -105,7 +109,8 @@ export async function callRuntimeEnvironment( const pairing = getPreferredPairingOffer(currentEnvironment) endpoint = pairing.endpoint const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS - if (envelope) { + const sharedControlEnvelope = shouldUseSharedControlEnvelope(method, params, envelope) + if (envelope && !sharedControlEnvelope) { const response = await sendRemoteRuntimeRequest( pairing, method, @@ -132,19 +137,36 @@ export async function callRuntimeEnvironment( !shouldUseOneShotRequest(method) && (await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs)) ) { - const response = await sendRemoteRuntimeSharedControlRequest( - currentEnvironment.id, - pairing, - method, - params, - effectiveTimeoutMs - ) + const response = sharedControlEnvelope + ? await sendRemoteRuntimeSharedControlRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs, + sharedControlEnvelope + ) + : await sendRemoteRuntimeSharedControlRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs + ) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response } // Why: startup/control-plane RPCs use the proven one-shot path so repo // hydration cannot be coupled to a stale terminal-control connection. - const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) + const response = sharedControlEnvelope + ? await sendRemoteRuntimeRequest( + pairing, + method, + params, + effectiveTimeoutMs, + sharedControlEnvelope + ) + : await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response }) @@ -250,6 +272,16 @@ function shouldUseCachedRequestConnection(method: string): boolean { return method === 'terminal.send' || method === 'terminal.updateViewport' } +function shouldUseSharedControlEnvelope( + method: string, + params: unknown, + envelope: RuntimeOrchestrationEnvelope | undefined +): RuntimeOrchestrationEnvelope | undefined { + return envelope && method.startsWith('orchestration.') && !isOrchestrationMutation(method, params) + ? envelope + : undefined +} + function shouldUseOneShotRequest(method: string): boolean { // Why: snapshot recovery must remain available while a retained shared-control stream is reconnecting after a HUB restart. return method === 'session.tabs.list' || method === 'session.tabs.listAll' diff --git a/src/main/ipc/runtime-watcher-quarantine-queue.ts b/src/main/ipc/runtime-watcher-quarantine-queue.ts index e326b299943..bc2b4cf5fd4 100644 --- a/src/main/ipc/runtime-watcher-quarantine-queue.ts +++ b/src/main/ipc/runtime-watcher-quarantine-queue.ts @@ -33,7 +33,7 @@ export class RuntimeWatcherQuarantineQueue { } settled = true const index = this.waiters.indexOf(waiter) - if (index >= 0) { + if (index !== -1) { this.waiters.splice(index, 1) } callback() diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 88199db7e41..af7117b086f 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -344,7 +344,7 @@ describe('registerSettingsHandlers', () => { }) it('updates the agent awake service when the keep-awake setting changes', () => { - const agentAwakeService = { setEnabled: vi.fn() } + const agentAwakeService = { setMode: vi.fn() } store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: true }) registerSettingsHandlers(store as never, agentAwakeService as never) @@ -356,11 +356,18 @@ describe('registerSettingsHandlers', () => { handler(settingsInvokeEvent, { keepComputerAwakeWhileAgentsRun: true }) - expect(agentAwakeService.setEnabled).toHaveBeenCalledWith(true) + expect(store.updateSettings).toHaveBeenCalledWith( + { + computerAwakeMode: 'auto', + keepComputerAwakeWhileAgentsRun: true + }, + expect.any(Object) + ) + expect(agentAwakeService.setMode).toHaveBeenCalledWith('auto') }) it('does not notify the agent awake service for unrelated setting changes', () => { - const agentAwakeService = { setEnabled: vi.fn() } + const agentAwakeService = { setMode: vi.fn() } store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) registerSettingsHandlers(store as never, agentAwakeService as never) @@ -372,7 +379,7 @@ describe('registerSettingsHandlers', () => { handler(settingsInvokeEvent, { defaultTuiAgent: 'codex' }) - expect(agentAwakeService.setEnabled).not.toHaveBeenCalled() + expect(agentAwakeService.setMode).not.toHaveBeenCalled() }) it('prepares local worktree roots when workspace directory changes', async () => { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 275ba54c82a..98e918300ab 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -29,6 +29,10 @@ import { normalizeMobilePairingCustomAddress, normalizeMobilePairingCustomAddresses } from '../../shared/mobile-pairing-custom-address' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../shared/computer-awake-mode' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -65,6 +69,18 @@ export function registerSettingsHandlers( store: Store, agentAwakeService?: AgentAwakeService ): void { + ipcMain.handle( + 'agentAwake:getStatus', + () => agentAwakeService?.getStatus() ?? { mode: 'off', active: false } + ) + agentAwakeService?.subscribe?.((status) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('agentAwake:changed', status) + } + } + }) + store.onSettingsChanged((updates, _settings, originWebContentsId) => { for (const window of BrowserWindow.getAllWindows()) { const isOrigin = @@ -109,6 +125,22 @@ export function registerSettingsHandlers( // Why: Floating Workspace grants are trusted only when written by the // main-process directory picker, never by renderer-provided settings IPC. delete sanitizedArgs.floatingTerminalTrustedCwds + if ('computerAwakeMode' in sanitizedArgs) { + Object.assign( + sanitizedArgs, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedArgs.computerAwakeMode, + sanitizedArgs.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { + Object.assign( + sanitizedArgs, + computerAwakeSettingsForMode(sanitizedArgs.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off') + ) + } if (typeof args.floatingTerminalCwd === 'string') { sanitizedArgs.floatingTerminalCwd = await sanitizeFloatingWorkspaceDirectorySetting( store, @@ -161,8 +193,13 @@ export function registerSettingsHandlers( notifyListeners: true, originWebContentsId: event.sender.id }) - if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { - agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun) + if ( + 'computerAwakeMode' in sanitizedArgs || + 'keepComputerAwakeWhileAgentsRun' in sanitizedArgs + ) { + agentAwakeService?.setMode( + normalizeComputerAwakeMode(result.computerAwakeMode, result.keepComputerAwakeWhileAgentsRun) + ) } const hookSettingChanged = ('agentStatusHooksEnabled' in sanitizedArgs && @@ -172,7 +209,7 @@ export function registerSettingsHandlers( if (hookSettingChanged) { try { await applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled, result, { - shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32', + shouldHydrateShellPath: app.isPackaged, onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const settings = store.getSettings() diff --git a/src/main/ipc/ssh-pty-legacy-projection-record.ts b/src/main/ipc/ssh-pty-legacy-projection-record.ts index 45c666dc09a..6d9843d888c 100644 --- a/src/main/ipc/ssh-pty-legacy-projection-record.ts +++ b/src/main/ipc/ssh-pty-legacy-projection-record.ts @@ -117,7 +117,7 @@ export function reclaimProjectionRecord( return } const index = ids.indexOf(id) - if (index >= 0) { + if (index !== -1) { ids.splice(index, 1) } if (ids.length === 0) { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 59ffcfbc9a9..0c504c5f0a0 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -177,7 +177,7 @@ export async function requestActiveSshAiVaultSessionList( targetId: string, params: SshAiVaultRelayListParams, options: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { +): Promise { if (isRuntimeOwnedSshTargetId(targetId)) { return null } @@ -192,7 +192,7 @@ export async function requestActiveSshAiVaultSessionTitles( targetId: string, params: SshAiVaultRelayTitleParams, options: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { +): Promise { if (isRuntimeOwnedSshTargetId(targetId)) { return null } diff --git a/src/main/ipc/terminal-preview-output-stream.test.ts b/src/main/ipc/terminal-preview-output-stream.test.ts new file mode 100644 index 00000000000..4f293015178 --- /dev/null +++ b/src/main/ipc/terminal-preview-output-stream.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WebContents } from 'electron' +import { TerminalPreviewOutputStream } from './terminal-preview-output-stream' + +function makeStream(): TerminalPreviewOutputStream { + const contents = { + id: 1, + isDestroyed: () => false, + send: vi.fn() + } as unknown as WebContents + return new TerminalPreviewOutputStream( + contents, + 'p1', + () => undefined, + () => undefined + ) +} + +// The renderer needs each buffered chunk's mode, not just its bytes. +// Returning a bare string[] made a proven post-snapshot `CSI > u` look like +// historical redelivery, so the TUI's single pop later landed on a stale frame. +describe('TerminalPreviewOutputStream.completeSnapshot', () => { + it('drops chunks the snapshot already covers', () => { + const stream = makeStream() + stream.append('abc', { seq: 5, rawLength: 3 }) + expect(stream.completeSnapshot(5)).toEqual([]) + }) + + it('gives a strictly newer sequenced chunk live mode', () => { + const stream = makeStream() + stream.append('abc', { seq: 9, rawLength: 3 }) + expect(stream.completeSnapshot(6)).toEqual([{ data: 'abc', mode: 'live' }]) + }) + + it('slices a partially covered chunk and keeps the suffix live', () => { + const stream = makeStream() + stream.append('abc', { seq: 7, rawLength: 3 }) + expect(stream.completeSnapshot(6)).toEqual([{ data: 'c', mode: 'live' }]) + }) + + it('falls back to replay mode for an unsliceable transformed overlap', () => { + const stream = makeStream() + // Renderer-side transforms make raw offsets unmappable, so the covered head + // cannot be removed and the whole chunk stays uncertain redelivery. + stream.append('abc', { seq: 7, rawLength: 3, transformed: true }) + expect(stream.completeSnapshot(6)).toEqual([{ data: 'abc', mode: 'replay' }]) + }) + + it('falls back to replay mode when the chunk carries no sequence metadata', () => { + const stream = makeStream() + stream.append('abc') + expect(stream.completeSnapshot(6)).toEqual([{ data: 'abc', mode: 'replay' }]) + }) + + it('falls back to replay mode when the snapshot itself is unsequenced', () => { + const stream = makeStream() + stream.append('abc', { seq: 9, rawLength: 3 }) + expect(stream.completeSnapshot(undefined)).toEqual([{ data: 'abc', mode: 'replay' }]) + }) +}) diff --git a/src/main/ipc/terminal-preview-output-stream.ts b/src/main/ipc/terminal-preview-output-stream.ts index af14424cbe2..16cb1613565 100644 --- a/src/main/ipc/terminal-preview-output-stream.ts +++ b/src/main/ipc/terminal-preview-output-stream.ts @@ -1,6 +1,9 @@ import type { WebContents } from 'electron' import { iterateTerminalInputChunks } from '../../shared/terminal-input' -import type { TerminalPreviewDataPayload } from '../../shared/terminal-preview' +import type { + TerminalPreviewDataPayload, + TerminalPreviewReplayChunk +} from '../../shared/terminal-preview' const OUTPUT_BATCH_MS = 5 export const TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 @@ -16,22 +19,37 @@ export type TerminalPreviewOutputMeta = { type PendingOutput = { data: string; bytes: number; meta?: TerminalPreviewOutputMeta } -function outputAfterSnapshotSeq(output: PendingOutput, snapshotSeq?: number): string | null { +/** + * Split one buffered chunk against the snapshot boundary, keeping WHY it + * survived. Only a chunk whose sequence metadata proves it is the suffix after + * that boundary may advance kitty state with live stack semantics; an overlap + * that cannot be sliced is redelivery, and a redelivered `CSI > u` applied as a + * push would leave the TUI's single pop landing on a stale frame. + */ +function outputAfterSnapshotSeq( + output: PendingOutput, + snapshotSeq?: number +): TerminalPreviewReplayChunk | null { if ( typeof snapshotSeq !== 'number' || typeof output.meta?.seq !== 'number' || typeof output.meta.rawLength !== 'number' ) { - return output.data + return { data: output.data, mode: 'replay' } } if (output.meta.seq <= snapshotSeq) { return null } const startSeq = output.meta.seq - output.meta.rawLength - if (startSeq >= snapshotSeq || output.meta.transformed === true) { - return output.data + if (startSeq >= snapshotSeq) { + return { data: output.data, mode: 'live' } } - return output.data.slice(snapshotSeq - startSeq) + if (output.meta.transformed === true) { + // Transformed payloads make raw offsets unmappable, so the covered head + // cannot be sliced off and the whole chunk stays uncertain redelivery. + return { data: output.data, mode: 'replay' } + } + return { data: output.data.slice(snapshotSeq - startSeq), mode: 'live' } } /** Owns one preview's bounded snapshot/live output queue and acknowledgements. */ @@ -101,10 +119,10 @@ export class TerminalPreviewOutputStream { return true } - completeSnapshot(snapshotSeq?: number): string[] { + completeSnapshot(snapshotSeq?: number): TerminalPreviewReplayChunk[] { const replay = this.initialPending.flatMap((output) => { const uncovered = outputAfterSnapshotSeq(output, snapshotSeq) - return uncovered ? [uncovered] : [] + return uncovered && uncovered.data.length > 0 ? [uncovered] : [] }) this.initialPending = [] this.initialPendingBytes = 0 diff --git a/src/main/ipc/terminal-preview.test.ts b/src/main/ipc/terminal-preview.test.ts index a01fd7c70eb..b8460554b73 100644 --- a/src/main/ipc/terminal-preview.test.ts +++ b/src/main/ipc/terminal-preview.test.ts @@ -122,7 +122,9 @@ describe('registerTerminalPreviewHandlers', () => { await expect(resultPromise).resolves.toEqual({ snapshot: { data: 'screen', cols: 80, rows: 20, seq: 6 }, - replay: ['c'] + // A sliced suffix is proven post-snapshot output, so it applies with live + // kitty semantics rather than as redelivery. + replay: [{ data: 'c', mode: 'live' }] }) expect(runtime.registerRawTerminalViewSubscriber).toHaveBeenCalledWith('p1') }) @@ -239,6 +241,64 @@ describe('registerTerminalPreviewHandlers', () => { expect(sender.send).not.toHaveBeenCalled() }) + // Flags and image must come from ONE capture. Publishing flags from + // a separate "current state" read would describe a different stream position, + // so replaying the intervening bytes could duplicate or reorder transitions. + it('returns the kitty flags of the capture that produced the returned image', async () => { + const runtime = makeRuntime() + const snapshotResolvers: ((snapshot: { + data: string + cols: number + rows: number + seq: number + kittyKeyboardFlags?: number + }) => void)[] = [] + runtime.serializeTerminalBuffer.mockImplementation( + () => + new Promise((resolve) => { + snapshotResolvers.push(resolve) + }) + ) + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + + const resultPromise = handlers.get('terminalPreview:connect')!(eventFor(sender), { + ptyId: 'p1' + }) as Promise + const chunk = 'x'.repeat(64 * 1024) + for (let index = 0; index < 5; index++) { + runtime.listeners[0]!(chunk) + } + // The first capture overflowed; its flags must not survive onto the second image. + snapshotResolvers[0]!({ + data: 'first', + cols: 80, + rows: 20, + seq: 1, + kittyKeyboardFlags: 0 + }) + await vi.waitFor(() => expect(snapshotResolvers).toHaveLength(2)) + snapshotResolvers[1]!({ + data: 'second', + cols: 80, + rows: 20, + seq: 2, + kittyKeyboardFlags: 8 + }) + + await expect(resultPromise).resolves.toEqual({ + snapshot: { + data: 'second', + cols: 80, + rows: 20, + seq: 2, + kittyKeyboardFlags: 8 + }, + replay: [] + }) + expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2) + }) + it('releases output and raw-view presence on unsubscribe and sender destruction', async () => { const runtime = makeRuntime() registerTerminalPreviewHandlers(runtime as never) diff --git a/src/main/ipc/usage-provider-handlers.test.ts b/src/main/ipc/usage-provider-handlers.test.ts new file mode 100644 index 00000000000..54733b87c26 --- /dev/null +++ b/src/main/ipc/usage-provider-handlers.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' + +const { handle } = vi.hoisted(() => ({ handle: vi.fn() })) + +vi.mock('electron', () => ({ ipcMain: { handle } })) + +import { registerUsageProviderHandlers } from './usage-provider-handlers' + +describe('usage provider IPC handlers', () => { + it('registers every provider route and forwards query arguments', () => { + const createUsage = () => ({ + getScanState: vi.fn(), + setEnabled: vi.fn(), + refresh: vi.fn(), + getSnapshot: vi.fn(), + getSummary: vi.fn(), + getDaily: vi.fn(), + getBreakdown: vi.fn(), + getRecentSessions: vi.fn() + }) + const claudeUsage = createUsage() + const codexUsage = createUsage() + const openCodeUsage = createUsage() + registerUsageProviderHandlers({ + claudeUsage: claudeUsage as never, + codexUsage: codexUsage as never, + openCodeUsage: openCodeUsage as never + }) + + const prefixes = ['claudeUsage', 'codexUsage', 'openCodeUsage'] + const suffixes = Object.keys(claudeUsage) + expect(handle.mock.calls.map(([channel]) => channel)).toEqual( + prefixes.flatMap((prefix) => suffixes.map((suffix) => `${prefix}:${suffix}`)) + ) + + const call = (prefix: string, suffix: string, args?: unknown): unknown => { + const handler = handle.mock.calls.find( + ([channel]) => channel === `${prefix}:${suffix}` + )?.[1] as ((event: unknown, args?: unknown) => unknown) | undefined + return handler?.({}, args) + } + call('claudeUsage', 'getScanState') + call('codexUsage', 'getScanState') + call('openCodeUsage', 'getScanState') + call('claudeUsage', 'setEnabled', { enabled: true }) + call('claudeUsage', 'refresh') + call('claudeUsage', 'refresh', { force: true }) + call('claudeUsage', 'getSnapshot', { scope: 'orca', range: '30d', limit: 7 }) + call('claudeUsage', 'getSummary', { scope: 'all', range: '7d' }) + call('claudeUsage', 'getDaily', { scope: 'orca', range: '90d' }) + call('claudeUsage', 'getBreakdown', { scope: 'all', range: 'all', kind: 'model' }) + call('claudeUsage', 'getRecentSessions', { scope: 'orca', range: '30d', limit: 4 }) + + expect(claudeUsage.getScanState).toHaveBeenCalledWith() + expect(codexUsage.getScanState).toHaveBeenCalledWith() + expect(openCodeUsage.getScanState).toHaveBeenCalledWith() + expect(claudeUsage.setEnabled).toHaveBeenCalledWith(true) + expect(claudeUsage.refresh.mock.calls).toEqual([[false], [true]]) + expect(claudeUsage.getSnapshot).toHaveBeenCalledWith('orca', '30d', 7) + expect(claudeUsage.getSummary).toHaveBeenCalledWith('all', '7d') + expect(claudeUsage.getDaily).toHaveBeenCalledWith('orca', '90d') + expect(claudeUsage.getBreakdown).toHaveBeenCalledWith('all', 'all', 'model') + expect(claudeUsage.getRecentSessions).toHaveBeenCalledWith('orca', '30d', 4) + }) +}) diff --git a/src/main/ipc/usage-provider-handlers.ts b/src/main/ipc/usage-provider-handlers.ts new file mode 100644 index 00000000000..58496f1c8ad --- /dev/null +++ b/src/main/ipc/usage-provider-handlers.ts @@ -0,0 +1,68 @@ +import { ipcMain } from 'electron' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' + +type UsageProviderStores = { + claudeUsage: ClaudeUsageStore + codexUsage: CodexUsageStore + openCodeUsage: OpenCodeUsageStore +} + +type UsageProviderChannelPrefix = keyof UsageProviderStores + +type UsageProviderHandlerStore = { + getScanState: () => unknown + setEnabled: (enabled: boolean) => unknown + refresh: (force?: boolean) => unknown + getSnapshot: (scope: Scope, range: Range, limit?: number) => unknown + getSummary: (scope: Scope, range: Range) => unknown + getDaily: (scope: Scope, range: Range) => unknown + getBreakdown: (scope: Scope, range: Range, kind: BreakdownKind) => unknown + getRecentSessions: (scope: Scope, range: Range, limit?: number) => unknown +} + +type UsageRangeArgs = { + scope: Scope + range: Range +} + +function registerProviderHandlers( + prefix: UsageProviderChannelPrefix, + usage: UsageProviderHandlerStore +): void { + ipcMain.handle(`${prefix}:getScanState`, () => usage.getScanState()) + ipcMain.handle(`${prefix}:setEnabled`, (_event, args: { enabled: boolean }) => + usage.setEnabled(args.enabled) + ) + ipcMain.handle(`${prefix}:refresh`, (_event, args?: { force?: boolean }) => + usage.refresh(args?.force ?? false) + ) + ipcMain.handle( + `${prefix}:getSnapshot`, + (_event, args: UsageRangeArgs & { limit?: number }) => + usage.getSnapshot(args.scope, args.range, args.limit) + ) + ipcMain.handle(`${prefix}:getSummary`, (_event, args: UsageRangeArgs) => + usage.getSummary(args.scope, args.range) + ) + ipcMain.handle(`${prefix}:getDaily`, (_event, args: UsageRangeArgs) => + usage.getDaily(args.scope, args.range) + ) + ipcMain.handle( + `${prefix}:getBreakdown`, + (_event, args: UsageRangeArgs & { kind: BreakdownKind }) => + usage.getBreakdown(args.scope, args.range, args.kind) + ) + ipcMain.handle( + `${prefix}:getRecentSessions`, + (_event, args: UsageRangeArgs & { limit?: number }) => + usage.getRecentSessions(args.scope, args.range, args.limit) + ) +} + +export function registerUsageProviderHandlers(stores: UsageProviderStores): void { + registerProviderHandlers('claudeUsage', stores.claudeUsage) + registerProviderHandlers('codexUsage', stores.codexUsage) + registerProviderHandlers('openCodeUsage', stores.openCodeUsage) +} diff --git a/src/main/ipc/workspace-ports.test.ts b/src/main/ipc/workspace-ports.test.ts index f5afe03628b..9c847b14ad8 100644 --- a/src/main/ipc/workspace-ports.test.ts +++ b/src/main/ipc/workspace-ports.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspacePort, WorkspacePortScanResult } from '../../shared/workspace-ports' -const handlers = new Map Promise | unknown>() +const handlers = new Map unknown>() const { handleMock, removeHandlerMock, scanWorkspacePortsMock, processKillMock } = vi.hoisted( () => ({ handleMock: vi.fn(), diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index 09cda0ed480..101d48e22cb 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, writeFileSync } from 'node:fs' import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createWorktreePollerWindowVisibility, startWorktreeBaseDirectoryPoller, + WORKTREE_BASE_BACKSTOP_TICKS, type WorktreeBasePollEvent, type WorktreePollerWindowVisibility } from './worktree-base-directory-poller' @@ -204,6 +206,103 @@ describe('worktree base directory poller', () => { expect(fullScans.length).toBeGreaterThan(0) }) + it('retires stale marker probes while preserving backstop detection and path reuse', async () => { + const root = await makeRoot() + const ordinaryFolder = join(root, 'ordinary-folder') + const markerPath = join(ordinaryFolder, '.git') + await mkdir(ordinaryFolder) + + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + let fullScans = 0 + let pendingMarkerProbes = 0 + let writeMarkerOnNextProbe = false + const pendingMarkerMaxTicks = WORKTREE_BASE_BACKSTOP_TICKS * 2 + const markerCreationTick = pendingMarkerMaxTicks * 2 + WORKTREE_BASE_BACKSTOP_TICKS * 4 + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { + pollIntervalMs: 0, + pendingMarkerMaxTicks, + onFullScan: () => { + fullScans += 1 + if (fullScans * WORKTREE_BASE_BACKSTOP_TICKS === markerCreationTick) { + writeFileSync(markerPath, 'gitdir: elsewhere') + } + }, + onPendingMarkerProbe: (path) => { + pendingMarkerProbes += 1 + if (writeMarkerOnNextProbe && path === markerPath) { + writeMarkerOnNextProbe = false + writeFileSync(markerPath, 'gitdir: elsewhere') + } + } + } + ) + cleanups.push(() => poller.unsubscribe()) + + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === markerPath) + ) + + await rm(ordinaryFolder, { recursive: true }) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'delete' && event.path === ordinaryFolder) + ) + writeMarkerOnNextProbe = true + await mkdir(ordinaryFolder) + const events = await waitForEvents( + received, + (flat) => + flat.filter((event) => event.type === 'create' && event.path === markerPath).length === 2 + ) + + expect(pendingMarkerProbes).toBeLessThanOrEqual(pendingMarkerMaxTicks) + expect( + events.filter((event) => event.type === 'create' && event.path === markerPath) + ).toHaveLength(2) + }) + + it('rescans on the next tick when a write races an in-flight full scan', async () => { + const root = await makeRoot() + const worktree = join(root, 'raced') + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + const snapshotTicks: number[] = [] + let raced = false + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { + pollIntervalMs: 0, + onSnapshotTaken: (tick) => { + snapshotTicks.push(tick) + if (raced) { + return + } + raced = true + mkdirSync(worktree) + writeFileSync(join(worktree, '.git'), 'gitdir: elsewhere') + } + } + ) + cleanups.push(() => poller.unsubscribe()) + + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git')) + ) + + // A write landing after a scan's listings must leave the gate stale, so the + // next tick rescans instead of deferring the create to the backstop. + expect(snapshotTicks.slice(0, 2)).toEqual([ + WORKTREE_BASE_BACKSTOP_TICKS, + WORKTREE_BASE_BACKSTOP_TICKS + 1 + ]) + }) + it('parks base scans while hidden and losslessly detects changes on resume', async () => { const root = await makeRoot() const visibility = createVisibilityHarness() diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 2f65e0fc282..42ee184b811 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -63,6 +63,12 @@ export type WorktreeBasePollerOptions = { onWatchError?: (error: Error) => void /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void + /** Test hook: called before a pending `.git` marker stat. */ + onPendingMarkerProbe?: (path: string) => void + /** Test hook: awaited with the tick after a full scan's listings, to land a racing write. */ + onSnapshotTaken?: (tick: number) => void | Promise + /** Test hook: overrides the fast-probe window. */ + pendingMarkerMaxTicks?: number } // Why: these targets used to be recursive FSEvents subscriptions spanning the @@ -113,6 +119,8 @@ type BaseSnapshot = { // dirs whose listing determines the candidate set: the root plus any // nested repo containers. Their stat signatures gate the next full scan. gateDirs: string[] + // index-aligned with gateDirs, each sampled *before* that dir's listing + gateSignatures: string[] } // Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested @@ -124,6 +132,10 @@ async function snapshotBase( ): Promise { const markers = new Map() const gateDirs = [rootPath] + // Why: sampling the signature before the listing makes a write that races the + // scan look stale next tick (one redundant rescan) instead of invisible until + // the backstop, which is up to 15 ticks of missed creates/deletes. + const gateSignatures = [await dirSignature(rootPath)] const configs = [...repos.values()] const includeFlat = configs.some((config) => !config.nestWorkspaces) const nestedRepoNames = new Set( @@ -138,7 +150,7 @@ async function snapshotBase( } catch { // Root vanished: an empty snapshot diffs into delete events for every // previously-known worktree dir, matching the old watcher's error path. - return { markers, gateDirs } + return { markers, gateDirs, gateSignatures } } const candidates: string[] = [] @@ -152,6 +164,7 @@ async function snapshotBase( } if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { gateDirs.push(entryPath) + gateSignatures.push(await dirSignature(entryPath)) let subEntries try { subEntries = await readdir(entryPath, { withFileTypes: true }) @@ -169,7 +182,7 @@ async function snapshotBase( for (const dir of candidates) { markers.set(dir, await hasGitMarker(dir)) } - return { markers, gateDirs } + return { markers, gateDirs, gateSignatures } } function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { @@ -193,45 +206,44 @@ async function startBasePoller( onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, - onFullScan?: () => void + options: WorktreeBasePollerOptions ): Promise { let disposed = false let ticking = false let tickCount = 0 let snapshot = await snapshotBase(target.path, getRepos()) - let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) let timer: ReturnType | null = null let parkedWhileHidden = false - // dir → tick when first seen without a `.git` marker - const pendingMarkers = new Map() + const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS + // dir → first probe tick; null means backstop scans only + const markerProbeStartedAt = new Map() for (const [dir, marker] of snapshot.markers) { if (!marker) { - pendingMarkers.set(dir, 0) + markerProbeStartedAt.set(dir, 0) } } const fullScan = async (): Promise => { - onFullScan?.() + options.onFullScan?.() const next = await snapshotBase(target.path, getRepos()) - const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature)) + await options.onSnapshotTaken?.(tickCount) if (disposed) { return } const events = diffBase(snapshot, next) for (const [dir, marker] of next.markers) { if (marker) { - pendingMarkers.delete(dir) - } else if (!pendingMarkers.has(dir)) { - pendingMarkers.set(dir, tickCount) + markerProbeStartedAt.delete(dir) + } else if (!markerProbeStartedAt.has(dir)) { + markerProbeStartedAt.set(dir, tickCount) } } - for (const [dir, firstSeenTick] of pendingMarkers) { - if (!next.markers.has(dir) || tickCount - firstSeenTick > PENDING_MARKER_MAX_TICKS) { - pendingMarkers.delete(dir) + for (const dir of markerProbeStartedAt.keys()) { + if (!next.markers.has(dir)) { + markerProbeStartedAt.delete(dir) } } snapshot = next - gateSignatures = nextSignatures if (events.length > 0) { onEvents(events) } @@ -239,9 +251,17 @@ async function startBasePoller( const checkPendingMarkers = async (): Promise => { const events: WorktreeBasePollEvent[] = [] - for (const dir of pendingMarkers.keys()) { + for (const [dir, firstSeenTick] of markerProbeStartedAt) { + if (firstSeenTick === null) { + continue + } + if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { + markerProbeStartedAt.set(dir, null) + continue + } + options.onPendingMarkerProbe?.(join(dir, '.git')) if (await hasGitMarker(dir)) { - pendingMarkers.delete(dir) + markerProbeStartedAt.delete(dir) snapshot.markers.set(dir, true) events.push({ type: 'create', path: join(dir, '.git') }) } @@ -261,13 +281,13 @@ async function startBasePoller( // are untouched, skip the readdir + per-candidate stat fan-out entirely. const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) const gateChanged = - signatures.length !== gateSignatures.length || - signatures.some((sig, index) => sig !== gateSignatures[index]) + signatures.length !== snapshot.gateSignatures.length || + signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) if (gateChanged) { await fullScan() return } - if (pendingMarkers.size > 0) { + if (markerProbeStartedAt.size > 0) { await checkPendingMarkers() } } @@ -356,5 +376,5 @@ export async function startWorktreeBaseDirectoryPoller( options.onWatchError ) } - return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan) + return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options) } diff --git a/src/main/ipc/worktree-common-git-directory.ts b/src/main/ipc/worktree-common-git-directory.ts index a2ec6b0e990..7fb464f3c94 100644 --- a/src/main/ipc/worktree-common-git-directory.ts +++ b/src/main/ipc/worktree-common-git-directory.ts @@ -26,7 +26,7 @@ function isFileStat(value: GitDirectoryStat): boolean { function runtimeDirname(pathValue: string): string { const normalized = normalizeRuntimePathSeparators(pathValue).replace(/\/+$/, '') const index = normalized.lastIndexOf('/') - if (index < 0) { + if (index === -1) { return '.' } if (index === 0) { diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index dae122943a3..86cc06e1420 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -74,7 +74,7 @@ async function snapshotGitCommon( previous?: GitCommonSnapshot, includePrimary = true, forceFullScan = false, - statusRefPaths: Set = new Set() + statusRefPaths = new Set() ): Promise { const worktreesDir = join(commonDirPath, 'worktrees') const [worktreesDirSignature, primarySignatures, statusRefSignatures] = await Promise.all([ diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index 8c4aa0efead..6b7542d18d4 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -2,6 +2,7 @@ import { basename } from 'node:path' import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' +import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' /** * Merge raw git worktree info with persisted user metadata into a full Worktree. @@ -13,6 +14,7 @@ export function mergeWorktree( defaultDisplayName?: string ): Worktree { const branchShort = git.branch.replace(/^refs\/heads\//, '') + const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta?.creatorProvenance) return { id: `${repoId}::${git.path}`, ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), @@ -22,6 +24,7 @@ export function mergeWorktree( ...(meta?.projectHostSetupId !== undefined ? { projectHostSetupId: meta.projectHostSetupId } : {}), + ...(creatorProvenance ? { creatorProvenance } : {}), path: git.path, head: git.head, branch: git.branch, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index b09b4e71c93..f83c5035976 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1784,6 +1784,7 @@ export async function createRemoteWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'ssh', + creatorProvenance: { kind: 'host' }, orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), @@ -2392,6 +2393,7 @@ export async function createLocalWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', + creatorProvenance: { kind: 'host' }, orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 84e9e1fbaae..02f23bc094a 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -603,6 +603,18 @@ describe('registerWorktreeHandlers', () => { expect(orderedTargets).not.toContain(staleId) }) + it('persistSortOrder skips ranks that already represent the requested order', () => { + const firstId = 'repo-1::/workspace/first' + const secondId = 'repo-1::/workspace/second' + store.getWorktreeMeta.mockImplementation((id: string) => ({ + sortOrder: id === firstId ? 200 : 100 + })) + + handlers['worktrees:persistSortOrder'](null, { orderedIds: [firstId, secondId] }) + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + }) + it('prefetches the local default create base through the runtime refresh cache', async () => { const repo = { id: 'repo-1', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 87bd0ded95c..ab299057169 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -8,6 +8,7 @@ import { isFolderRepo } from '../../shared/repo-kind' import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' +import { planWorktreeSortOrderUpdates } from '../../shared/worktree-sort-order-update' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { TaskSourceContextSchema } from '../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema' @@ -141,6 +142,7 @@ import { registerSshProviderRequestAbort } from '../ssh/ssh-provider-authority' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' +import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -521,15 +523,18 @@ type WorktreeRemovalInFlight = { } type PreservedBranchCleanupTarget = { + worktreeId: string + hostId: ExecutionHostId branchName: string head: string pushTarget?: GitPushTarget } -const preservedBranchCleanupByWorktreeId = new Map() +const preservedBranchCleanupByScope = new Map() function rememberPreservedBranchCleanupTarget( worktreeId: string, + hostId: ExecutionHostId, result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined, pushTarget: GitPushTarget | undefined @@ -541,14 +546,16 @@ function rememberPreservedBranchCleanupTarget( `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` ) } - preservedBranchCleanupByWorktreeId.set(worktreeId, { + preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { + worktreeId, + hostId, branchName: result.preservedBranch.branchName, head, ...(pushTarget ? { pushTarget } : {}) }) return } - preservedBranchCleanupByWorktreeId.delete(worktreeId) + preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) } function preserveBranchHeadFallback( @@ -570,9 +577,21 @@ function preserveBranchHeadFallback( function getPreservedBranchCleanupTarget( worktreeId: string, branchName: string, - expectedHead: string + expectedHead: string, + hostId?: ExecutionHostId ): PreservedBranchCleanupTarget { - const target = preservedBranchCleanupByWorktreeId.get(worktreeId) + const exactTarget = hostId + ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) + : undefined + const legacyMatches = hostId + ? [] + : [...preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === worktreeId && + target.branchName === branchName && + target.head === expectedHead + ) + const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) if (!target || target.branchName !== branchName || target.head !== expectedHead) { throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) } @@ -1026,6 +1045,7 @@ function createFolderWorkspace( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', + creatorProvenance: { kind: 'host' }, ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), @@ -2399,7 +2419,9 @@ export function registerWorktreeHandlers( await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) }) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2508,7 +2530,12 @@ export function registerWorktreeHandlers( } runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2553,7 +2580,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return {} @@ -2585,7 +2617,12 @@ export function registerWorktreeHandlers( } runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2633,6 +2670,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -2730,6 +2768,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -2883,7 +2922,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) removalCompleted = true @@ -2907,6 +2951,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, refreshedRegisteredWorktree.head, removedPushTarget @@ -3010,7 +3055,17 @@ export function registerWorktreeHandlers( removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id) // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. invalidateAuthorizedRootsCache() - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + if (ownerHost?.id) { + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) + ) + } else { + for (const [key, target] of preservedBranchCleanupByScope) { + if (target.worktreeId === args.worktreeId) { + preservedBranchCleanupByScope.delete(key) + } + } + } notifyWorktreesChanged(mainWindow, repoId) return {} })() @@ -3029,15 +3084,21 @@ export function registerWorktreeHandlers( 'worktrees:forceDeletePreservedBranch', async ( _event, - args: { worktreeId: string; branchName: string; expectedHead: string } + args: { + worktreeId: string + branchName: string + expectedHead: string + hostId?: ExecutionHostId + } ): Promise => { const { repoId } = parseWorktreeId(args.worktreeId) const cleanupTarget = getPreservedBranchCleanupTarget( args.worktreeId, args.branchName, - args.expectedHead + args.expectedHead, + args.hostId ) - const repo = store.getRepo(repoId) + const repo = getRepoForWorktreeRemoval(store, repoId, cleanupTarget.hostId) if (!repo) { throw new Error(`Repo not found: ${repoId}`) } @@ -3080,7 +3141,12 @@ export function registerWorktreeHandlers( ) } - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: cleanupTarget.hostId + }) + ) return { deleted: true } } ) @@ -3142,18 +3208,13 @@ export function registerWorktreeHandlers( if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) { return } - const now = Date.now() - for (let i = 0; i < args.orderedIds.length; i++) { - // Why: a sidebar-order snapshot must only reorder worktrees that already - // exist — it must never create one. Without this guard a stale id the - // renderer still lists (e.g. a removed repo's `${repoId}::${path}`) gets a - // fresh worktreeMeta entry minted here, resurrecting an orphan/duplicate - // workspace on the next launch. setWorktreeMeta has no repo-existence check. - if (!store.getWorktreeMeta(args.orderedIds[i])) { - continue - } - // Descending timestamps: first item gets highest sortOrder so b - a sorts first-wins on cold start. - store.setWorktreeMeta(args.orderedIds[i], { sortOrder: now - i * 1000 }) + const updates = planWorktreeSortOrderUpdates( + args.orderedIds, + (worktreeId) => store.getWorktreeMeta(worktreeId), + Date.now() + ) + for (const update of updates) { + store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) } }) diff --git a/src/main/jira/attachment-images.ts b/src/main/jira/attachment-images.ts index ce2e682b872..d1cfb2ab079 100644 --- a/src/main/jira/attachment-images.ts +++ b/src/main/jira/attachment-images.ts @@ -191,7 +191,7 @@ export function createMediaMarkdownResolver( } const index = queue.findIndex((entry) => entry.id === image.id) // Why: already-consumed images must not re-emit; fall through to positional pairing. - if (index < 0) { + if (index === -1) { return null } queue.splice(index, 1) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index 70f45628461..cd3497fb2be 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -61,7 +61,7 @@ export function acquire(signal?: AbortSignal): Promise { signal, onAbort: () => { const index = queue.indexOf(entry) - if (index < 0) { + if (index === -1) { return } queue.splice(index, 1) diff --git a/src/main/keybindings/keybinding-file.ts b/src/main/keybindings/keybinding-file.ts index 4d41a42ba87..7a357010406 100644 --- a/src/main/keybindings/keybinding-file.ts +++ b/src/main/keybindings/keybinding-file.ts @@ -221,7 +221,7 @@ function removeConflictingOverrides( for (const conflict of conflicts) { for (const actionId of conflict.actionIds) { - if (Object.prototype.hasOwnProperty.call(next, actionId)) { + if (Object.hasOwn(next, actionId)) { conflictingOverrides.add(actionId) } } @@ -346,8 +346,8 @@ export function seedLegacyTabSwitchBindings( // common + active-platform sections directly so the seed never replaces it. const toSeed = actionIds.filter( (actionId) => - !Object.prototype.hasOwnProperty.call(current.commonOverrides, actionId) && - !Object.prototype.hasOwnProperty.call(activePlatformOverrides, actionId) + !Object.hasOwn(current.commonOverrides, actionId) && + !Object.hasOwn(activePlatformOverrides, actionId) ) if (toSeed.length === 0) { return { seeded: false, snapshot: current } diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 94f5d11ea3e..a7b954f01cb 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -18,6 +18,7 @@ import { wrapPosixHookCommand, writeManagedScript } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readTextFileRemote, writeManagedScriptRemote, @@ -57,10 +58,12 @@ function getManagedCommand(scriptPath: string): string { return wrapPosixHookCommand(posixPath) } -function getManagedScript(): string { - return [ - '#!/bin/sh', - ...buildPosixHookPayloadCapture(), +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + // Why (#11549 class): on Windows this .sh runs under Git Bash but the caller is a + // Windows process that can abandon the pipe, so the missing-env guard must run before + // the capture owns stdin. POSIX callers close stdin (#8110), so posix keeps capture-first. + const windowsLocal = target === 'local' && process.platform === 'win32' + const endpointRefreshAndGuard = [ // Why: refresh PORT/TOKEN/ENV/VERSION from the current Orca install so a PTY // that survived an Orca restart still reaches the live listener. See // claude/hook-service.ts for the full rationale. @@ -69,7 +72,13 @@ function getManagedScript(): string { 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', - 'fi', + 'fi' + ] + return [ + '#!/bin/sh', + ...(windowsLocal + ? [...endpointRefreshAndGuard, ...buildPosixHookPayloadCapture()] + : [...buildPosixHookPayloadCapture(), ...endpointRefreshAndGuard]), // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -155,6 +164,10 @@ function buildStatus(present: Set, configPath: string): AgentHookInstall } export class KimiHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const text = readConfigToml(configPath) @@ -207,7 +220,7 @@ export class KimiHookService { const text = (await readTextFileRemote(sftp, remoteConfigPath)) ?? '' const command = wrapPosixHookCommand(remoteScriptPath) // Write the script first so config.toml never points at a missing script. - await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript()) + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeTextFileRemoteAtomic(sftp, remoteConfigPath, applyManagedKimiHooks(text, command)) return { agent: 'kimi', diff --git a/src/main/line-editor-ready-output-scanner.test.ts b/src/main/line-editor-ready-output-scanner.test.ts new file mode 100644 index 00000000000..332f1d65f04 --- /dev/null +++ b/src/main/line-editor-ready-output-scanner.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { + createLineEditorReadyOutputScanState, + scanForLineEditorReadyOutput +} from './line-editor-ready-output-scanner' + +describe('line editor ready output scanner', () => { + it.each(['\x1b[?2004h', '\x1b[?1034h'])('detects %j across chunks', (sequence) => { + const state = createLineEditorReadyOutputScanState() + expect(scanForLineEditorReadyOutput(state, `prompt${sequence.slice(0, 4)}`)).toBe(false) + expect(scanForLineEditorReadyOutput(state, sequence.slice(4))).toBe(true) + }) + + it('ignores ordinary prompt output', () => { + expect( + scanForLineEditorReadyOutput(createLineEditorReadyOutputScanState(), 'user@host % ') + ).toBe(false) + }) +}) diff --git a/src/main/line-editor-ready-output-scanner.ts b/src/main/line-editor-ready-output-scanner.ts new file mode 100644 index 00000000000..03467887a9a --- /dev/null +++ b/src/main/line-editor-ready-output-scanner.ts @@ -0,0 +1,19 @@ +const LINE_EDITOR_READY_SEQUENCES = ['\x1b[?2004h', '\x1b[?1034h'] as const +const MAX_SEQUENCE_LENGTH = Math.max(...LINE_EDITOR_READY_SEQUENCES.map((value) => value.length)) + +export type LineEditorReadyOutputScanState = { + tail: string +} + +export function createLineEditorReadyOutputScanState(): LineEditorReadyOutputScanState { + return { tail: '' } +} + +export function scanForLineEditorReadyOutput( + state: LineEditorReadyOutputScanState, + data: string +): boolean { + const combined = state.tail + data + state.tail = combined.slice(-(MAX_SEQUENCE_LENGTH - 1)) + return LINE_EDITOR_READY_SEQUENCES.some((sequence) => combined.includes(sequence)) +} diff --git a/src/main/linear/issue-context-client.ts b/src/main/linear/issue-context-client.ts index 1f4863d235a..c9e5085f3bb 100644 --- a/src/main/linear/issue-context-client.ts +++ b/src/main/linear/issue-context-client.ts @@ -43,7 +43,7 @@ export type ResolvedIssue = { export async function searchLinearIssuesForAgents(args: { query: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): Promise { const limit = clampLinearSearchLimit(args.limit) const workspaceId = resolveSearchWorkspaceId(args.workspaceId) @@ -144,14 +144,18 @@ function getExplicitClientEntries(workspaceId?: string): { } } -function resolveSearchWorkspaceId(workspaceId?: string | 'all'): string | 'all' | undefined { +function resolveSearchWorkspaceId( + workspaceId?: (string & {}) | 'all' +): (string & {}) | 'all' | undefined { if (!workspaceId || workspaceId === 'all') { return workspaceId } return resolveWorkspaceSelector({ workspaceId }, getConnectedWorkspaces())?.id ?? workspaceId } -function throwIfExplicitWorkspaceHasConnectedAlternatives(workspaceId?: string | 'all'): void { +function throwIfExplicitWorkspaceHasConnectedAlternatives( + workspaceId?: (string & {}) | 'all' +): void { if (!workspaceId || workspaceId === 'all') { return } @@ -169,7 +173,7 @@ function throwIfExplicitWorkspaceHasConnectedAlternatives(workspaceId?: string | export async function withLinearRead( entry: LinearClientForWorkspace, read: () => Promise, - selection?: string | 'all' + selection?: (string & {}) | 'all' ): Promise { void selection await acquire() @@ -206,7 +210,7 @@ async function readIssueWorkspace( async function readIssueWorkspaces( entries: LinearClientForWorkspace[], identifier: string, - selection: string | 'all', + selection: (string & {}) | 'all', initialFailures: WorkspaceReadFailure[] = [] ): Promise { if (selection !== 'all') { @@ -243,7 +247,7 @@ async function readSearchWorkspace( entry: LinearClientForWorkspace, query: string, limit: number, - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' ): Promise { const response = await withLinearRead( entry, @@ -269,7 +273,7 @@ async function readSearchWorkspaces( entries: LinearClientForWorkspace[], query: string, limit: number, - workspaceId?: string | 'all', + workspaceId?: (string & {}) | 'all', initialFailures: WorkspaceReadFailure[] = [] ): Promise<{ results: LinearSearchIssueSummary[][]; failures: WorkspaceReadFailure[] }> { if (workspaceId && workspaceId !== 'all') { diff --git a/src/main/linear/mcp-issue-list.ts b/src/main/linear/mcp-issue-list.ts index 3906442879e..09c5fff3336 100644 --- a/src/main/linear/mcp-issue-list.ts +++ b/src/main/linear/mcp-issue-list.ts @@ -106,7 +106,7 @@ export async function listMcpIssues( } } -function getIssueListEntries(workspaceId?: string | 'all'): { +function getIssueListEntries(workspaceId?: (string & {}) | 'all'): { entries: LinearClientForWorkspace[] failures: WorkspaceReadFailure[] } { diff --git a/src/main/linear/teams.test.ts b/src/main/linear/teams.test.ts index d04845e6f28..00d68988bab 100644 --- a/src/main/linear/teams.test.ts +++ b/src/main/linear/teams.test.ts @@ -5,10 +5,12 @@ import { credentialDecryptionMessage } from '../../shared/integration-credential const getClients = vi.fn() const clearToken = vi.fn() const isAuthError = vi.fn() +const acquire = vi.fn().mockResolvedValue(undefined) +const release = vi.fn() vi.mock('./client', () => ({ - acquire: vi.fn().mockResolvedValue(undefined), - release: vi.fn(), + acquire, + release, getClients: (...args: unknown[]) => getClients(...args), isAuthError: (...args: unknown[]) => isAuthError(...args), clearToken: (...args: unknown[]) => clearToken(...args) @@ -283,6 +285,30 @@ describe('Linear teams', () => { expect(members).toHaveBeenCalledWith({ first: 100 }) }) + it.each([ + ['getTeamStates', 'getTeamStatesOrThrow'], + ['getTeamLabels', 'getTeamLabelsOrThrow'], + ['getTeamMembers', 'getTeamMembersOrThrow'] + ] as const)('preserves the %s error policy', async (fallbackReader, throwingReader) => { + const error = new Error('request failed') + const entry = makeTeamLookupEntry('workspace-1', 'Workspace', null) + vi.mocked(entry.client.team).mockRejectedValue(error) + getClients.mockReturnValue([entry]) + const readers = await import('./teams') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(readers[fallbackReader]('team-1', 'workspace-1')).resolves.toEqual([]) + await expect(readers[throwingReader]('team-1', 'workspace-1')).rejects.toBe(error) + expect(warn).toHaveBeenCalledWith(`[linear] ${fallbackReader} failed:`, error) + expect(release).toHaveBeenCalledTimes(2) + + isAuthError.mockReturnValue(true) + await expect(readers[fallbackReader]('team-1', 'workspace-1')).rejects.toBe(error) + expect(clearToken).toHaveBeenCalledWith('workspace-1') + expect(release).toHaveBeenCalledTimes(3) + warn.mockRestore() + }) + it('surfaces Linear credential decrypt errors on active team reads', async () => { const error = new Error(credentialDecryptionMessage('Linear')) getClients.mockImplementation(() => { diff --git a/src/main/linear/teams.ts b/src/main/linear/teams.ts index e06697e8d46..004c8fbd92a 100644 --- a/src/main/linear/teams.ts +++ b/src/main/linear/teams.ts @@ -1,3 +1,4 @@ +import type { Team } from '@linear/sdk' import type { LinearTeam, LinearWorkflowState, @@ -108,10 +109,12 @@ export async function listTeamsForAgent( } } -export async function getTeamStates( +async function readTeamResource( teamId: string, - workspaceId?: string | null -): Promise { + workspaceId: string | null | undefined, + fetchAll: (team: Team) => Promise, + fallbackWarning?: string +): Promise { const entry = getClients(workspaceId)[0] if (!entry) { return [] @@ -119,137 +122,62 @@ export async function getTeamStates( await acquire() try { - const team = await entry.client.team(teamId) - return await fetchAllTeamStates(team) + return await fetchAll(await entry.client.team(teamId)) } catch (error) { if (isAuthError(error)) { clearToken(entry.workspace.id) throw error } - console.warn('[linear] getTeamStates failed:', error) + if (!fallbackWarning) { + throw error + } + console.warn(`[linear] ${fallbackWarning} failed:`, error) return [] } finally { release() } } +export async function getTeamStates( + teamId: string, + workspaceId?: string | null +): Promise { + return readTeamResource(teamId, workspaceId, fetchAllTeamStates, 'getTeamStates') +} + export async function getTeamStatesOrThrow( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamStates(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamStates) } export async function getTeamLabels( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamLabels(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - throw error - } - console.warn('[linear] getTeamLabels failed:', error) - return [] - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamLabels, 'getTeamLabels') } export async function getTeamLabelsOrThrow( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamLabels(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamLabels) } export async function getTeamMembers( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamMembers(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - throw error - } - console.warn('[linear] getTeamMembers failed:', error) - return [] - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamMembers, 'getTeamMembers') } export async function getTeamMembersOrThrow( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamMembers(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamMembers) } export async function getViewerForWorkspaceOrThrow( diff --git a/src/main/memory/windows-process-resource-collector.ts b/src/main/memory/windows-process-resource-collector.ts index d73ae95b8c9..527c4678fc9 100644 --- a/src/main/memory/windows-process-resource-collector.ts +++ b/src/main/memory/windows-process-resource-collector.ts @@ -286,7 +286,7 @@ export function parseTypeperfProcessOutput(stdout: string): WindowsProcessResour function parseTypeperfCounterPath(path: string): { instance: string; counter: string } | null { const processStart = path.lastIndexOf('\\Process(') const counterStart = path.lastIndexOf(')\\') - if (processStart < 0 || counterStart <= processStart + 9) { + if (processStart === -1 || counterStart <= processStart + 9) { return null } return { diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 82d1bc184de..08b5a489a9c 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -259,6 +259,26 @@ describe('registerAppMenu', () => { } ) + it.each(['darwin', 'linux', 'win32'] as const)( + 'preserves terminal undo and redo chords on %s', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + registerAppMenu(buildMenuOptions()) + + const editSubmenu = getSubmenu(getTemplate(), 'Edit') + const expectedRegistration = platform === 'darwin' ? undefined : false + const undoItem = editSubmenu.find((item) => item.role === 'undo') + const redoItem = editSubmenu.find((item) => item.role === 'redo') + + expect(undoItem?.accelerator).toBeUndefined() + expect(redoItem?.accelerator).toBeUndefined() + expect(undoItem && 'registerAccelerator' in undoItem).toBe(platform !== 'darwin') + expect(redoItem && 'registerAccelerator' in redoItem).toBe(platform !== 'darwin') + expect(undoItem?.registerAccelerator).toBe(expectedRegistration) + expect(redoItem?.registerAccelerator).toBe(expectedRegistration) + } + ) + it('keeps selection actions native in a focused guest webview', () => { const send = vi.fn() const guestContents = { copy: vi.fn(), selectAll: vi.fn() } diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 2feeef9fe47..cc99c69cbc7 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -167,11 +167,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { ] } + // Why: keep native menu hints while letting non-macOS Ctrl+Z/Ctrl+Y reach the focused terminal or DOM control. + const undoRedoOptions: Electron.MenuItemConstructorOptions = isMac + ? {} + : { registerAccelerator: false } const editMenu: Electron.MenuItemConstructorOptions = { label: translateMain('menu.edit', 'Edit'), submenu: [ - { role: 'undo' }, - { role: 'redo' }, + { role: 'undo', ...undoRedoOptions }, + { role: 'redo', ...undoRedoOptions }, { type: 'separator' }, { role: 'cut' }, createAppMenuSelectionItem({ diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts index 3864f892bfb..1b98a1a81c6 100644 --- a/src/main/minimax/minimax-cookie-store.ts +++ b/src/main/minimax/minimax-cookie-store.ts @@ -33,7 +33,7 @@ function decodeCookieEnvelope(raw: Buffer): MiniMaxCookieEnvelope | null { } const rest = text.slice(COOKIE_ENVELOPE_PREFIX.length) const separator = rest.indexOf(':') - if (separator < 0) { + if (separator === -1) { throw new Error('MiniMax session cookie could not be decrypted') } const kind = rest.slice(0, separator) diff --git a/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts b/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts new file mode 100644 index 00000000000..5043bd14f6c --- /dev/null +++ b/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' +const firstGuestPath = '/home/ada/.codex/sessions/first.jsonl' +const secondGuestPath = '/home/ada/.codex/sessions/second.jsonl' +const firstUncPath = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\first.jsonl' +const secondUncPath = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\second.jsonl' + +const fsMocks = vi.hoisted(() => ({ + access: vi.fn<(path: string) => Promise>() +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + access: fsMocks.access +})) + +import { + resetHostReadableTranscriptPathCacheForTests, + toHostReadableTranscriptPath +} from './host-readable-transcript-path' +import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate' + +type AccessControl = { + resolve: () => void + reject: (error: Error) => void +} + +function wslDeps(): { + platform: NodeJS.Platform + listWslHomeDirs: () => Promise +} { + return { platform: 'win32', listWslHomeDirs: async () => [UBUNTU_HOME] } +} + +beforeEach(() => { + resetHostReadableTranscriptPathCacheForTests() + fsMocks.access.mockReset() +}) + +describe('WSL transcript filesystem gate', () => { + it('serializes distinct probes and releases the permit after failure', async () => { + const controls: AccessControl[] = [] + fsMocks.access.mockImplementation( + () => + new Promise((resolve, reject) => { + controls.push({ resolve: () => resolve(), reject }) + }) + ) + + const first = toHostReadableTranscriptPath(firstGuestPath, wslDeps()) + const second = toHostReadableTranscriptPath(secondGuestPath, wslDeps()) + + await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledTimes(1)) + controls[0].reject(new Error('stopped distro')) + await expect(first).resolves.toBeNull() + await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledTimes(2)) + controls[1].resolve() + await expect(second).resolves.toBe(secondUncPath) + }) + + it('deduplicates identical in-flight probes', async () => { + let release: (() => void) | undefined + fsMocks.access.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + + const first = toHostReadableTranscriptPath(firstGuestPath, wslDeps()) + const duplicate = toHostReadableTranscriptPath(firstGuestPath, wslDeps()) + + await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledTimes(1)) + release?.() + await expect(Promise.all([first, duplicate])).resolves.toEqual([firstUncPath, firstUncPath]) + }) + + it('does not queue local filesystem work behind a stalled WSL probe', async () => { + let releaseWsl: (() => void) | undefined + fsMocks.access.mockImplementation((path) => + path === firstUncPath + ? new Promise((resolve) => { + releaseWsl = resolve + }) + : Promise.resolve() + ) + + const wslProbe = toHostReadableTranscriptPath(firstGuestPath, wslDeps()) + await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledWith(firstUncPath)) + + const localPath = import.meta.filename + await expect( + toHostReadableTranscriptPath(localPath, { platform: process.platform }) + ).resolves.toBe(localPath) + expect(fsMocks.access).toHaveBeenCalledTimes(1) + releaseWsl?.() + await expect(wslProbe).resolves.toBe(firstUncPath) + }) + + // Last on purpose: the never-settling Ubuntu probe leaves its gate permit + // stuck for the rest of this module's lifetime. + it('falls through to the next distro when a probe exceeds the gate deadline', async () => { + vi.useFakeTimers() + const DEBIAN_HOME = '\\\\wsl.localhost\\Debian\\home\\ada' + const debianUncPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\first.jsonl' + try { + fsMocks.access.mockImplementation((path) => + path === firstUncPath ? new Promise(() => {}) : Promise.resolve() + ) + const resolved = toHostReadableTranscriptPath(firstGuestPath, { + platform: 'win32', + listWslHomeDirs: async () => [UBUNTU_HOME, DEBIAN_HOME] + }) + + await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledWith(firstUncPath)) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await expect(resolved).resolves.toBe(debianUncPath) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/native-chat/host-readable-transcript-path.test.ts b/src/main/native-chat/host-readable-transcript-path.test.ts index 97513c78838..4d19b3fa8e8 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -7,6 +7,7 @@ import { toHostReadableTranscriptPath, wslCodexSessionsDirs } from './host-readable-transcript-path' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' const DEBIAN_HOME = '\\\\wsl.localhost\\Debian\\home\\other' @@ -46,7 +47,7 @@ describe('toHostReadableTranscriptPath', () => { await expect( toHostReadableTranscriptPath(ROLLOUT_LINUX, { platform: 'win32', - pathExists: (candidate) => candidate === ROLLOUT_UNC, + pathExists: async (candidate) => candidate === ROLLOUT_UNC, listWslHomeDirs: async () => [UBUNTU_HOME] }) ).resolves.toBe(ROLLOUT_UNC) @@ -59,7 +60,7 @@ describe('toHostReadableTranscriptPath', () => { await expect( toHostReadableTranscriptPath('/home/ada/x.jsonl', { platform: 'win32', - pathExists: (candidate) => { + pathExists: async (candidate) => { seen.push(candidate) return true }, @@ -75,7 +76,7 @@ describe('toHostReadableTranscriptPath', () => { await expect( toHostReadableTranscriptPath(path, { platform: 'win32', - pathExists: (candidate) => candidate === path, + pathExists: async (candidate) => candidate === path, listWslHomeDirs: async () => { throw new Error('should not enumerate distros') } @@ -89,7 +90,7 @@ describe('toHostReadableTranscriptPath', () => { await expect( toHostReadableTranscriptPath('/home/ada/.codex/sessions/rollout.jsonl', { platform: 'win32', - pathExists: (candidate) => { + pathExists: async (candidate) => { seen.push(candidate) return true }, @@ -103,17 +104,49 @@ describe('toHostReadableTranscriptPath', () => { await expect( toHostReadableTranscriptPath(ROLLOUT_LINUX, { platform: 'win32', - pathExists: () => false, + pathExists: async () => false, listWslHomeDirs: async () => [UBUNTU_HOME] }) ).resolves.toBeNull() }) + it('prefers a later distro hit over an earlier gate refusal', async () => { + // Guest path under Debian's $HOME so the refusing Debian probe ranks first. + await expect( + toHostReadableTranscriptPath('/home/other/x.jsonl', { + platform: 'win32', + pathExists: async (candidate) => { + if (candidate.includes('Debian')) { + throw new WslTranscriptFsError('timeout', 'slow share') + } + return candidate.includes('Ubuntu') + }, + listWslHomeDirs: async () => [DEBIAN_HOME, UBUNTU_HOME] + }) + ).resolves.toBe('\\\\wsl.localhost\\Ubuntu\\home\\other\\x.jsonl') + }) + + it('reports unavailability, not a miss, when a refused distro was never probed', async () => { + const refusal = new WslTranscriptFsError('timeout', 'slow share') + await expect( + toHostReadableTranscriptPath('/home/other/x.jsonl', { + platform: 'win32', + pathExists: async (candidate) => { + if (candidate.includes('Debian')) { + throw refusal + } + return false + }, + listWslHomeDirs: async () => [DEBIAN_HOME, UBUNTU_HOME] + }) + ).rejects.toBe(refusal) + }) + it('does not translate guest paths off Windows', async () => { await expect( toHostReadableTranscriptPath('/home/ada/rollout.jsonl', { platform: 'darwin', - pathExists: (candidate) => candidate === '/home/ada/rollout.jsonl', + pathExists: async (candidate) => candidate === '/home/ada/rollout.jsonl', listWslHomeDirs: async () => { throw new Error('should not enumerate distros') } @@ -128,7 +161,7 @@ describe('toHostReadableTranscriptPath', () => { for (let tick = 0; tick < 5; tick += 1) { await toHostReadableTranscriptPath(ROLLOUT_LINUX, { platform: 'win32', - pathExists: () => false, + pathExists: async () => false, listWslHomeDirs }) } @@ -151,7 +184,7 @@ describe('toHostReadableTranscriptPath', () => { const call = (): Promise => toHostReadableTranscriptPath('/home/other/x.jsonl', { platform: 'win32', - pathExists: (candidate) => candidate === debianRollout, + pathExists: async (candidate) => candidate === debianRollout, listWslHomeDirs }) diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index c9ed265dc7b..4d919099536 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -1,8 +1,14 @@ import { existsSync } from 'node:fs' +import { access } from 'node:fs/promises' import { isAbsolute } from 'node:path' -import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' +import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' +import { + runWslTranscriptFsTask, + wslTranscriptFsRefusal, + type WslTranscriptFsError +} from './wsl-transcript-fs-gate' /** * True for guest-absolute Linux paths that Win32 cannot open as-is. @@ -30,11 +36,31 @@ export function needsWslHostTranslation( export type HostReadableTranscriptPathDeps = { platform?: NodeJS.Platform - pathExists?: (path: string) => boolean + pathExists?: (path: string) => Promise + signal?: AbortSignal /** Each installed WSL distro's `$HOME` as a Windows UNC path. */ listWslHomeDirs?: () => Promise } +// Why: candidates are `\\wsl.localhost` UNC paths served over 9P. A sync probe +// against a stopped or unreachable distro would stall the Electron main thread +// instead of falling through to the next candidate, so keep this off the loop. +async function pathExistsAsync(path: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + if (!isWslUncPath(path)) { + return existsSync(path) + } + const probe = async (): Promise => { + try { + await access(path) + return true + } catch { + return false + } + } + return runWslTranscriptFsTask({ operation: 'access', path, priority: 'exact', signal }, probe) +} + // Why: resolveSessionFilePath runs on a 500ms–5s poll loop. listWslDistrosAsync // caches, but getWslHomeAsync does NOT cache failures, so a cold/stopped distro // would re-spawn wsl.exe on every tick. Cache the composed answer here instead. @@ -100,22 +126,36 @@ export async function toHostReadableTranscriptPath( if (!path) { return null } - const pathExists = deps.pathExists ?? existsSync + deps.signal?.throwIfAborted() + const pathExists = + deps.pathExists ?? ((candidate: string) => pathExistsAsync(candidate, deps.signal)) const platform = deps.platform ?? process.platform // Why: classify BEFORE probing — Win32 resolves a bare `/home/…` against the // current drive (`C:\home\…`), so a probe first could bind chat to a local // look-alike file instead of the real WSL transcript. if (!needsWslHostTranslation(path, platform)) { - return pathExists(path) ? path : null + return (await pathExists(path)) ? path : null } const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs) + // Sequential on purpose: the ranked order picks the owning distro, and probing + // every distro at once would fan out 9P calls to ones the user left stopped. + let unavailable: WslTranscriptFsError | undefined for (const distro of rankDistrosForGuestPath(homeDirs, path)) { const uncPath = toWindowsWslPath(path, distro) - if (pathExists(uncPath)) { - return uncPath + try { + if (await pathExists(uncPath)) { + return uncPath + } + } catch (error) { + // Why: one stalled distro must not hide another distro's hit. + unavailable = wslTranscriptFsRefusal(error) } } + // No hit and at least one distro never probed: "couldn't look", not "absent". + if (unavailable) { + throw unavailable + } return null } diff --git a/src/main/native-chat/session-file-resolver-codex-roots.test.ts b/src/main/native-chat/session-file-resolver-codex-roots.test.ts new file mode 100644 index 00000000000..5e0001e1137 --- /dev/null +++ b/src/main/native-chat/session-file-resolver-codex-roots.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const MANAGED_HOME = '/tmp/orca-user-data/codex-runtime-home/home' + +// Why: only the path-only variant may run on the resolve poll — the getter +// mkdirSyncs the runtime home, which is launch-time work and blocks the main +// thread on every tick. +vi.mock('../codex/codex-home-paths', () => ({ + getOrcaManagedCodexHomePath: vi.fn(() => MANAGED_HOME), + resolveOrcaManagedCodexHomePath: vi.fn(() => MANAGED_HOME) +})) + +// Keeps the WSL fallback tier inert so this stays a host-roots test on any platform. +vi.mock('../wsl', () => ({ + listWslDistrosAsync: vi.fn(async () => []), + getWslHomeAsync: vi.fn(async () => null) +})) + +const scanned = vi.hoisted(() => ({ dirs: [] as string[] })) +vi.mock('../ai-vault/session-scanner-discovery', () => ({ + walkSessionFiles: async (dir: string) => { + scanned.dirs.push(dir) + return [] + } +})) + +import { join } from 'node:path' +import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' +import { resetHostReadableTranscriptPathCacheForTests } from './host-readable-transcript-path' +import { resolveSessionFilePath } from './session-file-resolver' + +beforeEach(() => { + resetHostReadableTranscriptPathCacheForTests() + vi.mocked(getOrcaManagedCodexHomePath).mockClear() + scanned.dirs = [] +}) + +describe('codex sessions roots', () => { + it('scans the managed home without materializing it', async () => { + await resolveSessionFilePath('codex', 'sess-1') + + expect(scanned.dirs).toContain(join(MANAGED_HOME, 'sessions')) + expect(vi.mocked(getOrcaManagedCodexHomePath)).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts new file mode 100644 index 00000000000..af14882b371 --- /dev/null +++ b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WslTranscriptFsGateModule from './wsl-transcript-fs-gate' + +const WSL_SESSIONS_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions' +const DEBIAN_SESSIONS_DIR = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions' +const LOCAL_SESSIONS_DIR = 'C:\\Users\\ada\\.codex\\sessions' + +const mocks = vi.hoisted(() => ({ + gate: vi.fn(async (_options: { path: string }) => []), + walk: vi.fn( + async ( + dir: string, + _agent: string, + _issues: unknown[], + options: { readDirectory?: (dirPath: string) => Promise } + ) => { + await options.readDirectory?.(dir) + return [] as string[] + } + ) +})) + +vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => ({ + ...(await importOriginal()), + runWslTranscriptFsTask: mocks.gate +})) +vi.mock('../ai-vault/session-scanner-discovery', () => ({ + walkSessionFiles: mocks.walk +})) + +import { resolveSessionFilePath } from './session-file-resolver' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' + +beforeEach(() => { + mocks.gate.mockClear() + mocks.walk.mockClear() +}) + +describe('Codex WSL scan gate', () => { + it('routes WSL session-tree scans through the shared filesystem gate', async () => { + await resolveSessionFilePath('codex', 'session-id', { + codexSessionsDirs: [WSL_SESSIONS_DIR] + }) + + expect(mocks.gate).toHaveBeenCalledWith( + expect.objectContaining({ operation: 'readdir', priority: 'scan' }), + expect.any(Function) + ) + expect(mocks.walk).toHaveBeenCalledWith(WSL_SESSIONS_DIR, 'codex', [], expect.any(Object)) + }) + + it('keeps local session-tree scans outside the WSL gate', async () => { + await resolveSessionFilePath('codex', 'session-id', { + codexSessionsDirs: [LOCAL_SESSIONS_DIR] + }) + + expect(mocks.gate).not.toHaveBeenCalled() + expect(mocks.walk).toHaveBeenCalledTimes(1) + }) + + it("still surfaces a later root's hit when an earlier root is gate-refused", async () => { + const hit = `${DEBIAN_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl` + mocks.gate.mockImplementation(async (options) => { + if (options.path.includes('Ubuntu')) { + throw new WslTranscriptFsError('timeout', 'slow share') + } + return [] + }) + mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => { + await options.readDirectory?.(dir) + return dir === DEBIAN_SESSIONS_DIR ? [hit] : [] + }) + + await expect( + resolveSessionFilePath('codex', 'session-id', { + codexSessionsDirs: [WSL_SESSIONS_DIR, DEBIAN_SESSIONS_DIR] + }) + ).resolves.toBe(hit) + }) + + it('reports unavailability, not a miss, when every scanned root is gate-refused', async () => { + const refusal = new WslTranscriptFsError('unavailable', 'stuck permits') + mocks.gate.mockRejectedValue(refusal) + + await expect( + resolveSessionFilePath('codex', 'session-id', { + codexSessionsDirs: [WSL_SESSIONS_DIR] + }) + ).rejects.toBe(refusal) + }) + + it('falls through a gate-refused hook path to an id-based hit', async () => { + const hit = `${DEBIAN_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl` + mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => { + if (options.operation === 'access') { + throw new WslTranscriptFsError('timeout', 'slow share') + } + return [] + }) + mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => { + await options.readDirectory?.(dir) + return dir === DEBIAN_SESSIONS_DIR ? [hit] : [] + }) + + await expect( + resolveSessionFilePath('codex', 'session-id', { + transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`, + codexSessionsDirs: [DEBIAN_SESSIONS_DIR] + }) + ).resolves.toBe(hit) + }) + + it('surfaces the hook-path refusal when the id search also misses', async () => { + const refusal = new WslTranscriptFsError('unavailable', 'stuck permits') + mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => { + if (options.operation === 'access') { + throw refusal + } + return [] + }) + mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => { + await options.readDirectory?.(dir) + return [] + }) + + await expect( + resolveSessionFilePath('codex', 'session-id', { + transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`, + codexSessionsDirs: [LOCAL_SESSIONS_DIR] + }) + ).rejects.toBe(refusal) + }) +}) diff --git a/src/main/native-chat/session-file-resolver-wsl.test.ts b/src/main/native-chat/session-file-resolver-wsl.test.ts index 6195fca93ca..e57461d9047 100644 --- a/src/main/native-chat/session-file-resolver-wsl.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeFsModule from 'node:fs' +import type * as NodeFsPromisesModule from 'node:fs/promises' const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' const WSL_MANAGED_SESSIONS_DIR = `${UBUNTU_HOME}\\.local\\share\\orca\\codex-runtime-home\\home\\sessions` @@ -13,15 +13,25 @@ vi.mock('../wsl', () => ({ getWslHomeAsync: vi.fn(async () => UBUNTU_HOME) })) -// Only WSL UNC paths are readable; the guest Linux path is not (as on a real -// Windows host, where it would misresolve against the current drive). -const fsState = vi.hoisted(() => ({ existsAll: false })) -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() +// Only these UNC fixtures are readable. Every other `\\wsl.localhost\` path — +// wrong distro, missing file — must reject, or the mock would mask a misresolve. +// Non-WSL paths hit the real fs, so the guest Linux path stays unreadable as on a +// real Windows host, where it would misresolve against the current drive. +const READABLE_WSL_UNC_PATHS = new Set([ROLLOUT_UNC]) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - existsSync: (path: string) => - fsState.existsAll || path.startsWith('\\\\wsl.localhost\\') || actual.existsSync(path) + access: async (path: string) => { + if (!path.startsWith('\\\\wsl.localhost\\')) { + await actual.access(path) + return + } + if (!READABLE_WSL_UNC_PATHS.has(path)) { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + } + } } }) @@ -53,7 +63,6 @@ beforeEach(() => { vi.mocked(listWslDistrosAsync).mockClear() scanned.dirs = [] scanned.hostRootHasRollout = false - fsState.existsAll = false setPlatform('win32') }) @@ -70,6 +79,14 @@ describe('resolveSessionFilePath on a Windows host with WSL', () => { expect(resolved).toBe(ROLLOUT_UNC) }) + it('does not return a UNC twin that no distro actually has', async () => { + const resolved = await resolveSessionFilePath('codex', 'wsl-sess', { + transcriptPath: '/home/ada/.codex/sessions/2026/07/24/rollout-gone.jsonl', + codexSessionsDirs: [] + }) + expect(resolved).toBeNull() + }) + it('searches the WSL managed Codex sessions root when no hook path is known', async () => { await resolveSessionFilePath('codex', 'wsl-sess') expect(scanned.dirs).toContain(WSL_MANAGED_SESSIONS_DIR) @@ -79,7 +96,6 @@ describe('resolveSessionFilePath on a Windows host with WSL', () => { it('does not enumerate WSL distros when a host Codex root already has the rollout', async () => { // Why: listing WSL homes spawns wsl.exe per distro, which boots distros the // user deliberately left stopped. It must stay a last resort. - fsState.existsAll = true scanned.hostRootHasRollout = true await expect(resolveSessionFilePath('codex', 'wsl-sess')).resolves.toBe(HOST_ROLLOUT) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 2761f99eac3..f0376e01f02 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -1,17 +1,22 @@ -import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' -import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import { + resolveNativeChatTranscriptAgent, + type NativeChatTranscriptAgent +} from '../../shared/native-chat-agent-support' +import { isWslUncPath } from '../../shared/wsl-paths' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts' import { normalizeAgentSessionsDir } from '../ai-vault/session-scanner-values' -import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' +import { resolveOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { findGrokChatHistoryBySessionId, resolveGrokSessionsDir } from '../../shared/grok-session-paths' import { toHostReadableTranscriptPath, wslCodexSessionsDirs } from './host-readable-transcript-path' +import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' +import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate' // Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads // run in the main process against the runtime's own home directory; over SSH @@ -29,9 +34,11 @@ function claudeProjectsDir(): string { // fall back to CODEX_HOME/~/.codex so a non-Orca Codex transcript still resolves. // Duplicates are filtered so a managed-home symlink to ~/.codex isn't scanned twice. // WSL roots are a separate lazy tier — see resolveCodexSessionFile. +// Why: resolveOrcaManagedCodexHomePath avoids the mkdirSync performed by the +// getter; creating the runtime home belongs to launch, not this resolve poll. function codexSessionsDirs(): string[] { const candidates = [ - join(getOrcaManagedCodexHomePath(), 'sessions'), + join(resolveOrcaManagedCodexHomePath(), 'sessions'), join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions') ] return candidates.filter((dir, index) => candidates.indexOf(dir) === index) @@ -80,8 +87,10 @@ export type ResolveSessionFileOptions = { export async function resolveSessionFilePath( agent: AgentType, sessionId: string, - options: ResolveSessionFileOptions = {} + options: ResolveSessionFileOptions = {}, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const transcriptAgent = resolveNativeChatTranscriptAgent(agent) if (!transcriptAgent) { return null @@ -90,21 +99,45 @@ export async function resolveSessionFilePath( // beats reconstructing a path from the session id. Route it through the host // readability check so a WSL guest path becomes an openable UNC on Windows; // stale/missing paths fall through to the id-based search. + let unavailable: WslTranscriptFsError | undefined const hookPath = options.transcriptPath?.trim() if (hookPath && extname(hookPath) === '.jsonl') { - const hostReadable = await toHostReadableTranscriptPath(hookPath) - if (hostReadable) { - return hostReadable + try { + const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal }) + if (hostReadable) { + return hostReadable + } + } catch (error) { + // Why: the id-based search may still hit; surface the refusal only when + // it does not, so a stalled distro reads as unavailable, never "missing". + unavailable = wslTranscriptFsRefusal(error) } } + const resolved = await resolveSessionFileById(transcriptAgent, sessionId, options, signal) + if (!resolved && unavailable) { + throw unavailable + } + return resolved +} + +async function resolveSessionFileById( + transcriptAgent: NativeChatTranscriptAgent, + sessionId: string, + options: ResolveSessionFileOptions, + signal?: AbortSignal +): Promise { const trimmedId = sessionId.trim() if (!trimmedId) { return null } if (transcriptAgent === 'claude') { - return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir()) + return resolveClaudeSessionFile( + trimmedId, + options.claudeProjectsDir ?? claudeProjectsDir(), + signal + ) } if (transcriptAgent === 'codex') { const overrideDirs = options.codexSessionsDirs @@ -113,26 +146,33 @@ export async function resolveSessionFilePath( overrideDirs ?? codexSessionsDirs(), // Why: enumerating WSL homes spawns wsl.exe per distro, which boots ones the // user left stopped. Only pay that after this host's own Codex roots miss. - overrideDirs ? undefined : wslCodexSessionsDirs + overrideDirs ? undefined : wslCodexSessionsDirs, + signal ) } if (transcriptAgent === 'grok') { - return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) + return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir(), signal) } if (transcriptAgent === 'omp') { - return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir()) + return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal) } + // Why: a new transcript agent must pick its own resolver. Falling through to + // OMP's scan would search the wrong root with a foreign session id, so fail + // the build here instead of resolving silently wrong at runtime. + transcriptAgent satisfies never return null } async function resolveClaudeSessionFile( sessionId: string, - projectsDir: string + projectsDir: string, + signal?: AbortSignal ): Promise { const targetName = `${sessionId}.jsonl` const files = await walkSessionFiles(projectsDir, 'claude', [], { extensions: new Set(['.jsonl']), - filePredicate: (path) => basename(path) === targetName + filePredicate: (path) => basename(path) === targetName, + signal }) return files[0] ?? null } @@ -140,53 +180,79 @@ async function resolveClaudeSessionFile( async function resolveCodexSessionFile( sessionId: string, sessionsDirs: string[], - loadFallbackDirs?: () => Promise + loadFallbackDirs?: () => Promise, + signal?: AbortSignal ): Promise { // Codex rollout file names embed the session id (rollout--.jsonl), so // match the id as a suffix of the file's base name rather than an exact name. // Search each candidate root (managed home first) and stop at the first match. - const hit = await findCodexRolloutInDirs(sessionId, sessionsDirs) + const hit = await findCodexRolloutInDirs(sessionId, sessionsDirs, signal) if (hit) { return hit } if (!loadFallbackDirs) { return null } + signal?.throwIfAborted() // Why: a WSL-hosted session's rollout only exists inside the distro, so fall // back to each distro's Codex homes when the host's own roots came up empty (#10326). const fallbackDirs = (await loadFallbackDirs()).filter((dir) => !sessionsDirs.includes(dir)) - return findCodexRolloutInDirs(sessionId, fallbackDirs) + signal?.throwIfAborted() + return findCodexRolloutInDirs(sessionId, fallbackDirs, signal) } async function findCodexRolloutInDirs( sessionId: string, - sessionsDirs: string[] + sessionsDirs: string[], + signal?: AbortSignal ): Promise { + let unavailable: WslTranscriptFsError | undefined for (const sessionsDir of sessionsDirs) { - if (!existsSync(sessionsDir)) { - continue - } - const files = await walkSessionFiles(sessionsDir, 'codex', [], { + // Why: no existence pre-check — walkSessionFiles already yields [] for a + // missing/unreadable root, and a sync probe would block the main thread on a + // `\\wsl.localhost` root whose distro is stopped. + const scanOptions = { extensions: new Set(['.jsonl']), - filePredicate: (path) => { + filePredicate: (path: string): boolean => { const name = basename(path, extname(path)) return name === sessionId || name.endsWith(`-${sessionId}`) } - }) - if (files[0]) { - return files[0] } + const isWslRoot = isWslUncPath(sessionsDir) + try { + const files = isWslRoot + ? await findWslCodexSessionPath(sessionsDir, sessionId, signal) + : ( + await walkSessionFiles(sessionsDir, 'codex', [], { + ...scanOptions, + signal + }) + )[0] + if (files) { + return files + } + } catch (error) { + // Why: one stalled distro must not hide another root's hit. + unavailable = wslTranscriptFsRefusal(error) + } + } + // No hit and at least one root never scanned: "couldn't look", not "missing". + if (unavailable) { + throw unavailable } return null } async function resolveGrokSessionFile( sessionId: string, - sessionsDir: string + sessionsDir: string, + signal?: AbortSignal ): Promise { // Why: Native Chat runs on the main thread; use the bounded async direct-layout // lookup instead of blocking, then repeating, a recursive full-tree scan. + signal?.throwIfAborted() const history = await findGrokChatHistoryBySessionId(sessionsDir, sessionId) + signal?.throwIfAborted() return history } @@ -196,7 +262,8 @@ async function resolveGrokSessionFile( // walk cover the per-cwd subdirectories. async function resolveOmpSessionFile( sessionId: string, - sessionsDir: string + sessionsDir: string, + signal?: AbortSignal ): Promise { const files = await walkSessionFiles(sessionsDir, 'omp', [], { extensions: new Set(['.jsonl']), @@ -212,7 +279,8 @@ async function resolveOmpSessionFile( filePredicate: (path) => { const name = basename(path, extname(path)) return name === sessionId || name.endsWith(`_${sessionId}`) - } + }, + signal }) return files[0] ?? null } diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 8943c223dd2..d2a4b4d3de2 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -2,6 +2,7 @@ import { stat } from 'node:fs/promises' import type { AgentType } from '../../shared/native-chat-types' import { resolveSessionFilePath } from './session-file-resolver' import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader' +import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' // Why: both the desktop IPC handler and the runtime RPC handler read the same // host-filesystem transcript, so a single process-global cache keyed by the @@ -89,7 +90,14 @@ export async function readNativeChatTranscriptCached( /** Hook-reported authoritative transcript path, preferred over the id glob. */ transcriptPath?: string ): Promise { - const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) + let filePath: string | null + try { + filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) + } catch (err) { + // Why: gate refusal is transient unavailability — report it without caching + // or `notFound` so the next call re-resolves. + return { error: wslTranscriptFsRefusal(err).message } + } if (!filePath) { // Not cached (see below): a not-yet-flushed transcript should be re-checked // on the next call, not pinned as a settled miss (#8401). diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 8cb57fdb91d..a8e14e0e180 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -7,6 +7,7 @@ import type { import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, @@ -41,7 +42,14 @@ export async function readNativeChatTranscript( sessionId: string, options: ReadTranscriptOptions = {} ): Promise { - const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) + let filePath: string | null + try { + filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) + } catch (err) { + // Why: gate refusal is transient unavailability with retry guidance — + // `notFound` would settle callers into a false "missing" state. + return { error: wslTranscriptFsRefusal(err).message } + } if (!filePath) { return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } diff --git a/src/main/native-chat/transcript-tail-reader-cancellation.test.ts b/src/main/native-chat/transcript-tail-reader-cancellation.test.ts new file mode 100644 index 00000000000..d211805337f --- /dev/null +++ b/src/main/native-chat/transcript-tail-reader-cancellation.test.ts @@ -0,0 +1,100 @@ +import type * as NodeFsPromisesModule from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fsMocks = vi.hoisted(() => ({ + open: vi.fn(), + stat: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + open: fsMocks.open, + stat: fsMocks.stat +})) + +import { readNativeChatTranscriptTail } from './transcript-tail-reader' + +describe('native chat transcript tail cancellation', () => { + beforeEach(() => { + fsMocks.open.mockReset() + fsMocks.stat.mockReset() + }) + + it('closes after a pending tail read without reading another chunk when canceled', async () => { + let finishTailRead: (() => void) | undefined + let readCount = 0 + const close = vi.fn(async () => {}) + const read = vi.fn((buffer: Buffer) => { + readCount++ + if (readCount === 1) { + buffer[0] = 0x0a + return Promise.resolve({ bytesRead: 1, buffer }) + } + if (readCount > 2) { + buffer.fill(0x0a) + return Promise.resolve({ bytesRead: buffer.length, buffer }) + } + return new Promise<{ bytesRead: number; buffer: Buffer }>((resolve) => { + finishTailRead = () => { + buffer.fill(0x0a) + resolve({ bytesRead: buffer.length, buffer }) + } + }) + }) + fsMocks.stat.mockResolvedValue({ size: 8 * 64 * 1024 + 1 }) + fsMocks.open.mockResolvedValue({ close, read }) + const controller = new AbortController() + const canceled = new Error('request canceled') + const pending = readNativeChatTranscriptTail( + { + agent: 'claude', + sessionId: 'session-id', + filePath: 'transcript.jsonl', + limit: 40 + }, + controller.signal + ) + const rejection = expect(pending).rejects.toBe(canceled) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)) + + controller.abort(canceled) + finishTailRead?.() + + await rejection + expect(read).toHaveBeenCalledTimes(2) + expect(close).toHaveBeenCalledOnce() + }) + + it('closes a handle opened after cancellation without starting a read', async () => { + let finishOpen: (() => void) | undefined + const close = vi.fn(async () => {}) + const read = vi.fn() + fsMocks.stat.mockResolvedValue({ size: 1 }) + fsMocks.open.mockImplementation( + () => + new Promise((resolve) => { + finishOpen = () => resolve({ close, read }) + }) + ) + const controller = new AbortController() + const canceled = new Error('request canceled while opening') + const pending = readNativeChatTranscriptTail( + { + agent: 'claude', + sessionId: 'session-id', + filePath: 'transcript.jsonl', + limit: 40 + }, + controller.signal + ) + const rejection = expect(pending).rejects.toBe(canceled) + await vi.waitFor(() => expect(fsMocks.open).toHaveBeenCalledOnce()) + + controller.abort(canceled) + finishOpen?.() + + await rejection + expect(read).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts b/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts new file mode 100644 index 00000000000..ff5194668cd --- /dev/null +++ b/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolve: vi.fn<() => Promise>() +})) + +vi.mock('./session-file-resolver', () => ({ + resolveSessionFilePath: mocks.resolve +})) + +import { readNativeChatTranscriptTail } from './transcript-tail-reader' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' + +describe('native chat transcript tail under WSL gate refusals', () => { + beforeEach(() => { + mocks.resolve.mockReset() + }) + + it('reports a gate refusal as a retryable error, not a missing transcript', async () => { + mocks.resolve.mockRejectedValueOnce(new WslTranscriptFsError('timeout', 'slow share')) + + await expect( + readNativeChatTranscriptTail({ agent: 'codex', sessionId: 'session-id', limit: 10 }) + ).resolves.toEqual({ error: 'slow share' }) + }) + + it('rethrows non-gate resolver failures', async () => { + mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed')) + + await expect( + readNativeChatTranscriptTail({ agent: 'codex', sessionId: 'session-id', limit: 10 }) + ).rejects.toThrow('resolver crashed') + }) +}) diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 35bee906956..1264c2e3d98 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -17,6 +17,7 @@ import { nativeChatTurnLifecycleDecoderForAgent, type NativeChatTurnLifecycleDecoder } from './transcript-turn-lifecycle' +import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 const TAIL_CHUNK_BYTES = 64 * 1024 @@ -46,7 +47,8 @@ export async function readNativeChatTranscriptTailFile( decode: NativeChatLineDecoder, includeTrailingLine = false, endOffset?: number, - decodeLifecycle?: NativeChatTurnLifecycleDecoder | null + decodeLifecycle?: NativeChatTurnLifecycleDecoder | null, + signal?: AbortSignal ): Promise<{ messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle @@ -56,7 +58,9 @@ export async function readNativeChatTranscriptTailFile( malformedRecordCount?: number oversizedRecordCount?: number }> { + signal?.throwIfAborted() const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) + signal?.throwIfAborted() if (end === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } @@ -69,19 +73,25 @@ export async function readNativeChatTranscriptTailFile( let oversizedRecordCount = 0 let ignoreNextMalformedRecord = false try { - const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) + signal?.throwIfAborted() + const consumedTo = includeTrailingLine + ? end + : await findLastCompleteLineEnd(handle, end, signal) if (consumedTo === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } const newestFirst: { message: NativeChatMessage; offset: number }[] = [] const finalByte = Buffer.allocUnsafe(1) await handle.read(finalByte, 0, 1, consumedTo - 1) + signal?.throwIfAborted() ignoreNextMalformedRecord = finalByte[0] !== 0x0a let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) while (cursor > 0 && newestFirst.length <= limit) { + signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + signal?.throwIfAborted() let segmentEnd = bytesRead for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { if (buffer[index] !== 0x0a) { @@ -175,20 +185,25 @@ export async function readNativeChatTranscriptTailFile( async function findLastCompleteLineEnd( handle: Awaited>, - end: number + end: number, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const lastByte = Buffer.allocUnsafe(1) await handle.read(lastByte, 0, 1, end - 1) + signal?.throwIfAborted() if (lastByte[0] === 0x0a) { return end } let cursor = end while (cursor > 0) { + signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + signal?.throwIfAborted() const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) - if (newline >= 0) { + if (newline !== -1) { return start + newline + 1 } cursor = start @@ -204,7 +219,8 @@ export async function readNativeChatTranscriptTail( filePath?: string limit: number beforeOffset?: number - } + }, + signal?: AbortSignal ): Promise< | { messages: NativeChatMessage[] @@ -216,10 +232,20 @@ export async function readNativeChatTranscriptTail( > { const decode = nativeChatLineDecoderForAgent(args.agent) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) - const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args)) if (!decode) { return { error: 'Transcript unavailable' } } + let filePath: string | null + try { + filePath = + args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal)) + } catch (error) { + signal?.throwIfAborted() + // Why: gate refusal is transient unavailability with retry guidance — + // `notFound` would settle callers into a false "missing" state. + return { error: wslTranscriptFsRefusal(error).message } + } + signal?.throwIfAborted() // Why: a new agent session can report its id before the first JSONL flush; // callers keep that miss in loading/retry rather than showing a false error. if (!filePath) { @@ -232,8 +258,10 @@ export async function readNativeChatTranscriptTail( decode, true, args.beforeOffset, - decodeLifecycle + decodeLifecycle, + signal ) + signal?.throwIfAborted() return { messages: result.messages, // Why: an older pagination page must not rewind the live lifecycle; only @@ -245,6 +273,7 @@ export async function readNativeChatTranscriptTail( beforeOffset: result.beforeOffset } } catch (error) { + signal?.throwIfAborted() const message = error instanceof Error ? error.message : String(error) return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' ? { error: message, notFound: true } diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts index 3dd33659656..e5ec941e5e9 100644 --- a/src/main/native-chat/transcript-watch-resolve-poll.test.ts +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -20,6 +20,7 @@ vi.mock('./host-readable-transcript-path', async (importOriginal) => ({ })) import { subscribeNativeChatTranscript } from './transcript-watch' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' const realPlatform = process.platform @@ -68,7 +69,7 @@ describe('native chat transcript resolve polling', () => { }) it('retries the WSL translation on the slow cadence, never installing the raw guest path', async () => { - // Why: each translation sync-stats the UNC twin per distro over the 9P + // Why: each translation probes the UNC twin per distro over the 9P // share; doing it every fast tick would hammer the main process (#10326). setPlatform('win32') const subscription = await subscribeNativeChatTranscript({ @@ -124,4 +125,118 @@ describe('native chat transcript resolve polling', () => { expect(mocks.resolve.mock.calls.length).toBeGreaterThan(1) subscription.unsubscribe() }) + + it('degrades a gate-refused initial resolve to the poll fallback instead of failing', async () => { + const engine = { unsubscribe: vi.fn(), watching: true } + mocks.resolve + .mockRejectedValueOnce(new WslTranscriptFsError('unavailable', 'stuck permits')) + .mockResolvedValue('/home/ada/found.jsonl') + mocks.install.mockImplementation((filePath: string) => (filePath ? engine : null)) + + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + expect(subscription.watching).toBe(true) + + await vi.advanceTimersByTimeAsync(20) + expect( + mocks.install.mock.calls.some(([filePath]) => filePath === '/home/ada/found.jsonl') + ).toBe(true) + subscription.unsubscribe() + expect(engine.unsubscribe).toHaveBeenCalled() + }) + + it('still fails subscribe on non-gate resolver errors', async () => { + mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed')) + + await expect( + subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + onAppend: () => {} + }) + ).rejects.toThrow('resolver crashed') + }) + + it('cancels queued WSL resolution when the subscription closes', async () => { + setPlatform('win32') + let receivedSignal: AbortSignal | undefined + mocks.toHostReadable.mockImplementation( + (_path: string, deps: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + receivedSignal = deps.signal + deps.signal?.addEventListener('abort', () => reject(deps.signal?.reason), { once: true }) + }) + ) + const subscription = await subscribeNativeChatTranscript({ + agent: 'codex', + sessionId: 'session-id', + transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(10) + expect(receivedSignal?.aborted).toBe(false) + subscription.unsubscribe() + expect(receivedSignal?.aborted).toBe(true) + }) + + it('does not install after initial resolution is cancelled', async () => { + let finishResolve: ((path: string) => void) | undefined + mocks.resolve.mockImplementation( + () => + new Promise((resolve) => { + finishResolve = resolve + }) + ) + const controller = new AbortController() + const cancelled = new Error('setup cancelled') + const setup = subscribeNativeChatTranscript( + { + agent: 'codex', + sessionId: 'session-id', + onAppend: () => {} + }, + controller.signal + ) + + controller.abort(cancelled) + finishResolve?.('/transcript.jsonl') + await expect(setup).rejects.toBe(cancelled) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('tears down a watcher returned after initial setup is cancelled', async () => { + mocks.resolve.mockResolvedValue('/transcript.jsonl') + const installControl: { + finish?: (subscription: { unsubscribe: () => void; watching: boolean }) => void + } = {} + const unsubscribe = vi.fn() + mocks.install.mockImplementation( + () => + new Promise((resolve) => { + installControl.finish = resolve + }) + ) + const controller = new AbortController() + const cancelled = new Error('setup cancelled') + const setup = subscribeNativeChatTranscript( + { + agent: 'codex', + sessionId: 'session-id', + onAppend: () => {} + }, + controller.signal + ) + await vi.waitFor(() => expect(mocks.install).toHaveBeenCalledOnce()) + + controller.abort(cancelled) + installControl.finish?.({ unsubscribe, watching: true }) + await expect(setup).rejects.toBe(cancelled) + expect(unsubscribe).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts index e84bc329cc8..5a483c11c48 100644 --- a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts +++ b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeFsModule from 'node:fs' +import type * as NodeFsPromisesModule from 'node:fs/promises' const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' const ROLLOUT_LINUX = @@ -23,11 +23,15 @@ vi.mock('../wsl', () => ({ listWslDistrosAsync: vi.fn(async () => ['Ubuntu']), getWslHomeAsync: vi.fn(async () => UBUNTU_HOME) })) -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - existsSync: (path: string) => path === ROLLOUT_UNC || actual.existsSync(path) + access: async (path: string) => { + if (path !== ROLLOUT_UNC) { + await actual.access(path) + } + } } }) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index cc02c70c36d..2370d7b6410 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -11,6 +11,7 @@ import type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' +import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' export { readNativeChatTranscriptTail } from './transcript-tail-reader' export { getActiveNativeChatWatcherCount } from './transcript-watch-engine' @@ -23,13 +24,21 @@ export type { * is unresolved; native-watch failure degrades to reconciliation-only mode. */ async function attemptInstall( args: SubscribeNativeChatTranscriptArgs, - decode: (line: string, fallbackId: string) => NativeChatMessage | null + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + signal?: AbortSignal ): Promise { - const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args)) + const filePath = + args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal)) + signal?.throwIfAborted() if (!filePath) { return null } - return installTranscriptWatcher(filePath, decode, args) + const installed = await installTranscriptWatcher(filePath, decode, args) + if (signal?.aborted) { + installed?.unsubscribe() + signal.throwIfAborted() + } + return installed } // Why: Claude Code (and other agents) can take from ~3s to minutes to flush a @@ -69,6 +78,7 @@ function subscribeViaResolvePoll( // the exact-path install doesn't wait on the slower id-glob (#10326). let hostReadableExactPath: string | null = null let lastWslTranslateAt = 0 + const resolveController = new AbortController() function scheduleAttempt(): void { if (closed) { @@ -106,24 +116,30 @@ function subscribeViaResolvePoll( // not-yet-created file, so don't spend an extra probe per tick. hostReadableExactPath = exactPath } else if (Date.now() - lastWslTranslateAt >= FALLBACK_RESOLVE_POLL_MS) { - // Why: translating sync-stats the UNC twin per distro over the 9P + // Why: translating probes the UNC twin per distro over the 9P // share, and the guest file usually appears well after the hook does, // so retry on the slow cadence rather than every fast tick. The raw // guest path is never installed on Windows — it would resolve against // the current drive (`C:\home\…`) and bind chat to a look-alike file. lastWslTranslateAt = Date.now() - hostReadableExactPath = await toHostReadableTranscriptPath(exactPath) + hostReadableExactPath = await toHostReadableTranscriptPath(exactPath, { + signal: resolveController.signal + }) } } result = hostReadableExactPath - ? await attemptInstall({ ...args, filePath: hostReadableExactPath }, decode) + ? await attemptInstall( + { ...args, filePath: hostReadableExactPath }, + decode, + resolveController.signal + ) : null if ( !result && (!exactPath || Date.now() - lastFallbackResolveAt >= FALLBACK_RESOLVE_POLL_MS) ) { lastFallbackResolveAt = Date.now() - result = await attemptInstall(args, decode) + result = await attemptInstall(args, decode, resolveController.signal) } } catch { // Why: a transient resolve failure (EACCES/EIO during the glob) must not @@ -151,6 +167,7 @@ function subscribeViaResolvePoll( return } closed = true + resolveController.abort() if (pollTimer) { clearTimeout(pollTimer) pollTimer = null @@ -175,8 +192,10 @@ function subscribeViaResolvePoll( * than returning a no-op that never recovers. */ export async function subscribeNativeChatTranscript( - args: SubscribeNativeChatTranscriptArgs + args: SubscribeNativeChatTranscriptArgs, + setupSignal?: AbortSignal ): Promise { + setupSignal?.throwIfAborted() const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { // Nothing watchable — return a no-op teardown so callers can unconditionally @@ -189,9 +208,19 @@ export async function subscribeNativeChatTranscript( return { unsubscribe: () => {}, watching: false } } - const installed = await attemptInstall(args, decode) + let installed: NativeChatTranscriptSubscription | null + try { + installed = await attemptInstall(args, decode, setupSignal) + } catch (error) { + setupSignal?.throwIfAborted() + // Why: a gate-refused resolve (stalled WSL distro) must degrade to the + // resolve-poll fallback below, not fail the subscribe outright. + void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal + installed = null + } if (installed) { return installed } + setupSignal?.throwIfAborted() return subscribeViaResolvePoll(args, decode) } diff --git a/src/main/native-chat/wsl-codex-session-path-scan.test.ts b/src/main/native-chat/wsl-codex-session-path-scan.test.ts new file mode 100644 index 00000000000..7ea4c467501 --- /dev/null +++ b/src/main/native-chat/wsl-codex-session-path-scan.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ walk: vi.fn() })) + +vi.mock('../ai-vault/session-scanner-discovery', () => ({ + walkSessionFiles: mocks.walk +})) + +import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + return { promise: new Promise((res) => (resolve = res)), resolve } +} + +beforeEach(() => { + mocks.walk.mockReset() +}) + +describe('WSL Codex session path scans', () => { + it('shares one root snapshot across concurrent session ids', async () => { + const scan = deferred() + let filePredicate: ((path: string) => boolean) | undefined + mocks.walk.mockImplementation( + ( + _root: string, + _agent: string, + _issues: unknown[], + options: { filePredicate?: (path: string) => boolean } + ) => { + filePredicate = options.filePredicate + return scan.promise + } + ) + + const first = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'first') + const second = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'second') + const candidates = [ + '\\\\wsl.localhost\\Ubuntu\\sessions\\rollout-first.jsonl', + '\\\\wsl.localhost\\Ubuntu\\sessions\\rollout-second.jsonl', + '\\\\wsl.localhost\\Ubuntu\\sessions\\rollout-unrelated.jsonl' + ] + scan.resolve(candidates.filter((path) => filePredicate?.(path))) + + await expect(Promise.all([first, second])).resolves.toEqual([ + '\\\\wsl.localhost\\Ubuntu\\sessions\\rollout-first.jsonl', + '\\\\wsl.localhost\\Ubuntu\\sessions\\rollout-second.jsonl' + ]) + expect(mocks.walk).toHaveBeenCalledOnce() + expect(filePredicate?.(candidates[2])).toBe(false) + }) + + it('refreshes a shared miss so post-start file creation is visible', async () => { + const initial = deferred() + mocks.walk + .mockReturnValueOnce(initial.promise) + .mockResolvedValueOnce(['\\\\wsl.localhost\\Ubuntu\\sessions\\2026\\rollout-created.jsonl']) + + const initialCaller = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'absent') + const laterCaller = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'created') + initial.resolve([]) + + await expect(initialCaller).resolves.toBeNull() + await expect(laterCaller).resolves.toBe( + '\\\\wsl.localhost\\Ubuntu\\sessions\\2026\\rollout-created.jsonl' + ) + expect(mocks.walk).toHaveBeenCalledTimes(2) + }) + + it('aborts an abandoned scan when its final waiter closes', async () => { + let scanSignal: AbortSignal | undefined + mocks.walk.mockImplementation( + (_root: string, _agent: string, _issues: unknown[], options: { signal?: AbortSignal }) => { + scanSignal = options.signal + return new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { + once: true + }) + }) + } + ) + const controller = new AbortController() + const scan = findWslCodexSessionPath( + '\\\\wsl.localhost\\Ubuntu\\sessions', + 'closed', + controller.signal + ) + await vi.waitFor(() => expect(mocks.walk).toHaveBeenCalledOnce()) + + controller.abort(new Error('closed')) + await expect(scan).rejects.toThrow('closed') + expect(scanSignal?.aborted).toBe(true) + }) + + it('does not attach new callers to an abandoned scan', async () => { + const abandoned = deferred() + const replacementScan = deferred() + const replacementPath = '\\\\wsl.localhost\\Ubuntu\\sessions\\rollout-replacement.jsonl' + mocks.walk.mockReturnValueOnce(abandoned.promise).mockReturnValueOnce(replacementScan.promise) + const controller = new AbortController() + const first = findWslCodexSessionPath( + '\\\\wsl.localhost\\Ubuntu\\sessions', + 'first', + controller.signal + ) + await vi.waitFor(() => expect(mocks.walk).toHaveBeenCalledOnce()) + + controller.abort(new Error('closed')) + await expect(first).rejects.toThrow('closed') + const replacement = findWslCodexSessionPath( + '\\\\wsl.localhost\\Ubuntu\\sessions', + 'replacement' + ) + await vi.waitFor(() => expect(mocks.walk).toHaveBeenCalledTimes(2)) + + abandoned.resolve([]) + await Promise.resolve() + await Promise.resolve() + const duplicate = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'replacement') + replacementScan.resolve([replacementPath]) + + await expect(Promise.all([replacement, duplicate])).resolves.toEqual([ + replacementPath, + replacementPath + ]) + expect(mocks.walk).toHaveBeenCalledTimes(2) + }) + + it('removes canceled joiners from a scan that still has an active waiter', async () => { + const scan = deferred() + const scanThen = vi.spyOn(scan.promise, 'then') + let filePredicate: ((path: string) => boolean) | undefined + mocks.walk.mockImplementation( + ( + _root: string, + _agent: string, + _issues: unknown[], + options: { filePredicate?: (path: string) => boolean } + ) => { + filePredicate = options.filePredicate + return scan.promise + } + ) + const root = '\\\\wsl.localhost\\Ubuntu\\sessions' + const keeperPath = `${root}\\rollout-keeper.jsonl` + const keeper = findWslCodexSessionPath(root, 'keeper') + const initialThenCount = scanThen.mock.calls.length + const duplicateController = new AbortController() + const duplicate = findWslCodexSessionPath(root, 'keeper', duplicateController.signal) + const uniqueCanceled = Array.from({ length: 256 }, (_, index) => { + const controller = new AbortController() + return { + controller, + path: `${root}\\rollout-canceled-${index}.jsonl`, + promise: findWslCodexSessionPath(root, `canceled-${index}`, controller.signal) + } + }) + const canceledResults = Promise.allSettled([ + duplicate, + ...uniqueCanceled.map(({ promise }) => promise) + ]) + + duplicateController.abort(new Error('duplicate closed')) + for (const { controller } of uniqueCanceled) { + controller.abort(new Error('joiner closed')) + } + + const results = await canceledResults + expect(results.every(({ status }) => status === 'rejected')).toBe(true) + expect(scanThen).toHaveBeenCalledTimes(initialThenCount) + expect(filePredicate?.(keeperPath)).toBe(true) + expect(uniqueCanceled.every(({ path }) => filePredicate?.(path) === false)).toBe(true) + + const candidates = [keeperPath, ...uniqueCanceled.map(({ path }) => path)] + scan.resolve(candidates.filter((path) => filePredicate?.(path))) + + await expect(keeper).resolves.toBe(keeperPath) + expect(mocks.walk).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/native-chat/wsl-codex-session-path-scan.ts b/src/main/native-chat/wsl-codex-session-path-scan.ts new file mode 100644 index 00000000000..804bcfe858e --- /dev/null +++ b/src/main/native-chat/wsl-codex-session-path-scan.ts @@ -0,0 +1,180 @@ +import type { Dirent } from 'node:fs' +import { readdir } from 'node:fs/promises' +import { basename, extname } from 'node:path' +import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate' + +type ScanWaiter = { + sessionId: string + resolve: (paths: string[]) => void + reject: (error: unknown) => void + signal?: AbortSignal + onAbort?: () => void +} + +type ScanGeneration = { + root: string + controller: AbortController + sessionIdRefCounts: Map + waiters: Set + settled: boolean +} + +const inFlightScans = new Map() + +function readDirectory(dirPath: string, signal: AbortSignal): Promise { + return runWslTranscriptFsTask( + { operation: 'readdir', path: dirPath, priority: 'scan', signal }, + () => readdir(dirPath, { withFileTypes: true }) + ) +} + +function sessionFileName(path: string): string { + return basename(path, extname(path)) +} + +function nameMatchesSessionId(name: string, sessionId: string): boolean { + return name === sessionId || name.endsWith(`-${sessionId}`) +} + +function matchesRequestedSession(path: string, sessionIds: Map): boolean { + const name = sessionFileName(path) + for (const sessionId of sessionIds.keys()) { + if (nameMatchesSessionId(name, sessionId)) { + return true + } + } + return false +} + +function createScan(root: string): ScanGeneration { + const scan: ScanGeneration = { + root, + controller: new AbortController(), + sessionIdRefCounts: new Map(), + waiters: new Set(), + settled: false + } + inFlightScans.set(root, scan) + return scan +} + +function clearScan(scan: ScanGeneration): void { + if (inFlightScans.get(scan.root) === scan) { + inFlightScans.delete(scan.root) + } +} + +function removeWaiter(scan: ScanGeneration, waiter: ScanWaiter): boolean { + if (!scan.waiters.delete(waiter)) { + return false + } + if (waiter.signal && waiter.onAbort) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + } + const count = scan.sessionIdRefCounts.get(waiter.sessionId) + if (count === 1) { + scan.sessionIdRefCounts.delete(waiter.sessionId) + } else if (count) { + scan.sessionIdRefCounts.set(waiter.sessionId, count - 1) + } + return true +} + +function settleScan(scan: ScanGeneration, outcome: { paths: string[] } | { error: unknown }): void { + if (scan.settled) { + return + } + scan.settled = true + clearScan(scan) + for (const waiter of scan.waiters) { + removeWaiter(scan, waiter) + if ('paths' in outcome) { + waiter.resolve(outcome.paths) + } else { + waiter.reject(outcome.error) + } + } +} + +function startScan(scan: ScanGeneration): void { + try { + const promise = walkSessionFiles(scan.root, 'codex', [], { + extensions: new Set(['.jsonl']), + filePredicate: (path) => matchesRequestedSession(path, scan.sessionIdRefCounts), + readDirectory: (dirPath) => readDirectory(dirPath, scan.controller.signal), + signal: scan.controller.signal + }) + void promise.then( + (paths) => settleScan(scan, { paths }), + (error: unknown) => settleScan(scan, { error }) + ) + } catch (error) { + settleScan(scan, { error }) + } +} + +function waitForScan( + scan: ScanGeneration, + sessionId: string, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + return new Promise((resolve, reject) => { + const waiter: ScanWaiter = { sessionId, resolve, reject, signal } + scan.waiters.add(waiter) + scan.sessionIdRefCounts.set(sessionId, (scan.sessionIdRefCounts.get(sessionId) ?? 0) + 1) + if (!signal) { + return + } + waiter.onAbort = () => { + if (!removeWaiter(scan, waiter)) { + return + } + reject(signal.reason ?? new Error('Codex session scan aborted')) + if (!scan.settled && scan.waiters.size === 0) { + scan.settled = true + clearScan(scan) + scan.controller.abort() + } + } + signal.addEventListener('abort', waiter.onAbort, { once: true }) + if (signal.aborted) { + waiter.onAbort() + } + }) +} + +async function scanRoot( + root: string, + sessionId: string, + signal?: AbortSignal +): Promise<{ paths: string[]; joined: boolean }> { + signal?.throwIfAborted() + const existing = inFlightScans.get(root) + const scan = existing ?? createScan(root) + const pending = waitForScan(scan, sessionId, signal) + if (!existing) { + startScan(scan) + } + return { paths: await pending, joined: Boolean(existing) } +} + +function findSessionPath(paths: string[], sessionId: string): string | null { + return paths.find((path) => nameMatchesSessionId(sessionFileName(path), sessionId)) ?? null +} + +/** Share tree discovery, then refresh a shared miss for post-start file creation. */ +export async function findWslCodexSessionPath( + root: string, + sessionId: string, + signal?: AbortSignal +): Promise { + const first = await scanRoot(root, sessionId, signal) + const firstHit = findSessionPath(first.paths, sessionId) + if (firstHit || !first.joined) { + return firstHit + } + const refreshed = await scanRoot(root, sessionId, signal) + return findSessionPath(refreshed.paths, sessionId) +} diff --git a/src/main/native-chat/wsl-transcript-fs-gate.test.ts b/src/main/native-chat/wsl-transcript-fs-gate.test.ts new file mode 100644 index 00000000000..1f82ab25277 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-gate.test.ts @@ -0,0 +1,634 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { + runWslTranscriptFsTask, + WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, + WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS, + WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK, + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from './wsl-transcript-fs-gate' + +const SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' +const CAPACITY_MESSAGE = + 'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + return { + promise: new Promise((res, rej) => ((resolve = res), (reject = rej))), + resolve, + reject + } +} + +function run( + path: string, + priority: 'exact' | 'scan', + task: () => Promise, + signal?: AbortSignal +): Promise { + return runWslTranscriptFsTask( + { operation: priority === 'exact' ? 'access' : 'readdir', path, priority, signal }, + task + ) +} + +describe('WSL transcript filesystem task scheduling', () => { + let warnSpy: MockInstance + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('does not block an exact probe behind a running scan on the same route', async () => { + const stalled = deferred() + const scanTask = vi.fn(() => stalled.promise) + const scan = run('\\\\wsl.localhost\\Ubuntu\\tree', 'scan', scanTask) + await vi.waitFor(() => expect(scanTask).toHaveBeenCalledOnce()) + + await expect( + run('\\\\wsl.localhost\\Ubuntu\\transcript.jsonl', 'exact', async () => 'exact') + ).resolves.toBe('exact') + stalled.resolve('scan') + await expect(scan).resolves.toBe('scan') + }) + + it('allows healthy distros and provider routes to bypass a stalled lane', async () => { + const stalled = deferred() + const ubuntuLocalhost = run('\\\\wsl.localhost\\Ubuntu\\home\\a', 'scan', () => stalled.promise) + const debian = vi.fn(async () => 'debian') + const ubuntuLegacy = vi.fn(async () => 'legacy') + + await expect(run('\\\\wsl.localhost\\Debian\\home\\a', 'exact', debian)).resolves.toBe('debian') + await expect(run('\\\\wsl$\\Ubuntu\\home\\a', 'exact', ubuntuLegacy)).resolves.toBe('legacy') + expect(debian).toHaveBeenCalledOnce() + expect(ubuntuLegacy).toHaveBeenCalledOnce() + + stalled.resolve('localhost') + await expect(ubuntuLocalhost).resolves.toBe('localhost') + }) + + it('runs an exact probe before older queued scan work', async () => { + const ubuntu = deferred() + const debian = deferred() + const occupied: string[] = [] + const started: string[] = [] + const first = run('\\\\wsl.localhost\\Ubuntu\\a', 'exact', () => { + occupied.push('ubuntu') + return ubuntu.promise + }) + const second = run('\\\\wsl.localhost\\Debian\\a', 'exact', () => { + occupied.push('debian') + return debian.promise + }) + await vi.waitFor(() => expect(occupied).toEqual(['ubuntu', 'debian'])) + + const scan = run('\\\\wsl.localhost\\Fedora\\tree', 'scan', async () => { + started.push('scan') + return 'scan' + }) + const exact = run('\\\\wsl.localhost\\Fedora\\file', 'exact', async () => { + started.push('exact') + return 'exact' + }) + + debian.resolve('debian') + await expect(exact).resolves.toBe('exact') + expect(started).toEqual(['exact', 'scan']) + await expect(scan).resolves.toBe('scan') + ubuntu.resolve('ubuntu') + await expect(Promise.all([first, second])).resolves.toEqual(['ubuntu', 'debian']) + }) + + it('drops abandoned queued work before it reaches the filesystem', async () => { + const ubuntu = deferred() + const debian = deferred() + const first = run('\\\\wsl.localhost\\Ubuntu\\a', 'scan', () => ubuntu.promise) + const second = run('\\\\wsl.localhost\\Debian\\a', 'scan', () => debian.promise) + const controller = new AbortController() + const abandonedTask = vi.fn(async () => 'unused') + const abandoned = run('\\\\wsl.localhost\\Fedora\\a', 'scan', abandonedTask, controller.signal) + + const reason = new Error('closed subscription') + controller.abort(reason) + await expect(abandoned).rejects.toBe(reason) + ubuntu.resolve('ubuntu') + debian.resolve('debian') + await Promise.all([first, second]) + expect(abandonedTask).not.toHaveBeenCalled() + }) + + it('shares only byte-identical requests', async () => { + const firstTask = vi.fn(async () => 'first') + const duplicateTask = vi.fn(async () => 'duplicate') + const first = run('\\\\wsl.localhost\\Ubuntu\\home\\a', 'exact', firstTask) + const duplicate = run('\\\\wsl.localhost\\Ubuntu\\home\\a', 'exact', duplicateTask) + + await expect(Promise.all([first, duplicate])).resolves.toEqual(['first', 'first']) + expect(firstTask).toHaveBeenCalledOnce() + expect(duplicateTask).not.toHaveBeenCalled() + }) + + it('keeps shared work needed by a remaining waiter', async () => { + const pending = deferred() + const task = vi.fn(() => pending.promise) + const firstController = new AbortController() + const secondController = new AbortController() + const first = run('\\\\wsl.localhost\\Ubuntu\\shared', 'exact', task, firstController.signal) + const second = run('\\\\wsl.localhost\\Ubuntu\\shared', 'exact', task, secondController.signal) + await vi.waitFor(() => expect(task).toHaveBeenCalledOnce()) + + const reason = new Error('first closed') + firstController.abort(reason) + await expect(first).rejects.toBe(reason) + pending.resolve('second') + await expect(second).resolves.toBe('second') + expect(task).toHaveBeenCalledOnce() + }) + + it('does not attach new callers to abandoned running work', async () => { + const stalled = deferred() + const firstTask = vi.fn(() => stalled.promise) + const firstController = new AbortController() + const first = run( + '\\\\wsl.localhost\\Ubuntu\\abandoned', + 'exact', + firstTask, + firstController.signal + ) + await vi.waitFor(() => expect(firstTask).toHaveBeenCalledOnce()) + + firstController.abort(new Error('first closed')) + await expect(first).rejects.toThrow('first closed') + const replacementWork = deferred() + const replacementTask = vi.fn(() => replacementWork.promise) + const replacement = run('\\\\wsl.localhost\\Ubuntu\\abandoned', 'exact', replacementTask) + + stalled.resolve('abandoned') + await vi.waitFor(() => expect(replacementTask).toHaveBeenCalledOnce()) + const duplicateTask = vi.fn(async () => 'duplicate') + const duplicate = run('\\\\wsl.localhost\\Ubuntu\\abandoned', 'exact', duplicateTask) + replacementWork.resolve('replacement') + + await expect(Promise.all([replacement, duplicate])).resolves.toEqual([ + 'replacement', + 'replacement' + ]) + expect(replacementTask).toHaveBeenCalledOnce() + expect(duplicateTask).not.toHaveBeenCalled() + }) + + it('does not conflate provider aliases or Linux path case', async () => { + const paths = [ + '\\\\wsl.localhost\\Ubuntu\\home\\a', + '\\\\wsl$\\Ubuntu\\home\\a', + '\\\\wsl.localhost\\Ubuntu\\mnt\\C\\a', + '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\a' + ] + const tasks = paths.map((path, index) => run(path, 'exact', async () => String(index))) + + await expect(Promise.all(tasks)).resolves.toEqual(['0', '1', '2', '3']) + }) + + it.each([ + ['exact', WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS], + ['scan', WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS] + ] as const)('rejects a stalled %s waiter at its deadline', async (priority, deadlineMs) => { + vi.useFakeTimers() + const stalled = deferred() + try { + const task = vi.fn(() => stalled.promise) + const pending = run(`\\\\wsl.localhost\\Deadline-${priority}\\a`, priority, task) + const rejected = expect(pending).rejects.toMatchObject({ + name: 'WslTranscriptFsError', + code: 'timeout', + message: SLOW_MESSAGE + }) + let settled = false + void pending.then( + () => (settled = true), + () => (settled = true) + ) + + await vi.advanceTimersByTimeAsync(0) + expect(task).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(deadlineMs - 1) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await rejected + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it.each([ + ['exact', WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS], + ['scan', WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS] + ] as const)('completes healthy %s work below its deadline', async (priority, deadlineMs) => { + vi.useFakeTimers() + const work = deferred() + try { + const pending = run(`\\\\wsl.localhost\\Healthy-${priority}\\a`, priority, () => work.promise) + + await vi.advanceTimersByTimeAsync(deadlineMs - 1) + work.resolve('healthy') + await expect(pending).resolves.toBe('healthy') + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(deadlineMs) + expect(vi.getTimerCount()).toBe(0) + } finally { + work.resolve('healthy') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('does not let one timed-out waiter settle a later shared waiter', async () => { + vi.useFakeTimers() + const work = deferred() + const task = vi.fn(() => work.promise) + try { + const path = '\\\\wsl.localhost\\Ubuntu\\staggered' + const first = run(path, 'exact', task) + const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(10_000) + const second = run(path, 'exact', task) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000) + await firstRejected + work.resolve('shared') + await expect(second).resolves.toBe('shared') + expect(task).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } finally { + work.resolve('shared') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('rejects newest work when the pending queue is full', async () => { + vi.useFakeTimers() + const ubuntu = deferred() + const debian = deferred() + const queuedController = new AbortController() + try { + const ubuntuTask = vi.fn(() => ubuntu.promise) + const debianTask = vi.fn(() => debian.promise) + const first = run('\\\\wsl.localhost\\Ubuntu\\occupied', 'exact', ubuntuTask) + const second = run('\\\\wsl.localhost\\Debian\\occupied', 'exact', debianTask) + await vi.advanceTimersByTimeAsync(0) + + const queued = Array.from({ length: WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS }, (_, index) => + run( + `\\\\wsl.localhost\\Queued-${index}\\a`, + 'exact', + async () => String(index), + index === 0 ? queuedController.signal : undefined + ) + ) + const overflowTask = vi.fn(async () => 'overflow') + await expect( + run('\\\\wsl.localhost\\Overflow\\a', 'exact', overflowTask) + ).rejects.toMatchObject({ code: 'capacity', message: CAPACITY_MESSAGE }) + expect(overflowTask).not.toHaveBeenCalled() + + const reason = new Error('queued caller closed') + queuedController.abort(reason) + await expect(queued[0]).rejects.toBe(reason) + const replacement = run('\\\\wsl.localhost\\Replacement\\a', 'exact', async () => 'new') + + ubuntu.resolve('ubuntu') + debian.resolve('debian') + await expect( + Promise.all([first, second, ...queued.slice(1), replacement]) + ).resolves.toHaveLength(WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS + 2) + expect(vi.getTimerCount()).toBe(0) + } finally { + ubuntu.resolve('ubuntu') + debian.resolve('debian') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('caps callers waiting on shared work', async () => { + vi.useFakeTimers() + const stalled = deferred() + const task = vi.fn(() => stalled.promise) + const firstController = new AbortController() + try { + const path = '\\\\wsl.localhost\\Ubuntu\\shared-cap' + const waiters = Array.from({ length: WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK }, (_, index) => + run(path, 'exact', task, index === 0 ? firstController.signal : undefined) + ) + await vi.advanceTimersByTimeAsync(0) + + await expect(run(path, 'exact', task)).rejects.toMatchObject({ + code: 'capacity', + message: CAPACITY_MESSAGE + }) + const reason = new Error('first waiter closed') + firstController.abort(reason) + await expect(waiters[0]).rejects.toBe(reason) + const replacement = run(path, 'exact', task) + stalled.resolve('shared') + await expect(Promise.all([...waiters.slice(1), replacement])).resolves.toEqual( + Array(WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK).fill('shared') + ) + expect(task).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } finally { + stalled.resolve('shared') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('fails new work fast after both running permits stall', async () => { + vi.useFakeTimers() + const ubuntu = deferred() + const debian = deferred() + try { + const first = run('\\\\wsl.localhost\\Ubuntu\\stalled', 'exact', () => ubuntu.promise) + const second = run('\\\\wsl.localhost\\Debian\\stalled', 'exact', () => debian.promise) + const firstRejected = expect(first).rejects.toBeInstanceOf(WslTranscriptFsError) + const secondRejected = expect(second).rejects.toBeInstanceOf(WslTranscriptFsError) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await Promise.all([firstRejected, secondRejected]) + const laterTask = vi.fn(async () => 'later') + await expect( + run('\\\\wsl.localhost\\Fedora\\later', 'exact', laterTask) + ).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE }) + expect(laterTask).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + ubuntu.resolve('ubuntu') + debian.resolve('debian') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('fails only work needing a stuck lane fast, keeping the other permit usable', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\lane-stuck', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await stuckRejected + + const sameLaneTask = vi.fn(async () => 'same-lane') + await expect( + run('\\\\wsl.localhost\\Ubuntu\\lane-stuck-sibling', 'exact', sameLaneTask) + ).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE }) + expect(sameLaneTask).not.toHaveBeenCalled() + await expect( + run('\\\\wsl.localhost\\Debian\\healthy-lane', 'exact', async () => 'debian') + ).resolves.toBe('debian') + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('fails scans and same-route probes fast while a scan is stuck', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuckScan = run('\\\\wsl.localhost\\Ubuntu\\stuck-tree', 'scan', () => stalled.promise) + const stuckRejected = expect(stuckScan).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS) + await stuckRejected + + const otherScanTask = vi.fn(async () => 'other-scan') + await expect( + run('\\\\wsl.localhost\\Debian\\other-tree', 'scan', otherScanTask) + ).rejects.toMatchObject({ code: 'unavailable' }) + expect(otherScanTask).not.toHaveBeenCalled() + // The whole Ubuntu mount is hung — an exact probe there would only burn + // the remaining permit on it. + await expect( + run('\\\\wsl.localhost\\Ubuntu\\live-probe', 'exact', async () => 'exact') + ).rejects.toMatchObject({ code: 'unavailable' }) + await expect( + run('\\\\wsl.localhost\\Debian\\live-probe', 'exact', async () => 'exact') + ).resolves.toBe('exact') + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('keeps a stuck exact probe from feeding a doomed scan the second permit', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\hung-file', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await stuckRejected + + const doomedScanTask = vi.fn(async () => 'doomed') + await expect( + run('\\\\wsl.localhost\\Ubuntu\\tree', 'scan', doomedScanTask) + ).rejects.toMatchObject({ code: 'unavailable' }) + expect(doomedScanTask).not.toHaveBeenCalled() + await expect( + run('\\\\wsl.localhost\\Debian\\tree', 'scan', async () => 'debian-scan') + ).resolves.toBe('debian-scan') + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('admits new work again once stuck work finally settles', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\recovering', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await stuckRejected + await expect( + run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'blocked') + ).rejects.toMatchObject({ code: 'unavailable' }) + + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + await expect( + run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'recovered') + ).resolves.toBe('recovered') + expect(vi.getTimerCount()).toBe(0) + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('refuses to join in-flight work already past its own deadline', async () => { + vi.useFakeTimers() + const work = deferred() + const task = vi.fn(() => work.promise) + try { + const path = '\\\\wsl.localhost\\Ubuntu\\join-stuck' + const first = run(path, 'exact', task) + const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(10_000) + const second = run(path, 'exact', task) + const secondRejected = expect(second).rejects.toMatchObject({ code: 'timeout' }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000) + await firstRejected + const joinTask = vi.fn(async () => 'join') + await expect(run(path, 'exact', joinTask)).rejects.toMatchObject({ code: 'unavailable' }) + expect(joinTask).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(10_000) + await secondRejected + } finally { + work.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('fails joins onto queued work fast when stuck I/O keeps it from running', async () => { + vi.useFakeTimers() + const stalled = deferred() + const queuedTask = vi.fn(async () => 'queued') + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\trap-hung', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(5_000) + // Queued behind the same route before the hang is detected. + const queuedPath = '\\\\wsl.localhost\\Ubuntu\\trap-queued' + const queued = run(queuedPath, 'exact', queuedTask) + const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'timeout' }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 5_000) + await stuckRejected + // Re-requests must not keep the doomed queued task alive with fresh + // deadlines — that would defeat the fail-fast for as long as I/O hangs. + await expect(run(queuedPath, 'exact', queuedTask)).rejects.toMatchObject({ + code: 'unavailable' + }) + await vi.advanceTimersByTimeAsync(5_000) + await queuedRejected + expect(queuedTask).not.toHaveBeenCalled() + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('does not feed a stuck route the other permit from the queue', async () => { + vi.useFakeTimers() + const scanWork = deferred() + const debianWork = deferred() + const ubuntuTask = vi.fn(async () => 'ubuntu') + try { + const scanU = run( + '\\\\wsl.localhost\\Ubuntu\\queued-route-tree', + 'scan', + () => scanWork.promise + ) + const exactD = run( + '\\\\wsl.localhost\\Debian\\queued-route-d', + 'exact', + () => debianWork.promise + ) + const scanRejected = expect(scanU).rejects.toMatchObject({ code: 'timeout' }) + const exactDRejected = expect(exactD).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(35_000) + await exactDRejected + + // Queued before Ubuntu's hang is detected at the 60s scan deadline. + const queuedU = run('\\\\wsl.localhost\\Ubuntu\\queued-route-file', 'exact', ubuntuTask) + const queuedURejected = expect(queuedU).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(25_000) + await scanRejected + + debianWork.resolve('debian') + await vi.advanceTimersByTimeAsync(0) + expect(ubuntuTask).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(5_000) + await queuedURejected + expect(ubuntuTask).not.toHaveBeenCalled() + } finally { + scanWork.resolve('late') + debianWork.resolve('debian') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('warns once per task that outlives its deadline while running', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\logged', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS * 3) + await stuckRejected + expect(warnSpy).toHaveBeenCalledOnce() + expect(warnSpy.mock.calls[0][0]).toContain('exact') + expect(warnSpy.mock.calls[0][0]).toContain('logged') + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('does not mark a scan permit stuck before the scan deadline', async () => { + vi.useFakeTimers() + const exactWork = deferred() + const scanWork = deferred() + try { + const exact = run( + '\\\\wsl.localhost\\Ubuntu\\stalled-exact', + 'exact', + () => exactWork.promise + ) + const scanTask = vi.fn(() => scanWork.promise) + const scanPath = '\\\\wsl.localhost\\Debian\\healthy-scan' + const scan = run(scanPath, 'scan', scanTask) + const exactRejected = expect(exact).rejects.toMatchObject({ code: 'timeout' }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await exactRejected + const duplicateTask = vi.fn(async () => 'duplicate') + const duplicate = run(scanPath, 'scan', duplicateTask) + + scanWork.resolve('scan') + await expect(Promise.all([scan, duplicate])).resolves.toEqual(['scan', 'scan']) + expect(scanTask).toHaveBeenCalledOnce() + expect(duplicateTask).not.toHaveBeenCalled() + } finally { + exactWork.resolve('late') + scanWork.resolve('scan') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-gate.ts b/src/main/native-chat/wsl-transcript-fs-gate.ts new file mode 100644 index 00000000000..52b4b22f81b --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-gate.ts @@ -0,0 +1,339 @@ +import { parseWslUncPath } from '../../shared/wsl-paths' + +const MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS = 2 +export const WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS = 30_000 +export const WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS = 60_000 +// Burst bounds keep polling fan-out from growing retained tasks or callers indefinitely. +export const WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS = 64 +export const WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK = 64 +const WSL_TRANSCRIPT_FS_SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' +const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE = + 'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.' + +export type WslTranscriptFsFailureCode = 'timeout' | 'capacity' | 'unavailable' + +export class WslTranscriptFsError extends Error { + constructor( + readonly code: WslTranscriptFsFailureCode, + message: string + ) { + super(message) + this.name = 'WslTranscriptFsError' + } +} + +/** Narrow a caught error to a gate refusal, rethrowing anything else. */ +export function wslTranscriptFsRefusal(error: unknown): WslTranscriptFsError { + if (error instanceof WslTranscriptFsError) { + return error + } + throw error +} + +export type WslTranscriptFsTaskPriority = 'exact' | 'scan' + +type TaskWaiter = { + resolve: (value: T) => void + reject: (error: unknown) => void + signal?: AbortSignal + onAbort?: () => void + timeout?: ReturnType +} + +type ScheduledTask = { + key: string + laneKey: string + route: string + priority: WslTranscriptFsTaskPriority + operation: (signal: AbortSignal) => Promise + controller: AbortController + waiters: Set> + state: 'queued' | 'running' | 'settled' + // Set by the deadline timer, sharing the waiters' monotonic clock — wall time + // would misjudge stuckness across laptop sleep or NTP steps. + stuck: boolean + stuckTimer?: ReturnType +} + +type UnknownScheduledTask = ScheduledTask + +let activeScanCount = 0 +const activeLaneKeys = new Set() +const queuedTasks: UnknownScheduledTask[] = [] +const inFlightTasks = new Map() +const activeTasks = new Set() + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new Error('WSL transcript filesystem task aborted') +} + +// wsl$ and wsl.localhost spellings of one distro stay distinct routes on +// purpose (provider spelling can change behavior), so a stuck spelling never +// fast-fails its twin — the twin is bounded by its own waiter deadlines. +function routeKey(path: string): string { + const normalized = path.replace(/\\/g, '/') + const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i) + if (match) { + return `${match[1].toLowerCase()}/${match[2].trim().toLowerCase()}` + } + return parseWslUncPath(path)?.distro.trim().toLowerCase() ?? path +} + +function removeQueuedTask(task: UnknownScheduledTask): void { + const index = queuedTasks.indexOf(task) + if (index !== -1) { + queuedTasks.splice(index, 1) + } +} + +function clearTask(task: UnknownScheduledTask): void { + if (inFlightTasks.get(task.key) === task) { + inFlightTasks.delete(task.key) + } +} + +function abandonTaskIfUnused(task: UnknownScheduledTask, reason?: unknown): void { + if (task.waiters.size > 0 || task.state === 'settled') { + return + } + task.controller.abort(reason) + // Running I/O keeps its permit; new callers need a reusable controller. + clearTask(task) + if (task.state === 'queued') { + task.state = 'settled' + removeQueuedTask(task) + pumpTasks() + } +} + +function removeWaiter(task: ScheduledTask, waiter: TaskWaiter): boolean { + if (!task.waiters.delete(waiter)) { + return false + } + if (waiter.signal && waiter.onAbort) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + } + if (waiter.timeout) { + clearTimeout(waiter.timeout) + } + return true +} + +function timeoutMs(priority: WslTranscriptFsTaskPriority): number { + return priority === 'exact' + ? WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + : WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS +} + +function timeoutError(): WslTranscriptFsError { + return new WslTranscriptFsError('timeout', WSL_TRANSCRIPT_FS_SLOW_MESSAGE) +} + +function capacityError(): WslTranscriptFsError { + return new WslTranscriptFsError('capacity', WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE) +} + +function unavailableError(): WslTranscriptFsError { + return new WslTranscriptFsError('unavailable', WSL_TRANSCRIPT_FS_SLOW_MESSAGE) +} + +/** + * The stuck running task that would doom a new task's admission: one on the + * same route (the whole distro mount is hung, whatever the priority — spending + * the other permit on it escalates one bad distro into a global outage), one + * holding the single scan slot, or (with every permit stuck) any permit. + */ +function stuckBlocker( + route: string, + priority: WslTranscriptFsTaskPriority +): UnknownScheduledTask | undefined { + const stuck = [...activeTasks].filter((task) => task.stuck) + if (stuck.length === MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) { + return stuck[0] + } + return stuck.find( + (task) => task.route === route || (priority === 'scan' && task.priority === 'scan') + ) +} + +// Caller-abort pre-checks live in runWslTranscriptFsTask; nothing here yields +// before the waiter is attached, so no aborted-signal recheck is needed. +function attachWaiter( + task: ScheduledTask, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + if (task.waiters.size >= WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK) { + return Promise.reject(capacityError()) + } + return new Promise((resolve, reject) => { + const waiter: TaskWaiter = { resolve, reject, signal } + task.waiters.add(waiter) + if (signal) { + waiter.onAbort = () => { + const reason = abortReason(signal) + if (!removeWaiter(task, waiter)) { + return + } + reject(reason) + abandonTaskIfUnused(task as UnknownScheduledTask, reason) + } + signal.addEventListener('abort', waiter.onAbort, { once: true }) + } + // UNC/9P calls cannot be aborted; two blocked calls can retain both slots until settling or + // Orca restarts, while caller and queue retention remains bounded. + waiter.timeout = setTimeout(() => { + const error = timeoutError() + if (!removeWaiter(task, waiter)) { + return + } + reject(error) + abandonTaskIfUnused(task as UnknownScheduledTask, error) + }, timeoutMs(priority)) + waiter.timeout.unref?.() + }) +} + +function settleTask(task: ScheduledTask, result: { value: T } | { error: unknown }): void { + if (task.state !== 'running') { + return + } + task.state = 'settled' + if (task.priority === 'scan') { + activeScanCount -= 1 + } + activeLaneKeys.delete(task.laneKey) + activeTasks.delete(task as UnknownScheduledTask) + clearTimeout(task.stuckTimer) + clearTask(task as UnknownScheduledTask) + for (const waiter of task.waiters) { + removeWaiter(task, waiter) + if ('value' in result) { + waiter.resolve(result.value) + } else { + waiter.reject(result.error) + } + } + task.waiters.clear() + pumpTasks() +} + +function nextTaskIndex(): number { + // Tasks queued before their route hung must not be fed the other permit; + // their waiters drain by deadline while healthy routes use the slot. + const stuckRoutes = new Set( + [...activeTasks].filter((task) => task.stuck).map((task) => task.route) + ) + for (const priority of ['exact', 'scan'] as const) { + // Why: keep one libuv slot available for a live transcript probe. + if (priority === 'scan' && activeScanCount > 0) { + continue + } + const index = queuedTasks.findIndex( + (task) => + task.priority === priority && + !activeLaneKeys.has(task.laneKey) && + !stuckRoutes.has(task.route) + ) + if (index !== -1) { + return index + } + } + return -1 +} + +function pumpTasks(): void { + while (activeTasks.size < MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) { + const index = nextTaskIndex() + if (index === -1) { + return + } + const task = queuedTasks.splice(index, 1)[0] + if (!task || task.state !== 'queued') { + continue + } + task.state = 'running' + if (task.priority === 'scan') { + activeScanCount += 1 + } + activeLaneKeys.add(task.laneKey) + activeTasks.add(task) + // Anchored at run start: queue wait says nothing about the I/O itself, so + // the fail-fast may lag admission by at most one deadline period. + task.stuckTimer = setTimeout(() => { + task.stuck = true + console.warn( + `[wsl-transcript-fs-gate] ${task.priority} filesystem task still running after ` + + `${timeoutMs(task.priority)}ms and holding a permit: ${task.key}` + ) + }, timeoutMs(task.priority)) + task.stuckTimer.unref?.() + void Promise.resolve() + .then(() => { + task.controller.signal.throwIfAborted() + return task.operation(task.controller.signal) + }) + .then( + (value) => settleTask(task, { value }), + (error: unknown) => settleTask(task, { error }) + ) + } +} + +/** Bound 9P work without letting scans delay exact transcript probes. */ +export function runWslTranscriptFsTask( + options: { + operation: 'access' | 'readdir' + path: string + priority: WslTranscriptFsTaskPriority + signal?: AbortSignal + }, + task: (signal: AbortSignal) => Promise +): Promise { + if (options.signal?.aborted) { + return Promise.reject(abortReason(options.signal)) + } + // Why: route spelling and Linux path case can change provider behavior, so + // only byte-identical filesystem requests are safe to share. + const key = JSON.stringify([options.operation, options.path]) + const existing = inFlightTasks.get(key) as ScheduledTask | undefined + if (existing) { + // A stuck target — or a queued target that stuck I/O keeps from ever + // running — dooms every joiner to a slow timeout, and each fresh joiner + // would keep the queued task alive past its own abandonment. Fail fast. + if ( + existing.stuck || + (existing.state === 'queued' && stuckBlocker(existing.route, existing.priority)) + ) { + return Promise.reject(unavailableError()) + } + return attachWaiter(existing, options.priority, options.signal) + } + const route = routeKey(options.path) + // Fail fast when the permit, route, or scan slot this task needs is held by + // stuck I/O — queueing would only burn the caller's full deadline. + if (stuckBlocker(route, options.priority)) { + return Promise.reject(unavailableError()) + } + if (queuedTasks.length >= WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS) { + return Promise.reject(capacityError()) + } + + const scheduled: ScheduledTask = { + key, + laneKey: `${route}:${options.priority}`, + route, + priority: options.priority, + operation: task, + controller: new AbortController(), + waiters: new Set(), + state: 'queued', + stuck: false + } + inFlightTasks.set(key, scheduled as UnknownScheduledTask) + const result = attachWaiter(scheduled, options.priority, options.signal) + queuedTasks.push(scheduled as UnknownScheduledTask) + queueMicrotask(pumpTasks) + return result +} diff --git a/src/main/network/macos-tailscale-dns-diagnostic.ts b/src/main/network/macos-tailscale-dns-diagnostic.ts index 5ec5501525e..a4dc948c0bd 100644 --- a/src/main/network/macos-tailscale-dns-diagnostic.ts +++ b/src/main/network/macos-tailscale-dns-diagnostic.ts @@ -19,7 +19,7 @@ const NETWORK_LOOKUP_FAILURE_RE = function globalDnsSection(scutilOutput: string): string { const scopedStart = scutilOutput.indexOf('\nDNS configuration (for scoped queries)') - return scopedStart >= 0 ? scutilOutput.slice(0, scopedStart) : scutilOutput + return scopedStart !== -1 ? scutilOutput.slice(0, scopedStart) : scutilOutput } export function parseMacTailscaleDnsDiagnostic(scutilOutput: string): DnsDiagnostic | null { diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index 347e1025893..3c6644a7d43 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -35,7 +35,7 @@ function makeSpan(overrides: Record = {}): Record): RedactableSpan { kind: 'internal', startTimeUnixNano: '1000', endTimeUnixNano: '2000', - durationMs: 1.0, + durationMs: 1, attributes: {}, events: [], exit: { _tag: 'Success' }, diff --git a/src/main/observability/redactor.ts b/src/main/observability/redactor.ts index cd5570f8577..da8feb0a8b0 100644 --- a/src/main/observability/redactor.ts +++ b/src/main/observability/redactor.ts @@ -123,7 +123,7 @@ export function redactString(input: string): string { export function redactValue( value: unknown, mode: RedactorMode = 'client', - seen: WeakSet = new WeakSet() + seen = new WeakSet() ): unknown { if (value === null || value === undefined) { return value diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts index 6f3759efa7f..d565327fd18 100644 --- a/src/main/opencode-usage/store.test.ts +++ b/src/main/opencode-usage/store.test.ts @@ -1,24 +1,15 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OpenCodeUsageDailyAggregate, - OpenCodeUsagePersistedDatabase, OpenCodeUsagePersistedState, OpenCodeUsageSession } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -27,29 +18,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanOpenCodeUsageDatabases: vi.fn() })) @@ -57,24 +25,7 @@ vi.mock('./scanner', () => ({ import { OpenCodeUsageStore, initOpenCodeUsagePath, normalizePersistedState } from './store' import { scanOpenCodeUsageDatabases } from './scanner' -type ScanResult = { - processedDatabases: OpenCodeUsagePersistedDatabase[] - sessions: OpenCodeUsageSession[] - dailyAggregates: OpenCodeUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void -} { - let resolve!: (value: T) => void - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve - }) - return { promise, resolve } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedDatabases: [], sessions: [], @@ -101,9 +52,8 @@ function getDefaultState(): OpenCodeUsagePersistedState { function createStoreWithState(state: Partial): OpenCodeUsageStore { const store = new OpenCodeUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: OpenCodeUsagePersistedState }).state = { ...getDefaultState(), @@ -212,11 +162,6 @@ describe('OpenCodeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-store-')) getPathMock.mockReturnValue(tempUserData) initOpenCodeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanOpenCodeUsageDatabases).mockReset() vi.mocked(scanOpenCodeUsageDatabases).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -228,7 +173,7 @@ describe('OpenCodeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one full-cache write', async () => { + it('adapts OpenCode scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ scanState: { enabled: true, @@ -240,89 +185,9 @@ describe('OpenCodeUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole multi-MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8') + expect(scanOpenCodeUsageDatabases).toHaveBeenCalledWith([], []) expect(persistedJson).toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanOpenCodeUsageDatabases).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: OpenCodeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-opencode-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => { diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index c85fa519ba5..b87357f9b0a 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -1,27 +1,23 @@ -/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ +/* eslint-disable max-lines -- Why: OpenCode cost normalization and range, scope, and breakdown policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { OpenCodeUsageBreakdownKind, OpenCodeUsageBreakdownRow, OpenCodeUsageDailyPoint, OpenCodeUsageRange, - OpenCodeUsageScanState, OpenCodeUsageScope, OpenCodeUsageSessionRow, OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../../shared/opencode-usage-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { OPENCODE_USAGE_SCHEMA_VERSION, openCodeUsageProvider } from './opencode-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 let _openCodeUsageFile: string | null = null @@ -70,47 +66,6 @@ function getOpenCodeUsageFile(): string { return _openCodeUsageFile } -function getRangeCutoff(range: OpenCodeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - function addCost(left: number | null, right: number | null): number | null { if (left === null && right === null) { return null @@ -148,62 +103,22 @@ function normalizeSessionCost( } } -export class OpenCodeUsageStore { - private state: OpenCodeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the multi-MB usage JSON must not block the Electron main thread; the writer serializes - // writes and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[opencode-usage]', getOpenCodeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): OpenCodeUsagePersistedState { - try { - const usageFile = getOpenCodeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): OpenCodeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< + 'processedDatabases', + OpenCodeUsagePersistedState, + 'hasAnyOpenCodeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[opencode-usage]', + resolveCacheFile: getOpenCodeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedDatabases', + dataPresenceKey: 'hasAnyOpenCodeData', + jsonIndent: 2, + scan: openCodeUsageProvider.scan + }) } getSnapshot( @@ -221,63 +136,6 @@ export class OpenCodeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole cache before a single result - // changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await openCodeUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint - ? this.state.processedDatabases - : [] - ) - this.state.processedDatabases = result.processedDatabases - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary( scope: OpenCodeUsageScope, range: OpenCodeUsageRange @@ -479,7 +337,7 @@ export class OpenCodeUsageStore { scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): OpenCodeUsageDailyAggregate[] { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((row) => { if (scope === 'orca' && !row.worktreeId) { return false @@ -492,13 +350,13 @@ export class OpenCodeUsageStore { } private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { if (scope === 'orca' && !session.primaryWorktreeId) { return false } if (cutoff) { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day || day < cutoff) { return false } @@ -506,9 +364,4 @@ export class OpenCodeUsageStore { return true }) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) - } } diff --git a/src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts b/src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts new file mode 100644 index 00000000000..0624124c385 --- /dev/null +++ b/src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts @@ -0,0 +1,244 @@ +import { mkdirSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcaProfileCloudSummary } from '../../shared/orca-profiles' +import type * as ArtifactCreateIntentStore from '../artifacts/artifact-create-intent-store' +import type * as ProfileArtifactCloudCleanup from './profile-artifact-cloud-cleanup' +import type * as ProfileIndexStore from './profile-index-store' + +vi.mock('../artifacts/artifact-create-intent-store', async () => { + const actual = await vi.importActual( + '../artifacts/artifact-create-intent-store' + ) + return { ...actual, clearArtifactCreateIntents: vi.fn(actual.clearArtifactCreateIntents) } +}) + +vi.mock('./profile-index-store', async () => { + const actual = await vi.importActual('./profile-index-store') + return { ...actual, writeProfileIndex: vi.fn(actual.writeProfileIndex) } +}) + +vi.mock('./profile-artifact-cloud-cleanup', async () => { + const actual = await vi.importActual( + './profile-artifact-cloud-cleanup' + ) + return { + ...actual, + commitArtifactCloudCleanup: vi.fn(actual.commitArtifactCloudCleanup) + } +}) + +import { + clearArtifactCreateIntents, + getArtifactCreateIntent, + getOrCreateArtifactCreateIntent +} from '../artifacts/artifact-create-intent-store' +import type { ArtifactShareScope } from '../artifacts/artifact-share-record-store' +import { + artifactCloudCleanupNeedsCommit, + commitArtifactCloudCleanup, + prepareArtifactCloudCleanup, + prepareArtifactCloudUse +} from './profile-artifact-cloud-cleanup' +import { linkOrcaProfileToCloud, unlinkOrcaProfileFromCloud } from './profile-cloud-index' +import { + getOrcaProfileIndexPath, + getOrcaProfileDirectory, + loadOrCreateProfileIndex, + readProfileIndex, + writeProfileIndex +} from './profile-index-store' + +const createdPaths: string[] = [] +const profileId = 'local-default' + +afterEach(async () => { + vi.clearAllMocks() + await Promise.all( + createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe('profile artifact cloud cleanup', () => { + it('preserves recovery state when the profile write fails', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + vi.mocked(writeProfileIndex).mockImplementationOnce(() => { + throw new Error('profile write failed') + }) + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow( + 'profile write failed' + ) + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull() + + linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath) + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('retries cleanup after the profile transition commits', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + vi.mocked(clearArtifactCreateIntents).mockImplementationOnce(() => { + throw new Error('cleanup failed') + }) + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow( + 'cleanup failed' + ) + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull() + + linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath) + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('reconciles an interrupted transition before linking again', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + interruptNextCleanupCommit() + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow( + 'cleanup commit interrupted' + ) + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + vi.mocked(writeProfileIndex).mockClear() + vi.mocked(clearArtifactCreateIntents).mockImplementationOnce(() => { + throw new Error('cleanup failed') + }) + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-c'), userDataPath)).toThrow( + 'cleanup failed' + ) + expect(writeProfileIndex).not.toHaveBeenCalled() + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull() + + linkOrcaProfileToCloud(profileId, cloud('org-c'), userDataPath) + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-c') + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('reconciles an interrupted transition before unlinking', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + interruptNextCleanupCommit() + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow( + 'cleanup commit interrupted' + ) + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + vi.mocked(writeProfileIndex).mockClear() + vi.mocked(clearArtifactCreateIntents).mockImplementationOnce(() => { + throw new Error('cleanup failed') + }) + + expect(() => unlinkOrcaProfileFromCloud(profileId, userDataPath)).toThrow('cleanup failed') + expect(writeProfileIndex).not.toHaveBeenCalled() + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull() + + unlinkOrcaProfileFromCloud(profileId, userDataPath) + expect(currentCloud(userDataPath)).toBeUndefined() + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('preserves an orphaned local marker for an unknown profile', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const orphanProfileId = 'missing-profile' + const scope = shareScope('org-a') + mkdirSync(getOrcaProfileDirectory(orphanProfileId, userDataPath), { recursive: true }) + createIntent(userDataPath, scope, orphanProfileId) + prepareArtifactCloudCleanup(orphanProfileId, userDataPath, undefined) + + const transitions = [ + () => linkOrcaProfileToCloud(orphanProfileId, cloud('org-b'), userDataPath), + () => unlinkOrcaProfileFromCloud(orphanProfileId, userDataPath) + ] + for (const transition of transitions) { + expect(transition).toThrow('unknown_orca_profile') + expect(artifactCloudCleanupNeedsCommit(orphanProfileId, userDataPath, undefined)).toBe(true) + expect( + getArtifactCreateIntent(orphanProfileId, userDataPath, '/report.md', scope) + ).not.toBeNull() + } + }) + + it('cleans old recovery state when the active organization changes', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + + linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath) + + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('blocks artifact use until a visible transition is durably committed', async () => { + const cloudSummary = cloud('org-a') + const userDataPath = await createLinkedProfile(cloudSummary) + prepareArtifactCloudCleanup(profileId, userDataPath, cloudSummary) + + expect(() => + prepareArtifactCloudUse({ id: profileId, cloud: cloudSummary }, userDataPath) + ).toThrow(/transition must be retried/) + }) +}) + +function cloud(activeOrgId: string): OrcaProfileCloudSummary { + return { + cloudProfileId: 'cloud-profile-a', + userId: 'user-a', + email: 'user@example.com', + activeOrgId, + linkedAt: 1 + } +} + +function shareScope(cloudOrganizationId: string): ArtifactShareScope { + return { + cloudUserId: 'user-a', + cloudProfileId: 'cloud-profile-a', + cloudOrganizationId, + apiOrigin: 'https://share.onorca.dev' + } +} + +async function createLinkedProfile(cloudSummary: OrcaProfileCloudSummary): Promise { + const userDataPath = await mkdtemp(join(tmpdir(), 'orca-profile-artifact-cleanup-')) + createdPaths.push(userDataPath) + loadOrCreateProfileIndex(userDataPath) + linkOrcaProfileToCloud(profileId, cloudSummary, userDataPath) + vi.clearAllMocks() + return userDataPath +} + +function createIntent( + userDataPath: string, + scope: ArtifactShareScope, + targetProfileId = profileId +): void { + getOrCreateArtifactCreateIntent(targetProfileId, userDataPath, '/report.md', scope, 'key-a', { + content: '# report', + contentType: 'text/markdown', + fileName: 'report.md' + }) +} + +function currentCloud(userDataPath: string): OrcaProfileCloudSummary | undefined { + return readProfileIndex(getOrcaProfileIndexPath(userDataPath))?.profiles.find( + (profile) => profile.id === profileId + )?.cloud +} + +function interruptNextCleanupCommit(): void { + vi.mocked(commitArtifactCloudCleanup).mockImplementationOnce(() => { + throw new Error('cleanup commit interrupted') + }) +} diff --git a/src/main/orca-profiles/profile-artifact-cloud-cleanup.ts b/src/main/orca-profiles/profile-artifact-cloud-cleanup.ts new file mode 100644 index 00000000000..88e90471914 --- /dev/null +++ b/src/main/orca-profiles/profile-artifact-cloud-cleanup.ts @@ -0,0 +1,131 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import type { OrcaProfileCloudSummary, OrcaProfileSummary } from '../../shared/orca-profiles' +import { bestEffortFsyncDirectorySync, writeDurableSecureJsonFile } from '../../shared/secure-file' +import { clearArtifactCreateIntents } from '../artifacts/artifact-create-intent-store' +import { clearArtifactShareRecords } from '../artifacts/artifact-share-record-store' +import { getOrcaProfileDirectory } from './profile-storage-paths' + +type ArtifactCloudCleanupMarker = { + version: 1 + phase: 'prepared' | 'committed' + targetIdentity: string +} + +function cleanupMarkerPath(profileId: string, userDataPath: string): string { + return join(getOrcaProfileDirectory(profileId, userDataPath), 'artifact-cloud-cleanup.json') +} + +export function artifactCloudIdentity(cloud: OrcaProfileCloudSummary | undefined): string { + return cloud + ? JSON.stringify([cloud.userId, cloud.cloudProfileId, cloud.activeOrgId ?? '']) + : 'local' +} + +export function prepareArtifactCloudCleanup( + profileId: string, + userDataPath: string, + targetCloud: OrcaProfileCloudSummary | undefined +): void { + writeDurableSecureJsonFile(cleanupMarkerPath(profileId, userDataPath), { + version: 1, + phase: 'prepared', + targetIdentity: artifactCloudIdentity(targetCloud) + } satisfies ArtifactCloudCleanupMarker) +} + +function readCleanupMarker( + profileId: string, + userDataPath: string +): ArtifactCloudCleanupMarker | null { + const path = cleanupMarkerPath(profileId, userDataPath) + if (!existsSync(path)) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error('Artifact cloud cleanup marker could not be read safely.', { cause: error }) + } + if ( + !parsed || + typeof parsed !== 'object' || + (parsed as Partial).version !== 1 || + !['prepared', 'committed'].includes( + (parsed as Partial).phase ?? '' + ) || + typeof (parsed as Partial).targetIdentity !== 'string' + ) { + throw new Error('Artifact cloud cleanup marker has an unsupported format.') + } + return parsed as ArtifactCloudCleanupMarker +} + +export function artifactCloudCleanupNeedsCommit( + profileId: string, + userDataPath: string, + targetCloud: OrcaProfileCloudSummary | undefined +): boolean { + const marker = readCleanupMarker(profileId, userDataPath) + return ( + marker?.phase === 'prepared' && marker.targetIdentity === artifactCloudIdentity(targetCloud) + ) +} + +export function commitArtifactCloudCleanup( + profileId: string, + userDataPath: string, + targetCloud: OrcaProfileCloudSummary | undefined +): void { + const targetIdentity = artifactCloudIdentity(targetCloud) + const marker = readCleanupMarker(profileId, userDataPath) + if (marker?.phase !== 'prepared' || marker.targetIdentity !== targetIdentity) { + throw new Error('Artifact cloud cleanup marker does not match the profile transition.') + } + writeDurableSecureJsonFile(cleanupMarkerPath(profileId, userDataPath), { + version: 1, + phase: 'committed', + targetIdentity + } satisfies ArtifactCloudCleanupMarker) +} + +export function completeArtifactCloudCleanupIfCommitted( + profileId: string, + userDataPath: string, + currentCloud: OrcaProfileCloudSummary | undefined +): void { + const marker = readCleanupMarker(profileId, userDataPath) + if ( + marker?.phase !== 'committed' || + marker.targetIdentity !== artifactCloudIdentity(currentCloud) + ) { + return + } + clearArtifactCreateIntents(profileId, userDataPath) + clearArtifactShareRecords(profileId, userDataPath) + rmSync(cleanupMarkerPath(profileId, userDataPath), { force: true }) + bestEffortFsyncDirectorySync(getOrcaProfileDirectory(profileId, userDataPath)) +} + +function assertArtifactCloudCleanupReady( + profileId: string, + userDataPath: string, + currentCloud: OrcaProfileCloudSummary | undefined +): void { + const marker = readCleanupMarker(profileId, userDataPath) + if ( + marker?.phase === 'prepared' && + marker.targetIdentity === artifactCloudIdentity(currentCloud) + ) { + throw new Error('The Orca profile transition must be retried before publishing artifacts.') + } +} + +export function prepareArtifactCloudUse( + profile: Pick, + userDataPath: string +): void { + completeArtifactCloudCleanupIfCommitted(profile.id, userDataPath, profile.cloud) + assertArtifactCloudCleanupReady(profile.id, userDataPath, profile.cloud) +} diff --git a/src/main/orca-profiles/profile-cloud-index.ts b/src/main/orca-profiles/profile-cloud-index.ts index 7186ddd2a63..ccf7e70adb9 100644 --- a/src/main/orca-profiles/profile-cloud-index.ts +++ b/src/main/orca-profiles/profile-cloud-index.ts @@ -11,7 +11,12 @@ import { loadOrCreateProfileIndex, writeProfileIndex } from './profile-index-store' -import { clearArtifactShareRecords } from '../artifacts/artifact-share-record-store' +import { + artifactCloudCleanupNeedsCommit, + commitArtifactCloudCleanup, + completeArtifactCloudCleanupIfCommitted, + prepareArtifactCloudCleanup +} from './profile-artifact-cloud-cleanup' export type CreateCloudLinkedOrcaProfileRecordResult = OrcaProfileListState & { profile: OrcaProfileSummary @@ -50,6 +55,19 @@ function toLocalProfile(profile: OrcaProfileSummary, now: number): OrcaProfileSu } } +function reconcileCurrentArtifactCloudCleanup( + profileId: string, + userDataPath: string, + currentCloud: OrcaProfileCloudSummary | undefined +): void { + completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, currentCloud) + if (!artifactCloudCleanupNeedsCommit(profileId, userDataPath, currentCloud)) { + return + } + commitArtifactCloudCleanup(profileId, userDataPath, currentCloud) + completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, currentCloud) +} + export function createCloudLinkedOrcaProfileRecord( cloud: OrcaProfileCloudSummary, args: { name?: string }, @@ -92,6 +110,12 @@ export function linkOrcaProfileToCloud( userDataPath: string ): OrcaProfileListState { const index = loadOrCreateProfileIndex(userDataPath) + const currentProfile = index.profiles.find((profile) => profile.id === profileId) + if (!currentProfile) { + throw new Error('unknown_orca_profile') + } + reconcileCurrentArtifactCloudCleanup(profileId, userDataPath, currentProfile.cloud) + const cleanupNeedsCommit = artifactCloudCleanupNeedsCommit(profileId, userDataPath, cloud) const now = Date.now() let found = false let cloudIdentityChanged = false @@ -103,21 +127,26 @@ export function linkOrcaProfileToCloud( cloudIdentityChanged = Boolean( profile.cloud && (profile.cloud.userId !== cloud.userId || - profile.cloud.cloudProfileId !== cloud.cloudProfileId) + profile.cloud.cloudProfileId !== cloud.cloudProfileId || + (profile.cloud.activeOrgId ?? '') !== (cloud.activeOrgId ?? '')) ) return toCloudLinkedProfile(profile, cloud, now) }) if (!found) { throw new Error('unknown_orca_profile') } - if (cloudIdentityChanged) { - clearArtifactShareRecords(profileId, userDataPath) + if (cloudIdentityChanged || cleanupNeedsCommit) { + prepareArtifactCloudCleanup(profileId, userDataPath, cloud) } const nextIndex = { ...index, profiles } writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + if (cloudIdentityChanged || cleanupNeedsCommit) { + commitArtifactCloudCleanup(profileId, userDataPath, cloud) + completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, cloud) + } return { activeProfileId: nextIndex.activeProfileId, profiles: nextIndex.profiles @@ -129,6 +158,11 @@ export function unlinkOrcaProfileFromCloud( userDataPath: string ): OrcaProfileListState { const index = loadOrCreateProfileIndex(userDataPath) + const currentProfile = index.profiles.find((profile) => profile.id === profileId) + if (!currentProfile) { + throw new Error('unknown_orca_profile') + } + reconcileCurrentArtifactCloudCleanup(profileId, userDataPath, currentProfile.cloud) const now = Date.now() let found = false const profiles = index.profiles.map((profile) => { @@ -141,12 +175,14 @@ export function unlinkOrcaProfileFromCloud( if (!found) { throw new Error('unknown_orca_profile') } - clearArtifactShareRecords(profileId, userDataPath) + prepareArtifactCloudCleanup(profileId, userDataPath, undefined) const nextIndex = { ...index, profiles } writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + commitArtifactCloudCleanup(profileId, userDataPath, undefined) + completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, undefined) return { activeProfileId: nextIndex.activeProfileId, profiles: nextIndex.profiles diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts index 903e7a85ff8..8c1b4490f45 100644 --- a/src/main/orca-profiles/profile-index-store.ts +++ b/src/main/orca-profiles/profile-index-store.ts @@ -8,6 +8,7 @@ import { } from 'node:fs' import { randomUUID } from 'node:crypto' import { dirname } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file' import type { GlobalSettings } from '../../shared/types' import { createDefaultLocalOrcaProfile, @@ -130,7 +131,9 @@ export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): v } const tmpPath = `${indexPath}.tmp` writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8') + fsyncFileSync(tmpPath) renameSync(tmpPath, indexPath) + bestEffortFsyncDirectorySync(dirname(indexPath)) } function copyIfPresent(source: string, target: string): void { diff --git a/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts new file mode 100644 index 00000000000..4c04ab22d01 --- /dev/null +++ b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts @@ -0,0 +1,92 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { normalizeFeatureInteractions } from '../shared/feature-interactions' +import type { PersistedState } from '../shared/types' + +vi.mock('electron', () => ({ + app: { getPath: () => tmpdir() }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString('utf8') + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +import { Store } from './persistence' + +const INTERACTIONS = 200 +const tempDirs: string[] = [] + +function createStore(name: string): { dataFile: string; store: Store } { + const dir = mkdtempSync(join(tmpdir(), `orca-ui-broadcast-${name}-`)) + tempDirs.push(dir) + const dataFile = join(dir, 'orca-data.json') + return { dataFile, store: new Store({ dataFile }) } +} + +describe('feature interaction UI broadcast benchmark', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('reduces repeated interaction recording to one material UI broadcast', () => { + const { store: legacyStore } = createStore('legacy') + const { dataFile, store: optimizedStore } = createStore('optimized') + let legacyBroadcasts = 0 + let optimizedBroadcasts = 0 + legacyStore.onUIChanged(() => legacyBroadcasts++) + optimizedStore.onUIChanged(() => optimizedBroadcasts++) + + const legacyStart = performance.now() + for (let index = 0; index < INTERACTIONS; index++) { + const featureInteractions = normalizeFeatureInteractions( + legacyStore.getUI().featureInteractions + ) + const existing = featureInteractions['agent-orchestration'] + legacyStore.updateUI({ + featureInteractions: { + ...featureInteractions, + 'agent-orchestration': { + firstInteractedAt: existing?.firstInteractedAt ?? 1, + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + }) + } + const legacyMs = performance.now() - legacyStart + + const optimizedStart = performance.now() + for (let index = 0; index < INTERACTIONS; index++) { + optimizedStore.recordFeatureInteraction('agent-orchestration') + } + const optimizedMs = performance.now() - optimizedStart + + expect(legacyBroadcasts).toBe(INTERACTIONS) + expect(optimizedBroadcasts).toBe(1) + expect( + optimizedStore.getUI().featureInteractions?.['agent-orchestration']?.interactionCount + ).toBe(INTERACTIONS) + optimizedStore.flush() + expect( + new Store({ dataFile }).getUI().featureInteractions?.['agent-orchestration']?.interactionCount + ).toBe(INTERACTIONS) + const persisted = JSON.parse(readFileSync(dataFile, 'utf8')) as PersistedState + expect(persisted.featureInteractionTelemetryBuckets?.['agent-orchestration']).toBe( + 'count_200_499' + ) + + console.log( + `[bench] ${INTERACTIONS} orchestration interactions: ` + + `full-state broadcasts ${legacyBroadcasts} -> ${optimizedBroadcasts}; ` + + `main-thread mutation ${legacyMs.toFixed(2)}ms -> ${optimizedMs.toFixed(2)}ms` + ) + }) +}) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b59ca6f5986..06ee8a9d6a0 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4738,6 +4738,25 @@ describe('Store', () => { expect(updated!.externalWorktreeVisibilityLegacy).toBe(true) }) + it('persists agent worktree visibility independently from external visibility', async () => { + const store = await createStore() + store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' })) + + const updated = store.updateRepo('r1', { agentWorktreeVisibility: 'show' }) + + expect(updated).toMatchObject({ + externalWorktreeVisibility: 'hide', + agentWorktreeVisibility: 'show' + }) + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')).toMatchObject({ + externalWorktreeVisibility: 'hide', + agentWorktreeVisibility: 'show' + }) + }) + it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => { const store = await createStore() store.addRepo( @@ -5044,7 +5063,19 @@ describe('Store', () => { const updated = store.updateFolderWorkspace(workspace.id, { comment: 'Coordinate api and web', isPinned: true, - lastActivityAt: 123 + lastActivityAt: 123, + diffComments: [ + { + id: 'note-1', + worktreeId: folderWorkspaceKey(workspace.id), + filePath: 'README.md', + source: 'markdown', + lineNumber: 1, + body: 'Review this paragraph', + createdAt: 100, + side: 'modified' + } + ] }) expect(workspace.folderPath).toBe('/workspace/platform') @@ -5056,9 +5087,16 @@ describe('Store', () => { linkedTask, comment: 'Coordinate api and web', isPinned: true, - lastActivityAt: 123 + lastActivityAt: 123, + diffComments: [expect.objectContaining({ id: 'note-1', body: 'Review this paragraph' })] }) expect(store.getFolderWorkspaces()).toHaveLength(1) + store.flush() + + const restored = await createStore() + expect(restored.getFolderWorkspace(workspace.id)?.diffComments).toEqual([ + expect.objectContaining({ id: 'note-1', body: 'Review this paragraph' }) + ]) }) it('round-trips Jira item and source context for repo-less folder workspaces', async () => { @@ -5621,6 +5659,42 @@ describe('Store', () => { expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') }) + it('normalizes terminal cursor style before persistence and listener broadcasts', async () => { + const store = await createStore() + store.updateSettings({ terminalCursorStyle: 'underline' }) + const listener = vi.fn() + store.onSettingsChanged(listener) + + const invalid = store.updateSettings( + { terminalCursorStyle: 'beam' as never }, + { notifyListeners: true } + ) + + expect(invalid.terminalCursorStyle).toBe('block') + expect(invalid.terminalCursorStyleDefaultedToBlock).toBe(true) + expect(listener).toHaveBeenCalledWith( + { + terminalCursorStyle: 'block' + }, + expect.objectContaining({ terminalCursorStyle: 'block' }), + undefined + ) + + const valid = store.updateSettings( + { terminalCursorStyle: 'underline' }, + { notifyListeners: true } + ) + expect(valid.terminalCursorStyle).toBe('underline') + expect(listener).toHaveBeenLastCalledWith( + { terminalCursorStyle: 'underline' }, + expect.objectContaining({ terminalCursorStyle: 'underline' }), + undefined + ) + + store.flush() + expect((readDataFile() as PersistedState).settings.terminalCursorStyle).toBe('underline') + }) + it('normalizes disabled TUI agents on load and update', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), @@ -7295,6 +7369,22 @@ describe('Store', () => { expect(store.getSettings().terminalCursorStyleDefaultedToBlock).toBe(true) }) + it('replaces an invalid persisted terminal cursor choice after migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalCursorStyle: 'beam', terminalCursorStyleDefaultedToBlock: true }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().terminalCursorStyle).toBe('block') + store.flush() + expect((readDataFile() as PersistedState).settings.terminalCursorStyle).toBe('block') + }) + it('preserves explicit "false" terminalMacOptionAsAlt through migration', async () => { // 'false' never matched the old default — it was an explicit choice. writeDataFile({ @@ -9124,6 +9214,149 @@ describe('Store', () => { ) }) + it('admits a split binding only while the exact source still owns its layout leaf', async () => { + for (const hostId of [undefined, 'ssh:ssh-1']) { + const store = await createStore() + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' } + } + } + }, + hostId + ) + const staleRendererSession = structuredClone(store.getWorkspaceSession(hostId)) + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt1', + tabId: 'different-target-tab', + leafId: TEST_LEAF_2, + ptyId: 'pty-split', + expectedSourceBinding: { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-source' + } + }, + hostId + ) + ).toBe(false) + expect( + store + .getWorkspaceSession(hostId) + .tabsByWorktree.wt1.some((tab) => tab.id === 'different-target-tab') + ).toBe(false) + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt-canonical', + tabId: 'tab1', + leafId: TEST_LEAF_2, + ptyId: 'pty-split', + expectedSourceBinding: { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-source' + } + }, + hostId + ) + ).toBe(true) + const admitted = store.getWorkspaceSession(hostId) + expect(admitted.terminalTopologyRevisionByRepoId?.wt1).toBe(1) + expect(admitted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toMatchObject({ + [TEST_LEAF_1]: 'pty-source', + [TEST_LEAF_2]: 'pty-split' + }) + + store.setWorkspaceSession(staleRendererSession, hostId) + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.ptyIdsByLeafId + ).toMatchObject({ + [TEST_LEAF_1]: 'pty-source', + [TEST_LEAF_2]: 'pty-split' + }) + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt1', + tabId: 'rejected-tab', + leafId: TEST_LEAF_2, + ptyId: 'pty-rejected', + expectedSourceBinding: { + worktreeId: 'wt1', + tabId: 'missing-source-tab', + leafId: TEST_LEAF_1, + ptyId: 'pty-source' + } + }, + hostId + ) + ).toBe(false) + expect( + store + .getWorkspaceSession(hostId) + .tabsByWorktree.wt1.some((tab) => tab.id === 'rejected-tab') + ).toBe(false) + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId['rejected-tab'] + ).toBeUndefined() + } + }) + + it('rejects a split source incarnation mismatch', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' } + } + }, + terminalPtyIncarnationsByPaneKey: { [`tab1:${TEST_LEAF_1}`]: 'persisted-incarnation' } + }) + + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_2, + ptyId: 'pty-split', + expectedSourceBinding: { + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-source', + incarnationId: 'different-incarnation' + } + }) + ).toBe(false) + expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.root).toEqual({ + type: 'leaf', + leafId: TEST_LEAF_1 + }) + }) + it('rejects competing PTY and incarnation changes during reconciliation', async () => { const store = await createStore() const paneKey = `tab1:${TEST_LEAF_1}` @@ -12142,21 +12375,177 @@ describe('Store host-partitioned workspace sessions', () => { expect(session.terminalTopologyRevisionByRepoId?.['repo-gone']).toBeUndefined() }) - it('drops a corrupt host partition to defaults without failing the others', async () => { + it('resets only the corrupt required field of a host partition, not the partition', async () => { + const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, workspaceSessionsByHostId: { 'runtime:good': makeHostSession('good-repo'), // activeRepoId must be string|null; a number fails the zod parse. - 'runtime:bad': { ...makeHostSession('x'), activeRepoId: 123 } + 'runtime:bad': { + ...makeHostSession('x'), + activeRepoId: 123, + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'bad-host-tab', worktreeId })] } + } } }) const store = await createStore() expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo') - // Bad partition collapses to defaults rather than poisoning the map. + // The unsalvageable field falls back to its default; the partition's tabs survive. expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull() + expect( + store.getWorkspaceSession('runtime:bad').tabsByWorktree[worktreeId]?.map((tab) => tab.id) + ).toEqual(['bad-host-tab']) + }) + + it('keeps every other worktree when the local session has a corrupt required field', async () => { + const worktreeId = 'repo-1::/worktree' + writeDataFile({ + schemaVersion: 1, + workspaceSession: { + ...makeHostSession('local-repo'), + // A projected/truncated write can leave a top-level field the wrong type; + // that must not cost every worktree's tabs the way a full reset did. + activeTabId: 42, + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'local-keep', worktreeId })] + } + } + }) + + const store = await createStore() + + const session = store.getWorkspaceSession('local') + expect(session.activeTabId).toBeNull() + expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['local-keep']) + }) + + type PersistedSessionsFile = { + workspaceSession?: { + tabsByWorktree?: Record + sleepingAgentSessionsByPaneKey?: Record + } + workspaceSessionsByHostId?: Record< + string, + { tabsByWorktree?: Record } + > + } + + // Why: flush() writes whatever the state hash says is dirty, so it passes even + // when nothing scheduled a save — it cannot see the repair write at all. Loading + // once first canonicalizes the profile (a second load of a canonical file + // schedules nothing), so a later rewrite proves the salvage scheduled it. + async function loadAndAwaitScheduledSave(): Promise { + vi.useFakeTimers() + try { + const store = await createStore() + vi.advanceTimersByTime(10_000) + await store.waitForPendingWrite() + } finally { + vi.useRealTimers() + } + } + + async function canonicalize(fixture: Record): Promise { + writeDataFile(fixture) + await loadAndAwaitScheduledSave() + const canonical = readFileSync(dataFile(), 'utf-8') + // Why: the save assertions below are only meaningful if a clean load schedules + // nothing. Prove that here rather than assume it — a future migration that + // dirtied every load would otherwise leave those tests silently vacuous. + await loadAndAwaitScheduledSave() + expect(readFileSync(dataFile(), 'utf-8')).toBe(canonical) + return JSON.parse(canonical) as PersistedSessionsFile + } + + it('schedules a save for a salvaged local session instead of re-salvaging every launch', async () => { + const worktreeId = 'repo-1::/worktree' + const profile = await canonicalize({ + schemaVersion: 1, + workspaceSession: { + ...makeHostSession('local-repo'), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] } + } + }) + const tabs = profile.workspaceSession?.tabsByWorktree?.[worktreeId] + expect(tabs).toBeDefined() + tabs!.push({ id: 'tab-corrupt' }) + writeDataFile(profile) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await loadAndAwaitScheduledSave() + expect(warn).toHaveBeenCalledWith( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + { count: 1, fields: ['tabsByWorktree'], detailsTruncated: false } + ) + expect(JSON.stringify(warn.mock.calls)).not.toContain(worktreeId) + } finally { + warn.mockRestore() + } + + const persisted = readDataFile() as PersistedSessionsFile + expect(persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'tab-keep' + ]) + }) + + it('schedules a save for salvaged host partitions', async () => { + const worktreeId = 'repo-1::/worktree' + const profile = await canonicalize({ + schemaVersion: 1, + workspaceSessionsByHostId: { + 'runtime:env-a': { + ...makeHostSession('runtime-repo'), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'runtime-keep', worktreeId })] } + }, + 'ssh:target-b': { + ...makeHostSession('ssh-repo'), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'ssh-keep', worktreeId })] } + } + } + }) + const partitions = profile.workspaceSessionsByHostId + const runtimeTabs = partitions?.['runtime:env-a']?.tabsByWorktree?.[worktreeId] + const sshTabs = partitions?.['ssh:target-b']?.tabsByWorktree?.[worktreeId] + expect(runtimeTabs).toBeDefined() + expect(sshTabs).toBeDefined() + runtimeTabs!.push({ id: 'runtime-corrupt' }) + sshTabs!.push({ id: 'ssh-corrupt' }) + const mutablePartitions = partitions as Record + mutablePartitions['runtime:broken'] = 'not a session' + writeDataFile(profile) + await loadAndAwaitScheduledSave() + + const persisted = (readDataFile() as PersistedSessionsFile).workspaceSessionsByHostId + expect( + persisted?.['runtime:env-a']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id) + ).toEqual(['runtime-keep']) + expect(persisted?.['ssh:target-b']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual( + ['ssh-keep'] + ) + expect(persisted).not.toHaveProperty('runtime:broken') + }) + + it('writes back sleeping-agent records dropped during salvage', async () => { + const profile = await canonicalize({ + schemaVersion: 1, + workspaceSession: { + ...makeHostSession('local-repo'), + sleepingAgentSessionsByPaneKey: {} + } + }) + profile.workspaceSession!.sleepingAgentSessionsByPaneKey = { + 'tab-bad:leaf': { paneKey: 'different:leaf' } + } + writeDataFile(profile) + + await loadAndAwaitScheduledSave() + + const persisted = readDataFile() as PersistedSessionsFile + expect(persisted.workspaceSession?.sleepingAgentSessionsByPaneKey).toBeUndefined() }) }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 12856638d29..b62b25f7977 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -134,7 +134,7 @@ import { ONBOARDING_FLOW_VERSION, ONBOARDING_FINAL_STEP } from '../shared/constants' -import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' @@ -555,15 +555,27 @@ function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatc ) } +function workspaceSessionSalvageLogDetails(result: { + droppedCount: number + droppedPaths: string[] +}): { count: number; fields: string[]; detailsTruncated: boolean } { + return { + count: result.droppedCount, + fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], + detailsTruncated: result.droppedCount > result.droppedPaths.length + } +} + /** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ function parseWorkspaceSessionsByHostId( raw: unknown, defaults: WorkspaceSessionState -): Partial> { +): { partitions: Partial>; repaired: boolean } { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return {} + return { partitions: {}, repaired: raw !== undefined } } + let repaired = false const partitions: Partial> = {} for (const [key, value] of Object.entries(raw as Record)) { const hostId = normalizeExecutionHostId(key) @@ -571,17 +583,25 @@ function parseWorkspaceSessionsByHostId( if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { continue } - const result = parseWorkspaceSession(value) + const result = parseWorkspaceSessionSalvaging(value) if (!result.ok) { + repaired = true console.error( `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, result.error ) continue } + if (result.droppedCount > 0) { + console.warn( + `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, + workspaceSessionSalvageLogDetails(result) + ) + repaired = true + } partitions[hostId] = { ...defaults, ...result.value } } - return partitions + return { partitions, repaired } } function backupPath(dataFile: string, index: number): string { @@ -653,11 +673,8 @@ function migrateTerminalScrollbackRows(settings: unknown): { needsSave: boolean } { const legacySettings = readLegacyTerminalScrollbackSettings(settings) - const hasRows = Object.prototype.hasOwnProperty.call(legacySettings, 'terminalScrollbackRows') - const hasLegacyBytes = Object.prototype.hasOwnProperty.call( - legacySettings, - 'terminalScrollbackBytes' - ) + const hasRows = Object.hasOwn(legacySettings, 'terminalScrollbackRows') + const hasLegacyBytes = Object.hasOwn(legacySettings, 'terminalScrollbackBytes') const rows = hasRows ? normalizeDesktopTerminalScrollbackRows(legacySettings.terminalScrollbackRows) : legacyTerminalScrollbackBytesToRows(legacySettings.terminalScrollbackBytes) @@ -1665,7 +1682,7 @@ type LayoutLeafNormalization = { function collectLayoutLeafCounts( node: TerminalPaneLayoutNode, - counts: Map = new Map() + counts = new Map() ): Map { if (node.type === 'leaf') { counts.set(node.leafId, (counts.get(node.leafId) ?? 0) + 1) @@ -2763,6 +2780,14 @@ export type StoreOptions = { dataFile?: string } +export type PtyBindingSourceExpectation = { + worktreeId?: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string +} + export class Store { private state: PersistedState private readonly dataFile: string @@ -3224,6 +3249,13 @@ export class Store { parsed.settings ) const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) + if ( + parsed.settings?.terminalCursorStyle !== + migratedTerminalCursorStyle.terminalCursorStyle || + parsed.settings?.terminalCursorStyleDefaultedToBlock !== true + ) { + this.loadNeedsSave = true + } const migratedTerminalLineHeight = normalizeTerminalLineHeight( parsed.settings?.terminalLineHeight ) @@ -3651,7 +3683,7 @@ export class Store { if (parsed.workspaceSession === undefined) { return defaults.workspaceSession } - const result = parseWorkspaceSession(parsed.workspaceSession) + const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) if (!result.ok) { console.error( '[persistence] Corrupt workspace session, using defaults:', @@ -3659,13 +3691,28 @@ export class Store { ) return defaults.workspaceSession } + if (result.droppedCount > 0) { + console.warn( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + workspaceSessionSalvageLogDetails(result) + ) + // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. + this.loadNeedsSave = true + } return { ...defaults.workspaceSession, ...result.value } })(), // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: parseWorkspaceSessionsByHostId( - parsed.workspaceSessionsByHostId, - defaults.workspaceSession - ), + workspaceSessionsByHostId: (() => { + const { partitions, repaired } = parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ) + if (repaired) { + // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. + this.loadNeedsSave = true + } + return partitions + })(), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) ? parsed.deletedSshConfigAliases.filter( @@ -4438,6 +4485,7 @@ export class Store { linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }): FolderWorkspace { @@ -4460,6 +4508,7 @@ export class Store { name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`), folderPath, connectionId: input.connectionId ?? group.connectionId ?? null, + ...(input.creatorProvenance ? { creatorProvenance: input.creatorProvenance } : {}), linkedTask, linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch(linkedTask, sourceContext) ? sourceContext @@ -4502,6 +4551,7 @@ export class Store { | 'pendingFirstAgentMessageRename' | 'firstAgentMessageRenameError' | 'lastActivityAt' + | 'diffComments' > > ): FolderWorkspace | null { @@ -4575,6 +4625,9 @@ export class Store { if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) { workspace.lastActivityAt = updates.lastActivityAt } + if (updates.diffComments !== undefined) { + workspace.diffComments = updates.diffComments + } workspace.updatedAt = Date.now() this.scheduleSave() return workspace @@ -4866,6 +4919,7 @@ export class Store { | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' | 'projectGroupId' | 'projectGroupOrder' | 'projectHostSetupMethod' @@ -5743,7 +5797,7 @@ export class Store { } } - // Why: UI view-state is written from both desktop and mobile (ui.set RPC), so notify to keep bi-directional sync (desktop hydrates UI only once). + // Why: renderer-visible UI state is written from desktop and mobile, so notify to keep bi-directional sync. onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { this.uiChangeListeners.add(listener) return () => { @@ -5804,6 +5858,15 @@ export class Store { updates.terminalCustomThemes ) } + if ('terminalCursorStyle' in updates) { + Object.assign( + sanitizedUpdates, + normalizeTerminalCursorStyleDefault( + { terminalCursorStyle: updates.terminalCursorStyle }, + { preserveExplicitValue: true } + ) + ) + } if ('terminalScrollbackRows' in updates) { sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( updates.terminalScrollbackRows @@ -6154,7 +6217,8 @@ export class Store { (lastEmittedBucket === null || compareFeatureInteractionUsageBuckets(nextBucket, lastEmittedBucket) > 0) - this.updateUI({ + this.state.ui = { + ...this.state.ui, featureInteractions: { ...featureInteractions, [id]: { @@ -6162,11 +6226,15 @@ export class Store { interactionCount: nextCount } } - }) + } this.state.featureInteractionTelemetryBuckets = shouldEmit ? { ...telemetryBuckets, [id]: nextBucket } : telemetryBuckets this.scheduleSave() + // Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer. + if (!existing) { + this.notifyUIChanged() + } if (shouldEmit) { track('feature_interaction_usage_bucket_reached', { @@ -6715,15 +6783,37 @@ export class Store { incarnationId?: string startupCwd?: string expectedBinding?: { ptyId: string; incarnationId?: string } + expectedSourceBinding?: PtyBindingSourceExpectation }, hostId?: string | null ): boolean { const resolvedHostId = this.resolveHostId(hostId) const session = this.getWorkspaceSession(resolvedHostId) const paneKey = `${args.tabId}:${args.leafId}` + const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + if (args.expectedSourceBinding) { + const expected = args.expectedSourceBinding + if (expected.tabId !== args.tabId) { + return false + } + const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId + ) + const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] + const sourcePaneKey = `${expected.tabId}:${expected.leafId}` + if ( + !sourceTab || + sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || + !layoutContainsLeafId(sourceLayout.root, expected.leafId) || + (expected.incarnationId !== undefined && + session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) + ) { + return false + } + } if (args.expectedBinding) { - const tab = session.tabsByWorktree?.[args.worktreeId]?.find( - (candidate) => candidate.id === args.tabId && candidate.worktreeId === args.worktreeId + const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId ) const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] if ( @@ -6746,9 +6836,13 @@ export class Store { args.incarnationId !== args.expectedBinding.incarnationId let terminalMembershipChanged = false const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(args.worktreeId) + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - if (!reconciledIncarnation && (!terminalMembershipChanged || currentRevision <= 0)) { + const establishesSplitAuthority = args.expectedSourceBinding !== undefined + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesSplitAuthority)) + ) { return } // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. @@ -6779,7 +6873,7 @@ export class Store { delete session.terminalSurfaceTombstonesByPaneKey[paneKey] } } - const tabs = session.tabsByWorktree?.[args.worktreeId] + const tabs = session.tabsByWorktree?.[bindingWorktreeId] const tab = tabs?.find((t) => t.id === args.tabId) if (tab) { tab.ptyId = args.ptyId @@ -6790,18 +6884,19 @@ export class Store { ...(tabs ?? []), createMinimalPersistedTerminalTab({ ...args, + worktreeId: bindingWorktreeId, existingTabCount: tabs?.length ?? 0 }) ] session.tabsByWorktree = { ...session.tabsByWorktree, - [args.worktreeId]: nextTabs + [bindingWorktreeId]: nextTabs } - session.activeWorktreeId ??= args.worktreeId + session.activeWorktreeId ??= bindingWorktreeId session.activeTabId ??= args.tabId session.activeTabIdByWorktree = { ...session.activeTabIdByWorktree, - [args.worktreeId]: session.activeTabIdByWorktree?.[args.worktreeId] ?? args.tabId + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId } } if (!isTerminalLeafId(args.leafId)) { @@ -7168,14 +7263,14 @@ export class Store { (entry) => entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId ) - const existing = existingIndex >= 0 ? this.state.sshRemotePtyLeases[existingIndex] : undefined + const existing = existingIndex !== -1 ? this.state.sshRemotePtyLeases[existingIndex] : undefined const next: SshRemotePtyLease = { ...existing, ...normalizedLease, createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now, updatedAt: normalizedLease.updatedAt ?? now } - if (existingIndex >= 0) { + if (existingIndex !== -1) { this.state.sshRemotePtyLeases[existingIndex] = next } else { this.state.sshRemotePtyLeases.push(next) diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index ee051e938d1..b735cd60198 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -30,6 +30,7 @@ type Harness = { fsMock: { existsSync: ReturnType readFileSync: ReturnType + statSync: ReturnType } handlers: Record processEnv: Record @@ -62,6 +63,7 @@ function createHarness(args: { argv?: string[] existsSync?: (path: string) => boolean readFileSync?: (path: string, encoding: string) => string + statSync?: (path: string) => { mtimeMs: number; size: number; ino: number } fetchImpl?: (...params: Parameters) => Promise }): Harness { const fetchMock = vi.fn( @@ -86,6 +88,12 @@ function createHarness(args: { const fsMock = { existsSync: vi.fn(args.existsSync ?? (() => false)), + statSync: vi.fn( + args.statSync ?? + ((path: string) => { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }) + ), readFileSync: vi.fn( args.readFileSync ?? ((path: string) => { @@ -562,6 +570,31 @@ describe('getPiAgentStatusExtensionSource', () => { ) }) + it('uses current Windows coordinates when a same-token guest endpoint is stale', async () => { + const endpointPath = '/home/u/.orca-wsl/agent-hooks/instance-test/endpoint.env' + const harness = createHarness({ + kind: 'prime-agent', + env: { WSL_DISTRO_NAME: 'Ubuntu', ORCA_AGENT_HOOK_ENDPOINT: endpointPath }, + existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe', + statSync: () => ({ mtimeMs: 1, size: 80, ino: 1 }), + readFileSync: (path) => { + if (path === endpointPath) { + return 'ORCA_AGENT_HOOK_PORT=9999\nORCA_AGENT_HOOK_TOKEN=token-1\n' + } + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }, + fetchImpl: vi.fn(async () => { + throw new Error('stale guest relay') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:9999/hook/prime-agent') + await vi.waitFor(() => expect(harness.spawnMock).toHaveBeenCalledTimes(1)) + expect(harness.spawnMock.mock.calls[0]?.[1]).toContain('http://127.0.0.1:4321/hook/prime-agent') + }) + it('probes WSL evidence and the curl path once per process', async () => { const harness = createHarness({ kind: 'omp', diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index ff2240531ce..6adfdf23fbc 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -13,6 +13,7 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' import { getPiAgentStatusHandlerSourceLines } from './agent-status-handler-source' import { getPiAgentStatusRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source' +import { getPiAgentStatusWslCurlSourceLines } from './agent-status-wsl-curl-source' export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' @@ -228,92 +229,13 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' // Why: status reporting must never fail the pi run just because Orca', ' // is unavailable or the loopback request failed (e.g. Orca restart).', ' if (!isWslRuntime()) return', - ' postViaWindowsCurl(url, coords, body)', + ' postViaWindowsCurl(body, ompRuntime)', ' } finally {', ' if (timeout) clearTimeout(timeout)', ' }', '}', '', - '// Why: WSL-ness and curl.exe presence cannot change within a process', - '// lifetime; re-probing /proc and /mnt/c on every failed event would add', - '// filesystem work to the per-event path.', - 'let cachedIsWslRuntime: boolean | null = null', - 'let cachedWindowsCurlPath: string | null | undefined', - '', - 'function isWslRuntime(): boolean {', - ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', - ' cachedIsWslRuntime = detectWslRuntime()', - ' return cachedIsWslRuntime', - '}', - '', - 'function detectWslRuntime(): boolean {', - ' if (process.env.WSL_DISTRO_NAME) return true', - ' try {', - " const fs = require('fs')", - " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", - ' try {', - " const contents = String(fs.readFileSync(path, 'utf8'))", - ' if (/microsoft|wsl/i.test(contents)) return true', - ' } catch {', - ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', - ' }', - ' }', - ' } catch {', - ' return false', - ' }', - ' return false', - '}', - '', - 'function resolveWindowsCurlPath(): string | null {', - ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', - ' try {', - " const fs = require('fs')", - " const curlPath = '/mnt/c/Windows/System32/curl.exe'", - ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', - ' } catch {', - ' cachedWindowsCurlPath = null', - ' }', - ' return cachedWindowsCurlPath', - '}', - '', - '// Why: WSL loopback is not the Windows loopback, so a WSL-side POST cannot', - '// reach Orca. curl.exe runs on the Windows side, where 127.0.0.1 IS the', - '// listener Orca binds. Fire-and-forget: blocking on the spawn would stall', - '// the pi event loop (and the TUI) on every hook event.', - 'function postViaWindowsCurl(url: string, coords: { token: string }, body: string): void {', - ' const curlPath = resolveWindowsCurlPath()', - ' if (!curlPath) return', - ' try {', - " const { spawn } = require('child_process')", - ' const child = spawn(', - ' curlPath,', - ' [', - " '-sS',", - ' // Why: the spawn is detached from the event loop, so these bounds', - ' // size a background process, not TUI latency. WSL->Win32 interop', - ' // connects can exceed 0.5s on loaded machines (observed 3/3 drops', - ' // to a healthy listener); size for delivery, not snappiness.', - " '--connect-timeout', '3',", - " '--max-time', '10',", - " '--noproxy', '127.0.0.1',", - " '-o', 'NUL',", - " '-X', 'POST',", - " '-H', 'Content-Type: application/json',", - " '-H', `X-Orca-Agent-Hook-Token: ${coords.token}`,", - " '--data-binary', '@-',", - ' url', - ' ],', - " { stdio: ['pipe', 'ignore', 'ignore'] }", - ' )', - " child.on('error', () => {})", - " child.stdin.on('error', () => {})", - ' child.stdin.end(body)', - ' } catch {', - ' // Why: the bridge is best-effort; a failed spawn must not surface', - ' // inside the pi TUI.', - ' }', - '}', - '', + ...getPiAgentStatusWslCurlSourceLines(), ...getPiAgentStatusHandlerSourceLines(kind) ].join('\n') } diff --git a/src/main/pi/agent-status-wsl-curl-source.ts b/src/main/pi/agent-status-wsl-curl-source.ts new file mode 100644 index 00000000000..558e25a49db --- /dev/null +++ b/src/main/pi/agent-status-wsl-curl-source.ts @@ -0,0 +1,81 @@ +export function getPiAgentStatusWslCurlSourceLines(): string[] { + return [ + '// Why: WSL-ness and curl.exe presence cannot change within a process', + '// lifetime; re-probing /proc and /mnt/c on every failed event would add', + '// filesystem work to the per-event path.', + 'let cachedIsWslRuntime: boolean | null = null', + 'let cachedWindowsCurlPath: string | null | undefined', + '', + 'function isWslRuntime(): boolean {', + ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', + ' cachedIsWslRuntime = detectWslRuntime()', + ' return cachedIsWslRuntime', + '}', + '', + 'function detectWslRuntime(): boolean {', + ' if (process.env.WSL_DISTRO_NAME) return true', + ' try {', + " const fs = require('fs')", + " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", + ' try {', + " const contents = String(fs.readFileSync(path, 'utf8'))", + ' if (/microsoft|wsl/i.test(contents)) return true', + ' } catch {', + ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', + ' }', + ' }', + ' } catch {', + ' return false', + ' }', + ' return false', + '}', + '', + 'function resolveWindowsCurlPath(): string | null {', + ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', + ' try {', + " const fs = require('fs')", + " const curlPath = '/mnt/c/Windows/System32/curl.exe'", + ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', + ' } catch {', + ' cachedWindowsCurlPath = null', + ' }', + ' return cachedWindowsCurlPath', + '}', + '', + '// Why: WSL loopback is not the Windows loopback, so use curl.exe on the host.', + 'function postViaWindowsCurl(body: string, ompRuntime: boolean): void {', + ' const curlPath = resolveWindowsCurlPath()', + ' const windowsPort = process.env.ORCA_AGENT_HOOK_PORT', + ' const windowsToken = process.env.ORCA_AGENT_HOOK_TOKEN', + ' if (!curlPath || !windowsPort || !windowsToken) return', + ' // Why: a stale guest endpoint must fall back to current host coordinates.', + ' const windowsUrl = `http://127.0.0.1:${windowsPort}${resolveHookPath(ompRuntime)}`', + ' try {', + " const { spawn } = require('child_process')", + ' const child = spawn(', + ' curlPath,', + ' [', + " '-sS',", + ' // Why: detached delivery may take seconds under loaded WSL interop.', + " '--connect-timeout', '3',", + " '--max-time', '10',", + " '--noproxy', '127.0.0.1',", + " '-o', 'NUL',", + " '-X', 'POST',", + " '-H', 'Content-Type: application/json',", + " '-H', `X-Orca-Agent-Hook-Token: ${windowsToken}`,", + " '--data-binary', '@-',", + ' windowsUrl', + ' ],', + " { stdio: ['pipe', 'ignore', 'ignore'] }", + ' )', + " child.on('error', () => {})", + " child.stdin.on('error', () => {})", + ' child.stdin.end(body)', + ' } catch {', + ' // Why: status delivery must not surface inside the agent TUI.', + ' }', + '}', + '' + ] +} diff --git a/src/main/pi/legacy-omp-overlay-migration.ts b/src/main/pi/legacy-omp-overlay-migration.ts index 88baf78ea9f..74d6039fcb5 100644 --- a/src/main/pi/legacy-omp-overlay-migration.ts +++ b/src/main/pi/legacy-omp-overlay-migration.ts @@ -146,7 +146,7 @@ function copyMissingLegacyOmpOverlayEntries( overlayDir: string, sourceAgentDir: string, pathSegments: string[] = [], - copiedFilePaths: Set = new Set() + copiedFilePaths = new Set() ): boolean { let completed = true let entries: Dirent[] diff --git a/src/main/plugins/plugin-dev-watcher.ts b/src/main/plugins/plugin-dev-watcher.ts index c6aefa12699..6e7f6f908f0 100644 --- a/src/main/plugins/plugin-dev-watcher.ts +++ b/src/main/plugins/plugin-dev-watcher.ts @@ -98,7 +98,7 @@ export class PluginDevWatcher { private removeSubscription(subscription: WatcherProcessSubscription): void { const index = this.subscriptions.indexOf(subscription) - if (index >= 0) { + if (index !== -1) { this.subscriptions.splice(index, 1) } } diff --git a/src/main/plugins/plugin-git-repository.ts b/src/main/plugins/plugin-git-repository.ts index e8870f9e7fe..ac786273fc1 100644 --- a/src/main/plugins/plugin-git-repository.ts +++ b/src/main/plugins/plugin-git-repository.ts @@ -1,26 +1,17 @@ -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' import { isAllowedPluginGitUrl, PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { gitExecFileAsync } from '../git/runner' -const execFileAsync = promisify(execFile) const PLUGIN_GIT_TIMEOUT_MS = 120_000 /** Runs system Git with argv-only invocation so credential helpers and SSH * remotes work without exposing an executable remote-helper surface. */ export async function runPluginGit(args: string[], cwd: string): Promise { - const { stdout } = await execFileAsync('git', args, { + const { stdout } = await gitExecFileAsync(args, { cwd, - timeout: PLUGIN_GIT_TIMEOUT_MS, - windowsHide: true, - env: { - ...process.env, - // Existing non-interactive helpers and SSH agents still work, but a - // background marketplace refresh can never hang on a terminal prompt. - GIT_TERMINAL_PROMPT: '0' - } + timeout: PLUGIN_GIT_TIMEOUT_MS }) return stdout.trim() } diff --git a/src/main/plugins/plugin-host-runtime.ts b/src/main/plugins/plugin-host-runtime.ts index 2a2f5f9fcab..b7f3eb9b8a1 100644 --- a/src/main/plugins/plugin-host-runtime.ts +++ b/src/main/plugins/plugin-host-runtime.ts @@ -20,7 +20,7 @@ export type PluginHostCallError = Error & { code?: string } export type PluginWorkerOrcaApi = { /** Register the handler for a command declared in the manifest. */ commands: { - register(commandId: string, handler: (args: unknown) => unknown | Promise): void + register(commandId: string, handler: (args: unknown) => unknown): void } /** Handle an event the manifest subscribed to (`contributes.events`). */ events: { @@ -55,7 +55,7 @@ export function createPluginWorkerRuntime( const send = options.send const importModule = options.importModule ?? ((specifier: string) => import(specifier)) const exit = options.exit ?? ((code: number) => process.exit(code)) - const commandHandlers = new Map unknown | Promise>() + const commandHandlers = new Map unknown>() const eventHandlers = new Map void | Promise)[]>() const pendingHostCalls = new Map< number, @@ -64,7 +64,7 @@ export function createPluginWorkerRuntime( let nextHostCallId = 0 let initialized = false let shuttingDown = false - let deactivate: (() => unknown | Promise) | null = null + let deactivate: (() => unknown) | null = null async function handleInit(input: { pluginRoot: string @@ -88,7 +88,7 @@ export function createPluginWorkerRuntime( if (module.deactivate !== undefined && typeof module.deactivate !== 'function') { throw new Error(`plugin entry ${input.mainEntry} has a non-function deactivate export`) } - deactivate = (module.deactivate as (() => unknown | Promise) | undefined) ?? null + deactivate = (module.deactivate as (() => unknown) | undefined) ?? null const orca: PluginWorkerOrcaApi = { commands: { register(commandId, handler) { diff --git a/src/main/plugins/plugin-worker-output-buffer.ts b/src/main/plugins/plugin-worker-output-buffer.ts index dfbe0478c28..836330c8799 100644 --- a/src/main/plugins/plugin-worker-output-buffer.ts +++ b/src/main/plugins/plugin-worker-output-buffer.ts @@ -34,7 +34,7 @@ export function pipePluginWorkerOutput( while (remaining.length > 0) { if (discarding) { const newline = remaining.indexOf('\n') - if (newline < 0) { + if (newline === -1) { return } discarding = false @@ -42,20 +42,20 @@ export function pipePluginWorkerOutput( continue } const newline = remaining.indexOf('\n') - const segment = newline < 0 ? remaining : remaining.slice(0, newline) + const segment = newline === -1 ? remaining : remaining.slice(0, newline) const available = PLUGIN_WORKER_OUTPUT_LINE_LIMIT - buffered.length if (segment.length > available) { emit(buffered + segment.slice(0, available), true) buffered = '' - discarding = newline < 0 + discarding = newline === -1 } else { buffered += segment - if (newline >= 0) { + if (newline !== -1) { emit(buffered) buffered = '' } } - if (newline < 0) { + if (newline === -1) { return } remaining = remaining.slice(newline + 1) diff --git a/src/main/plugins/plugin-worker-slot-pool.ts b/src/main/plugins/plugin-worker-slot-pool.ts index 5b86acf38d7..7cb6c79d6f1 100644 --- a/src/main/plugins/plugin-worker-slot-pool.ts +++ b/src/main/plugins/plugin-worker-slot-pool.ts @@ -49,7 +49,7 @@ export class PluginWorkerSlotPool { } cancelled = true const index = this.waiters.indexOf(waiter) - if (index >= 0) { + if (index !== -1) { this.waiters.splice(index, 1) } signal.removeEventListener('abort', waiter.onAbort) diff --git a/src/main/ports/advertised-url-watcher.test.ts b/src/main/ports/advertised-url-watcher.test.ts index d46b8268fab..17fcc5803bb 100644 --- a/src/main/ports/advertised-url-watcher.test.ts +++ b/src/main/ports/advertised-url-watcher.test.ts @@ -147,6 +147,13 @@ describe('AdvertisedUrlWatcher.ingest', () => { expect(watcher.lookup(WORKTREE, 3001)?.origin).toBe('https://local.getmontecarlo.com:3001') }) + it('preserves a split URL inside the bounded suffix', () => { + const watcher = bindFresh() + watcher.ingest(PTY, `${'x'.repeat(4080)} https://local.`) + watcher.ingest(PTY, 'example.com:3003/\n') + expect(watcher.lookup(WORKTREE, 3003)?.origin).toBe('https://local.example.com:3003') + }) + it('does not scan buffered PTY text until a line break arrives', () => { const watcher = bindFresh() const charCodeAtSpy = vi.spyOn(String.prototype, 'charCodeAt') @@ -168,6 +175,35 @@ describe('AdvertisedUrlWatcher.ingest', () => { expect(watcher.lookup(WORKTREE, 3001)?.origin).toBe('https://example.com:3001') }) + it('keeps URLs whose scheme is split by terminal controls', () => { + const watcher = bindFresh() + watcher.ingest(PTY, 'H\x1b[32mtT\x1b[0mP://example.com:3001/\n') + expect(watcher.lookup(WORKTREE, 3001)?.origin).toBe('http://example.com:3001') + }) + + it('rejects finalized lines missing any character required by an HTTP URL', () => { + const watcher = bindFresh() + for (const line of [ + 'ttp://example.com:3001/\n', + 'hhp://example.com:3001/\n', + 'htt://example.com:3001/\n', + 'http//example.com/3001\n', + 'http:example.com:3001\n' + ]) { + watcher.ingest(PTY, line) + } + expect(watcher.lookup(WORKTREE, 3001)).toBeUndefined() + }) + + it('retains exactly the latest bounded tail across oversized chunks', () => { + const watcher = bindFresh() + watcher.ingest(PTY, `https://stale.example.com:3001/${'x'.repeat(4096)}`) + watcher.ingest(PTY, ' https://fresh.example.com:3002/\n') + + expect(watcher.lookup(WORKTREE, 3001)).toBeUndefined() + expect(watcher.lookup(WORKTREE, 3002)?.host).toBe('fresh.example.com') + }) + it('sanitizes to origin (drops path, query, fragment, userinfo)', () => { const watcher = bindFresh() watcher.ingest(PTY, 'go to https://user:pass@example.com:3001/callback?token=secret#x\n') diff --git a/src/main/ports/advertised-url-watcher.ts b/src/main/ports/advertised-url-watcher.ts index befc11ab94f..3f8ca60bcce 100644 --- a/src/main/ports/advertised-url-watcher.ts +++ b/src/main/ports/advertised-url-watcher.ts @@ -66,9 +66,13 @@ class PtyBuffer { /** Append a chunk; return cleaned text up to the last newline. The tail stays buffered so a URL or ANSI sequence split across chunks survives. */ ingest(chunk: string): string { const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r') - this.raw += chunk - if (this.raw.length > PER_PTY_BUFFER_LIMIT) { - this.raw = this.raw.slice(-PER_PTY_BUFFER_LIMIT) + // Keep the suffix directly so oversized chunks never materialize a throwaway full concatenation. + if (chunk.length >= PER_PTY_BUFFER_LIMIT) { + this.raw = chunk.slice(-PER_PTY_BUFFER_LIMIT) + } else if (this.raw.length + chunk.length > PER_PTY_BUFFER_LIMIT) { + this.raw = `${this.raw.slice(-(PER_PTY_BUFFER_LIMIT - chunk.length))}${chunk}` + } else { + this.raw += chunk } if (!chunkHasLineBreak) { return '' @@ -79,10 +83,21 @@ class PtyBuffer { } const finalized = this.raw.slice(0, lastNewline + 1) this.raw = this.raw.slice(lastNewline + 1) - return stripTerminalControls(finalized) + return mayContainHttpUrl(finalized) ? stripTerminalControls(finalized) : '' } } +function mayContainHttpUrl(text: string): boolean { + // Control stripping cannot create any character required by an HTTP scheme. + return ( + (text.includes('h') || text.includes('H')) && + (text.includes('t') || text.includes('T')) && + (text.includes('p') || text.includes('P')) && + text.includes(':') && + text.includes('/') + ) +} + function lastLineBreak(text: string): number { // Accept either \n or \r as a finalize point (\r\n is normalized later in stripTerminalControls). for (let i = text.length - 1; i >= 0; i--) { diff --git a/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts index c9217f17994..a1dd3727c54 100644 --- a/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts +++ b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts @@ -62,6 +62,8 @@ export async function shellScriptTest( const env: Record = { ...config.env, + // Why: these examples inspect startup-file discovery, not the PTY-owner protocol stream. + ORCA_SHELL_STARTUP_IDENTITY: '0', // Why: the framework creates user startup files under testHome after // computing the wrapper config; route wrapper discovery to that fixture. HOME: testHome, diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index c204b9d9016..d13bf5addf9 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -66,7 +66,7 @@ export type IGitProvider = { getBranchDiff( worktreePath: string, baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string } + options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } ): Promise getCommitDiff( worktreePath: string, diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 5567c11c957..909ca9b73f1 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -14,7 +14,9 @@ const { resolveAgentForegroundProcessMock, readWindowsConptyProcessIdsMock, killWithDescendantSweepMock, - isWslAvailableAsyncMock + isWslAvailableAsyncMock, + wslUncDirectoryExistsMock, + createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), statSyncMock: vi.fn(), @@ -26,7 +28,9 @@ const { resolveAgentForegroundProcessMock: vi.fn(), readWindowsConptyProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), - isWslAvailableAsyncMock: vi.fn() + isWslAvailableAsyncMock: vi.fn(), + wslUncDirectoryExistsMock: vi.fn(), + createShellPromptReadinessProbeMock: vi.fn() })) vi.mock('fs', () => ({ @@ -102,7 +106,11 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: () => true + wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) +})) + +vi.mock('../shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: createShellPromptReadinessProbeMock })) import { @@ -170,6 +178,9 @@ describe('LocalPtyProvider', () => { readWindowsConptyProcessIdsMock.mockResolvedValue(null) isWslAvailableAsyncMock.mockReset() isWslAvailableAsyncMock.mockResolvedValue(true) + wslUncDirectoryExistsMock.mockReset() + wslUncDirectoryExistsMock.mockReturnValue(true) + createShellPromptReadinessProbeMock.mockReset() exitCb = undefined mockProc = { @@ -530,6 +541,24 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') }) + it('verifies shell identity against the exact spawn PATH', async () => { + provider.configure({ + buildSpawnEnv: (_id, env) => ({ ...env, PATH: '/post-hook/bin' }) + }) + + await provider.spawn({ + cols: 80, + rows: 24, + command: 'printf ready', + env: { PATH: '/pre-hook/bin' } + }) + + expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/post-hook/bin') + expect(createShellPromptReadinessProbeMock).toHaveBeenCalledWith( + expect.objectContaining({ shellPathEnv: '/post-hook/bin' }) + ) + }) + it('does not inherit NODE_ENV from the Orca process env', async () => { // Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets // `development` in dev runs); leaking it breaks `next build` and Vitest. @@ -978,6 +1007,52 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian') }) + it.each(['/home/jin/repo', '/a', '/c'])( + 'preserves a POSIX cwd through the preferred WSL distro (%s)', + async (cwd) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await provider.spawn({ + cols: 80, + rows: 24, + worktreeId: 'repo-1::C:\\Users\\jin\\repo', + cwd, + prevalidatedCwd: `\\\\wsl.localhost\\Debian${cwd.replaceAll('/', '\\')}`, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[1]).toEqual([ + '-d', + 'Debian', + '--', + 'sh', + '-c', + expect.stringContaining(`cd '${cwd}'`) + ]) + expect(spawnCall[2].cwd).not.toBe(cwd) + expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + } + ) + + it('revalidates when cwd evidence does not exactly match the resolved WSL path', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await provider.spawn({ + cols: 80, + rows: 24, + worktreeId: 'repo-1::C:\\Users\\jin\\repo', + cwd: '/a', + prevalidatedCwd: '\\\\wsl.localhost\\Debian\\c', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith('\\\\wsl.localhost\\Debian\\a') + }) + it('resolves and persists the default distro for Windows cwd WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const buildSpawnEnv = vi.fn( diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 6ed3c9f0f87..a31bf4857b7 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -13,6 +13,7 @@ import { existsSync } from 'node:fs' import * as pty from 'node-pty' import { getDefaultWslDistro, parseWslPath, isWslAvailableAsync } from '../wsl' import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' import { injectHistoryEnv, updateHistFileForFallback, @@ -74,6 +75,15 @@ import { createPtySlaveEchoProbe, readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo' +import { + createShellStartupIdentityScanState, + drainShellStartupIdentityHeldBytes, + scanForShellStartupIdentity +} from '../shell-startup-identity-scanner' +import { + createShellPromptReadinessProbe, + type ShellPromptReadinessProbe +} from '../shell-prompt-readiness-probe' import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables @@ -239,9 +249,9 @@ function getWslContextFromWorktreeId( */ function getWslContextFromPreferredDistro( distro: string | null | undefined -): { distro: string } | undefined { +): { distro: string; treatPosixCwdAsWsl: true } | undefined { const trimmed = distro?.trim() - return trimmed ? { distro: trimmed } : undefined + return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined } /** @@ -670,7 +680,9 @@ export class LocalPtyProvider implements IPtyProvider { } ensureNodePtySpawnHelperExecutable() - validateWorkingDirectory(validationCwd) + if (args.prevalidatedCwd !== validationCwd) { + validateWorkingDirectory(validationCwd) + } const spawnEnv: Record = { ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), args.env), @@ -948,6 +960,11 @@ export class LocalPtyProvider implements IPtyProvider { // Shell-ready startup command support let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null let shellReadyTimeout: ReturnType | null = null + let shellStartupPid: number | null = null + let shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null + let shellStartupIdentityScanState = shellReadyLaunch?.supportsReadyMarker + ? createShellStartupIdentityScanState() + : null const shellReadyScanState = shellReadyLaunch?.supportsReadyMarker ? createShellReadyScanState() : null @@ -964,6 +981,8 @@ export class LocalPtyProvider implements IPtyProvider { clearTimeout(shellReadyTimeout) shellReadyTimeout = null } + shellPromptReadinessProbe?.dispose() + shellPromptReadinessProbe = null const resolve = resolveShellReady resolveShellReady = null resolve(signal) @@ -972,12 +991,35 @@ export class LocalPtyProvider implements IPtyProvider { if (!shellReadyScanState) { return } - const heldBytes = drainShellReadyHeldBytes(shellReadyScanState) + let heldBytes = shellStartupIdentityScanState + ? drainShellStartupIdentityHeldBytes(shellStartupIdentityScanState) + : '' + shellStartupIdentityScanState = null + if (heldBytes) { + heldBytes = scanForShellReady(shellReadyScanState, heldBytes).output + } + heldBytes += drainShellReadyHeldBytes(shellReadyScanState) if (heldBytes.length === 0) { return } startupIngress.accept(heldBytes) } + if (shellReadyScanState) { + shellPromptReadinessProbe = createShellPromptReadinessProbe({ + slavePath: readPtySlavePath(proc), + shellPath, + shellCwd: effectiveCwd, + shellPathEnv: finalEnv.PATH, + getShellPid: () => shellStartupPid, + onPromptReady: () => { + console.warn( + `[pty] ${id}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.` + ) + releaseHeldShellReadyBytes() + finishShellReady({ postMarkerBytesObserved: true }) + } + }) + } if (args.command) { if (shellReadyLaunch?.supportsReadyMarker) { shellReadyTimeout = setTimeout(() => { @@ -999,20 +1041,33 @@ export class LocalPtyProvider implements IPtyProvider { startupCommandCleanup?.() startupCommandCleanup = null resolveShellReady = null + shellPromptReadinessProbe?.dispose() + shellPromptReadinessProbe = null }) } const disposables: { dispose: () => void }[] = [] const onDataDisposable = proc.onData((rawData) => { let data = rawData + if (shellStartupIdentityScanState && resolveShellReady) { + const scanned = scanForShellStartupIdentity(shellStartupIdentityScanState, data) + data = scanned.output + if (scanned.shellPid) { + shellStartupPid = scanned.shellPid + shellStartupIdentityScanState = null + } + } if (shellReadyScanState && resolveShellReady) { - const scanned = scanForShellReady(shellReadyScanState, rawData) + const scanned = scanForShellReady(shellReadyScanState, data) data = scanned.output if (scanned.matched) { finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved }) } } startupIngress.accept(data) + if (resolveShellReady && data.length > 0) { + shellPromptReadinessProbe?.notifyOutput(data) + } }) if (onDataDisposable) { disposables.push(onDataDisposable) @@ -1030,6 +1085,8 @@ export class LocalPtyProvider implements IPtyProvider { shellReadyTimeout = null } startupCommandCleanup?.() + shellPromptReadinessProbe?.dispose() + shellPromptReadinessProbe = null clearPtyState(id) startupIngress.drainAndClose() startupIngressByPty.delete(id) @@ -1046,10 +1103,14 @@ export class LocalPtyProvider implements IPtyProvider { ptyDisposables.set(id, disposables) if (args.command && !startupCommandDeliveredInShellArgs) { - // Why: only POSIX bash/zsh have bracketed-paste armed so multiline startup prompts paste literally; others use raw submit. + // Why: shells with bracketed paste armed take a multiline startup prompt literally; others use raw submit. const spawnedShellName = getSpawnedShellName(shellPath).toLowerCase() const bracketedPasteSafe = - process.platform !== 'win32' && (spawnedShellName === 'bash' || spawnedShellName === 'zsh') + process.platform !== 'win32' && + isBracketedPasteSafeShell({ + shellName: spawnedShellName, + waitsForShellReady: shellReadyLaunch?.supportsReadyMarker === true + }) writeStartupCommandWhenShellReady( shellReadyPromise, proc, diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 1cb10ad7f14..a35eae779da 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -485,6 +485,7 @@ describePosix('local PTY shell-ready launch config', () => { const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;') expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') expectZdotdirSourceContext(zprofile, '.zprofile') @@ -547,6 +548,12 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshrc).toContain(ompWrapperLine) expect(zlogin).toContain(ompWrapperLine) expect(bashRc).toContain(ompWrapperLine) + for (const wrapperFile of [zshrc, zlogin, bashRc]) { + expect(wrapperFile).not.toContain('prime-agent()') + expect(wrapperFile).not.toContain('__orca_prime_agent') + expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') + expect(wrapperFile).not.toContain('command prime-agent --extension') + } }) // Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited. diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 18052dfa670..28c56b27d1f 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -21,6 +21,7 @@ import { getZshEnvTemplate, getZshFinalZdotdirRestoreBlock, getZshShellReadyMarkerRegistrationBlock, + SHELL_STARTUP_IDENTITY_MARKER_BLOCK, getZshStartupFileSourceBlock } from '../shell-templates' export { @@ -92,6 +93,7 @@ function resolveOriginalZshenvSourceDir(): string { export function getBashShellReadyRcfileContent(): string { return `# Orca bash shell-ready wrapper +${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} [[ -f /etc/profile ]] && source /etc/profile if [[ -f "$HOME/.bash_profile" ]]; then source "$HOME/.bash_profile" @@ -368,7 +370,8 @@ function getWrappedShellLaunchConfig( ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(), ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(), ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`, - ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0' + ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0', + ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0' }, supportsReadyMarker: options.emitReadyMarker } @@ -379,7 +382,8 @@ function getWrappedShellLaunchConfig( return { args: ['--rcfile', `${getShellReadyWrapperRoot()}/bash/rcfile`], env: { - ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0' + ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0', + ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0' }, supportsReadyMarker: options.emitReadyMarker } @@ -429,7 +433,7 @@ export function writeStartupCommandWhenShellReady( proc: pty.IPty, startupCommand: string, onExit: (cleanup: () => void) => void, - // Why: only Orca-wrapped bash/zsh have bracketed-paste active; other shells use the raw path to avoid echoing the ESC[200~ markers. + // Why: only shells with bracketed-paste active (see isBracketedPasteSafeShell) accept the wrapper; others use the raw path so ESC[200~ isn't echoed. options: { bracketedPasteSafe?: boolean } = {} ): void { let sent = false diff --git a/src/main/providers/local-pty-utils-windows-fallback.test.ts b/src/main/providers/local-pty-utils-windows-fallback.test.ts index 0de16d03f17..a71537ba8b2 100644 --- a/src/main/providers/local-pty-utils-windows-fallback.test.ts +++ b/src/main/providers/local-pty-utils-windows-fallback.test.ts @@ -133,18 +133,28 @@ describe('spawnShellWithFallback on Windows', () => { const ptySpawn = vi.fn(() => { throw new Error(ACCESS_DENIED_5) }) as unknown as typeof pty.spawn + const previousVersion = process.env.ORCA_APP_VERSION + process.env.ORCA_APP_VERSION = '1.4.178-test' - expect(() => - spawnShellWithFallback({ - shellPath: PWSH7, - shellArgs: attempts[0].shellArgs, - cols: 80, - rows: 24, - cwd: 'C:\\repo', - env: {}, - ptySpawn, - windowsFallbackAttempts: attempts - }) - ).toThrow(/Failed to spawn shell/) + try { + expect(() => + spawnShellWithFallback({ + shellPath: PWSH7, + shellArgs: attempts[0].shellArgs, + cols: 80, + rows: 24, + cwd: 'C:\\repo', + env: {}, + ptySpawn, + windowsFallbackAttempts: attempts + }) + ).toThrow(/Failed to spawn shell.*orca: 1\.4\.178-test/) + } finally { + if (previousVersion === undefined) { + delete process.env.ORCA_APP_VERSION + } else { + process.env.ORCA_APP_VERSION = previousVersion + } + } }) }) diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index bc812e1a46a..6b4846617ca 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -93,9 +93,21 @@ describe('validateWorkingDirectory', () => { it('rejects a missing native Windows path', () => { existsSyncMock.mockReturnValue(false) + const previousVersion = process.env.ORCA_APP_VERSION + process.env.ORCA_APP_VERSION = '1.4.178-test' - expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow(/does not exist/) - expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + try { + expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow( + /does not exist.*orca: 1\.4\.178-test/ + ) + expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + } finally { + if (previousVersion === undefined) { + delete process.env.ORCA_APP_VERSION + } else { + process.env.ORCA_APP_VERSION = previousVersion + } + } }) it('rejects a native Windows path that exists but is not a directory', () => { diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 3e21095d535..cb414d05a8e 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,5 +1,6 @@ import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' +import { release } from 'node:os' import type * as pty from 'node-pty' import { isWslUncPath } from '../../shared/wsl-paths' import { wslUncDirectoryExists } from '../wsl' @@ -99,10 +100,26 @@ export function ensureNodePtySpawnHelperExecutable(): void { } } +function formatLocalPtyEnvironmentDiag(extra: Record = {}): string { + const systemVersion = + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || + release() + const parts = { + ...extra, + arch: process.arch, + platform: `${process.platform} ${systemVersion}`, + orca: process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' + } + return Object.entries(parts) + .map(([key, value]) => `${key}: ${value}`) + .join(', ') +} + function throwMissingWorkingDirectory(cwd: string): never { throw new Error( `Working directory "${cwd}" does not exist. ` + - `It may have been deleted or is on an unmounted volume.` + `It may have been deleted or is on an unmounted volume ` + + `(${formatLocalPtyEnvironmentDiag({ cwd })}).` ) } @@ -305,12 +322,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu } } - const diag = [ - `shell: ${shellPath}`, - `cwd: ${cwd}`, - `arch: ${process.arch}`, - `platform: ${process.platform} ${process.getSystemVersion?.() ?? ''}` - ].join(', ') + const diag = formatLocalPtyEnvironmentDiag({ shell: shellPath, cwd }) throw new Error( `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ` + `If this persists, please file an issue.` diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 02b1d9acf1e..09f3cda62fd 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -19,6 +19,8 @@ export type { export type PtyProviderBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string /** Authoritative normal buffer captured beside an alternate-screen frame. */ scrollbackAnsi?: string cols: number @@ -30,12 +32,18 @@ export type PtyProviderBufferSnapshot = { oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean pendingEscapeTailAnsi?: string + /** Effective kitty keyboard flags PROVEN at this snapshot's own `seq` + * boundary. Absent means the source could not prove them; readers must not + * rewrite that silence into a known `0`. */ + kittyKeyboardFlags?: number } export type PtySpawnOptions = { cols: number rows: number cwd?: string + /** Exact per-spawn cwd already proven by main; providers validate any other resolved path. */ + prevalidatedCwd?: string env?: Record envToDelete?: string[] /** Main-validated home provenance for an automatic Codex session resume. */ diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 235672e05e7..ebed657ee21 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -32,6 +32,12 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Normal-buffer history and mode preamble before an alternate-screen frame. */ + snapshotPrefixAnsi?: string + /** Visual alternate-screen frame, separate so newer clients can omit it safely. */ + snapshotFrameAnsi?: string + /** Live state to append when omitting `snapshotFrameAnsi`. */ + snapshotFrameRestoreAnsi?: string /** Provider sequence at the attach boundary. `reset` starts a new provider * generation; `continued` resumes the existing absolute domain. */ providerSequence?: { @@ -43,6 +49,9 @@ export type PtySpawnResult = { * (terminal-query-authority.md §kitty). Never replayed into a renderer * xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */ snapshotKittyKeyboardFlags?: number + /** Renderer-domain sequence main reconciled for the attach boundary those + * flags describe. Set by main, not the provider. */ + snapshotSeq?: number /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean /** Last OSC title tracked by the daemon session the snapshot came from. diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index c0613f35f2e..20ebd9293a1 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -1081,6 +1081,125 @@ describe('SshGitProvider', () => { expect(result).toEqual(diffs) }) + it('getBranchDiff forwards a pinned head OID without adding it when omitted', async () => { + const headOid = 'a'.repeat(40) + mux.request.mockResolvedValue([]) + + await provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid + }) + await provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/other-file.ts', + headOid: undefined + }) + + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.branchDiff', { + worktreePath: '/home/user/repo', + baseRef: 'main', + includePatch: true, + filePath: 'src/file.ts', + headOid, + __streamResponse: true + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.branchDiff', { + worktreePath: '/home/user/repo', + baseRef: 'main', + includePatch: true, + filePath: 'src/other-file.ts', + __streamResponse: true + }) + }) + + // Why: an unpinned compare snapshot reaches getBranchDiff as null despite the type. + it('omits a null head OID and shares the absent-head dedupe key', async () => { + const diffs = [{ kind: 'text', originalContent: 'old', modifiedContent: 'new' }] + const pendingDiff = deferredValue(diffs) + const headOid = 'a'.repeat(40) + mux.request.mockReturnValue(pendingDiff.promise) + + const reads = [ + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid: null as unknown as undefined + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts' + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid + }) + ] + + await waitForRequestCount(mux.request, 2) + expect(mux.request).toHaveBeenCalledTimes(2) + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.branchDiff', { + worktreePath: '/home/user/repo', + baseRef: 'main', + includePatch: true, + filePath: 'src/file.ts', + __streamResponse: true + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.branchDiff', { + worktreePath: '/home/user/repo', + baseRef: 'main', + includePatch: true, + filePath: 'src/file.ts', + headOid, + __streamResponse: true + }) + + pendingDiff.resolve() + await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 3 }, () => diffs)) + }) + + it('coalesces matching branch diff heads while keeping distinct and absent heads separate', async () => { + const diffs = [{ kind: 'text', originalContent: 'old', modifiedContent: 'new' }] + const pendingDiff = deferredValue(diffs) + const firstHeadOid = 'a'.repeat(40) + const secondHeadOid = 'b'.repeat(40) + mux.request.mockReturnValue(pendingDiff.promise) + + const reads = [ + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid: firstHeadOid + }), + provider.getBranchDiff('/home/user/repo', 'main', { + filePath: 'src/file.ts', + headOid: firstHeadOid, + includePatch: true + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid: secondHeadOid + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts' + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid: undefined + }) + ] + + await waitForRequestCount(mux.request, 3) + expect(mux.request).toHaveBeenCalledTimes(3) + + pendingDiff.resolve() + await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 5 }, () => diffs)) + }) + it('coalesces concurrent identical diff RPCs while in flight', async () => { const diff = { kind: 'text', diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index e4c306529ee..51e2b91e1c2 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -41,6 +41,7 @@ type NonInteractiveExecQueueEntry = { } const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 +const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const function isJsonRpcMethodNotFoundError(error: unknown): boolean { if (!error || typeof error !== 'object') { @@ -658,9 +659,14 @@ export class SshGitProvider implements IGitProvider { async getBranchDiff( worktreePath: string, baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string } + options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } ): Promise { const keyOptions = options ?? {} + const { headOid: rawHeadOid, ...relayOptions } = keyOptions + // Why: compare snapshots type headOid as `string | null`, so collapse an + // unpinned null to absent instead of putting a field on the wire that a + // pinned-OID relay must reject. + const headOid = rawHeadOid == null ? undefined : rawHeadOid return this.gitDiffReadDedupe.run( stableInFlightKey([ 'branchDiff', @@ -668,13 +674,15 @@ export class SshGitProvider implements IGitProvider { baseRef, keyOptions.includePatch ?? null, keyOptions.filePath ?? null, - keyOptions.oldPath ?? null + keyOptions.oldPath ?? null, + headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid ]), async () => (await requestGitStreamable(this.mux, 'git.branchDiff', { worktreePath, baseRef, - ...options + ...relayOptions, + ...(headOid === undefined ? {} : { headOid }) })) as GitDiffResult[] ) as Promise } diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index 7e4cdd2e48d..d5e8f8185cd 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -112,7 +112,7 @@ function parseWindowsProcessValueRows(stdout: string): WindowsProcessRow[] { continue } const eq = line.indexOf('=') - if (eq < 0) { + if (eq === -1) { continue } const key = line.slice(0, eq) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 39cb81ee9b5..534bfc90085 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -219,7 +219,7 @@ describe('resolveWindowsShellLaunchArgs', () => { it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => { const result = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Users\\alice\\code', + '/c', 'C:\\Users\\alice' ) @@ -237,8 +237,8 @@ describe('resolveWindowsShellLaunchArgs', () => { // Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the // exec and kill the terminal on startup. expect(bashCommand).not.toContain('&&') - expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code') - expect(result.validationCwd).toBe('C:\\Users\\alice\\code') + expect(result.effectiveCwd).toBe('C:\\') + expect(result.validationCwd).toBe('C:\\') }) it('does not apply Git Bash launch args to unrelated bash.exe paths', () => { @@ -280,13 +280,17 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true) expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true) - // Why: the point of materializing wrappers for WSL is that a typed `omp` - // picks up Orca's status extension; pin that shim end to end. + // Why: typed OMP keeps its existing shell integration, while typed Prime + // commands must reach the user's binary without Orca rewriting argv. const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') for (const wrapperFile of [bashRcfile, zshLogin]) { expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') + expect(wrapperFile).not.toContain('prime-agent()') + expect(wrapperFile).not.toContain('__orca_prime_agent') + expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') + expect(wrapperFile).not.toContain('command prime-agent --extension') } }) @@ -304,17 +308,15 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\project') }) - it('does not treat MSYS drive cwd as a WSL POSIX cwd', () => { - const result = resolveWindowsShellLaunchArgs( - 'wsl.exe', - '/c/Users/alice/project', - 'C:\\Users\\alice', - { distro: 'Ubuntu', treatPosixCwdAsWsl: true } - ) + it.each(['/a', '/c'])('keeps a selected WSL runtime single-letter cwd exact (%s)', (cwd) => { + const result = resolveWindowsShellLaunchArgs('wsl.exe', cwd, 'C:\\Users\\alice', { + distro: 'Ubuntu', + treatPosixCwdAsWsl: true + }) - expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project', 'Ubuntu')) + expect(result.shellArgs).toEqual(expectedWslArgs(cwd, 'Ubuntu')) expect(result.effectiveCwd).toBe('C:\\Users\\alice') - expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + expect(result.validationCwd).toBe(`\\\\wsl.localhost\\Ubuntu\\${cwd.slice(1)}`) }) it('escapes single quotes when translating a WSL cwd', () => { diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index bde8a872efb..772ef8d9f05 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -131,7 +131,8 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { return distro ? ['-d', distro, ...shellArgs] : shellArgs } -function normalizeMsysDrivePath(cwd: string): string { +/** Converts an MSYS drive spelling to the native cwd used by Windows terminal processes. */ +export function normalizeWindowsTerminalCwd(cwd: string): string { const match = cwd.match(/^\/([A-Za-z])(?:\/(.*))?$/) if (!match) { return cwd @@ -161,8 +162,7 @@ export function resolveWindowsShellLaunchArgs( startupCommand?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() - const nativeCwd = normalizeMsysDrivePath(cwd) - const isMsysDriveCwd = nativeCwd !== cwd + const nativeCwd = normalizeWindowsTerminalCwd(cwd) if (shellBasename === 'cmd.exe') { const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) @@ -210,7 +210,7 @@ export function resolveWindowsShellLaunchArgs( validationCwd: cwd } } - if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/') && !isMsysDriveCwd) { + if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) { return { shellArgs: buildWslShellArgs(cwd, wslContext.distro), effectiveCwd: defaultCwd, diff --git a/src/main/pty/posix-pty-foreground-group.ts b/src/main/pty/posix-pty-foreground-group.ts index 124978c269d..8b3e375dbff 100644 --- a/src/main/pty/posix-pty-foreground-group.ts +++ b/src/main/pty/posix-pty-foreground-group.ts @@ -103,7 +103,7 @@ export function getPosixPtyForegroundGroup( export function signalPosixPtyForegroundGroup( rootPid: number, ptsName: string | undefined, - signal: NodeJS.Signals | string, + signal: NodeJS.Signals | (string & {}), fallback: () => void, deps: PosixPtyForegroundGroupDeps = {} ): void { diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts index 73aabc3b753..eaf8cffeba4 100644 --- a/src/main/pty/shell-startup-env.test.ts +++ b/src/main/pty/shell-startup-env.test.ts @@ -1,28 +1,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { existsSyncMock, readFileSyncMock } = vi.hoisted(() => ({ +const { existsSyncMock, readFileSyncMock, readdirSyncMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), - readFileSyncMock: vi.fn() + readFileSyncMock: vi.fn(), + readdirSyncMock: vi.fn() })) vi.mock('fs', () => ({ existsSync: existsSyncMock, - readFileSync: readFileSyncMock + readFileSync: readFileSyncMock, + readdirSync: readdirSyncMock })) import { __resetShellStartupEnvCache, isShellStartupEnvProbeSupported, + readSessionShellStartupEnvVar, readShellStartupEnvVar } from './shell-startup-env' describe('readShellStartupEnvVar', () => { const originalPlatform = process.platform const originalShell = process.env.SHELL + // Why pinned: the fish branch defaults configHome to process.env.XDG_CONFIG_HOME, which CI + // runners set and dev machines usually do not — leaving it ambient makes these mocked paths + // resolve differently per host. + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME beforeEach(() => { + delete process.env.XDG_CONFIG_HOME existsSyncMock.mockReset() readFileSyncMock.mockReset() + readdirSyncMock.mockReset() + // Why: only the fish branch lists a directory; every other case must see ENOENT. + readdirSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) process.env.SHELL = '/bin/zsh' __resetShellStartupEnvCache() @@ -35,6 +48,11 @@ describe('readShellStartupEnvVar', () => { } else { process.env.SHELL = originalShell } + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome + } }) function mockStartupFiles(files: Record) { @@ -326,4 +344,218 @@ describe('readShellStartupEnvVar', () => { mockStartupFiles({ '.zshrc': '# export OPENCODE_CONFIG_DIR=/from-comment\n' }) expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() }) + + describe('fish', () => { + const FISH = '/opt/homebrew/bin/fish' + + function mockFishFiles(files: Record, snippets: string[] = []) { + readdirSyncMock.mockImplementation((dir: string) => { + if (dir === '/home/alice/.config/fish/conf.d' || dir === '/cfg/fish/conf.d') { + return snippets + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockStartupFiles(files) + } + + it('reads an exported set from config.fish', () => { + mockFishFiles({ + '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /home/alice/.codex\n' + }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex') + }) + + it('lets config.fish win over a conf.d snippet, matching fish source order', () => { + mockFishFiles( + { + '/home/alice/.config/fish/conf.d/10-agents.fish': 'set -gx CODEX_HOME /from/confd\n', + '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/config\n' + }, + ['10-agents.fish'] + ) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/config') + }) + + it('sources conf.d snippets in filename order', () => { + mockFishFiles( + { + '/home/alice/.config/fish/conf.d/aaa.fish': 'set -gx CODEX_HOME /from/aaa\n', + '/home/alice/.config/fish/conf.d/zzz.fish': 'set -gx CODEX_HOME /from/zzz\n' + }, + ['zzz.fish', 'aaa.fish', 'notes.txt'] + ) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/zzz') + }) + + it('honors XDG_CONFIG_HOME', () => { + mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/xdg\n' }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, '/cfg')).toBe('/from/xdg') + }) + + // Why also via the env: the argument defaults to process.env.XDG_CONFIG_HOME, so without + // this every other fish case here silently depends on the host not exporting it. + it('defaults configHome to XDG_CONFIG_HOME from the environment', () => { + process.env.XDG_CONFIG_HOME = '/cfg' + __resetShellStartupEnvCache() + mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/env-xdg\n' }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/env-xdg') + }) + + it('ignores sets that are not exported', () => { + mockFishFiles({ + '/home/alice/.config/fish/config.fish': + 'set -g CODEX_HOME /global\nset -l CODEX_HOME /local\nset CODEX_HOME /plain\n' + }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined() + }) + + it('accepts every fish export spelling', () => { + for (const line of [ + 'set -x CODEX_HOME /a', + 'set -xg CODEX_HOME /a', + 'set -gx CODEX_HOME /a', + 'set -Ux CODEX_HOME /a', + 'set --export --global CODEX_HOME /a' + ]) { + __resetShellStartupEnvCache() + mockFishFiles({ '/home/alice/.config/fish/config.fish': `${line}\n` }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/a') + } + }) + + it('expands $HOME in double quotes and keeps single quotes literal', () => { + mockFishFiles({ + '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex" # note\n' + }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex') + + __resetShellStartupEnvCache() + mockFishFiles({ + '/home/alice/.config/fish/config.fish': "set -gx CODEX_HOME '$HOME/.codex'\n" + }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('$HOME/.codex') + }) + + it('does not read zsh or bash startup files for a fish user', () => { + mockFishFiles({ + '/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n', + '/home/alice/.bash_profile': 'export CODEX_HOME=/from/bash\n' + }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined() + }) + + it('ignores a commented assignment', () => { + mockFishFiles({ + '/home/alice/.config/fish/config.fish': '# set -gx CODEX_HOME /from-comment\n' + }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined() + }) + + it('does not match a different variable with the same prefix', () => { + mockFishFiles({ + '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME_BACKUP /backup\n' + }) + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined() + }) + }) + + // Why these matter: a caller that plumbs HOME/SHELL but forgets XDG_CONFIG_HOME + // reads a different fish config than the shell will, so the same user gets one + // answer locally and another over relay. + describe('readSessionShellStartupEnvVar', () => { + const FISH = '/opt/homebrew/bin/fish' + const savedConfigHome = process.env.XDG_CONFIG_HOME + const savedHome = process.env.HOME + + afterEach(() => { + restoreEnv('XDG_CONFIG_HOME', savedConfigHome) + restoreEnv('HOME', savedHome) + }) + + function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + + it("prefers the session env's XDG_CONFIG_HOME over the main process's", () => { + process.env.XDG_CONFIG_HOME = '/main-process-cfg' + mockStartupFiles({ + '/session-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/session\n', + '/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n' + }) + + expect( + readSessionShellStartupEnvVar('CODEX_HOME', { + HOME: '/home/alice', + SHELL: FISH, + XDG_CONFIG_HOME: '/session-cfg' + }) + ).toBe('/from/session') + }) + + it('falls back to the main process XDG_CONFIG_HOME when the session env lacks one', () => { + process.env.XDG_CONFIG_HOME = '/main-process-cfg' + mockStartupFiles({ + '/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n' + }) + + expect( + readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH }) + ).toBe('/from/main-process') + }) + + it("falls back to fish's own ~/.config default when neither env has one", () => { + delete process.env.XDG_CONFIG_HOME + mockStartupFiles({ + '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/default\n' + }) + + expect( + readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH }) + ).toBe('/from/default') + }) + + it('resolves against the session HOME, not the main process HOME', () => { + delete process.env.XDG_CONFIG_HOME + process.env.HOME = '/home/root-user' + mockStartupFiles({ + '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex"\n', + '/home/root-user/.config/fish/config.fish': 'set -gx CODEX_HOME /from/wrong-home\n' + }) + + expect( + readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH }) + ).toBe('/home/alice/.codex') + }) + + it('lets an explicit shell override beat the session SHELL', () => { + delete process.env.XDG_CONFIG_HOME + mockStartupFiles({ + '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/fish\n', + '/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n' + }) + + expect( + readSessionShellStartupEnvVar( + 'CODEX_HOME', + { HOME: '/home/alice', SHELL: '/bin/zsh' }, + FISH + ) + ).toBe('/from/fish') + }) + + it('falls back to the main process HOME and SHELL with no session env at all', () => { + delete process.env.XDG_CONFIG_HOME + process.env.HOME = '/home/alice' + process.env.SHELL = FISH + mockStartupFiles({ + '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/process-env\n' + }) + + expect(readSessionShellStartupEnvVar('CODEX_HOME', undefined)).toBe('/from/process-env') + }) + }) }) diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts index f423ef38f22..ad6392160a9 100644 --- a/src/main/pty/shell-startup-env.ts +++ b/src/main/pty/shell-startup-env.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { posix } from 'node:path' // Why: only files the user's actual shell would source. Mixing zsh and bash @@ -11,24 +11,45 @@ const ZSH_AFTER_ENV_FILES = ['.zprofile', '.zshrc', '.zlogin'] // .bash_profile / .bash_login / .profile but intentionally does NOT force // .bashrc. Scanning .bashrc would mirror values the live Orca bash never sees. const BASH_LOGIN_FILES = ['.bash_profile', '.bash_login', '.profile'] +// Why: fish sources conf.d/*.fish (sorted by name) before config.fish, for +// login and non-login shells alike — verified against fish 4.7. +const FISH_SNIPPET_DIR = 'conf.d' +const FISH_SNIPPET_SUFFIX = '.fish' +const FISH_CONFIG_FILE = 'config.fish' + +/** Assignment grammar of a startup file: `export NAME=value` vs fish `set -gx NAME value`. */ +type StartupFileSyntax = 'export' | 'fish-set' + +type ShellStartupFiles = { + paths: readonly string[] + syntax: StartupFileSyntax +} export function isShellStartupEnvProbeSupported(): boolean { return process.platform !== 'win32' } -function parseExportedValue(content: string, name: string, home: string): string | undefined { - const assignment = new RegExp(`^export\\s+${name}=(.+)$`) +function parseAssignedValue( + content: string, + name: string, + home: string, + syntax: StartupFileSyntax +): string | undefined { + const assignment = + syntax === 'fish-set' + ? new RegExp(`^set\\s+((?:-{1,2}[A-Za-z][\\w-]*\\s+)+)${name}\\s+(.+)$`) + : new RegExp(`^export\\s+${name}=()(.+)$`) let lastMatch: string | undefined for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim() const match = assignment.exec(line) - if (!match?.[1]) { + if (!match?.[2] || (syntax === 'fish-set' && !fishFlagsExport(match[1] ?? ''))) { continue } // Why: strip trailing unquoted `# comment` first so quoted values like // `"$HOME/.opencode" # note` survive intact for unquoteShellValue. - const decommented = stripTrailingComment(match[1]) + const decommented = stripTrailingComment(match[2]) const { text, quoted } = unquoteShellValue(decommented) // Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would // do for double-quoted and unquoted values; single-quoted is literal. @@ -41,6 +62,21 @@ function parseExportedValue(content: string, name: string, home: string): string return lastMatch } +function parseExportedValue(content: string, name: string, home: string): string | undefined { + return parseAssignedValue(content, name, home, 'export') +} + +// Why: only `set -x` / `--export` reaches child processes; `set -g`, `set -l` +// and function-local sets never appear in the PTY's environment. +function fishFlagsExport(flags: string): boolean { + return flags + .trim() + .split(/\s+/) + .some((flag) => + flag.startsWith('--') ? flag === '--export' : /^-[A-Za-z]*x[A-Za-z]*$/.test(flag) + ) +} + function readStartupFile(path: string): string | null { if (!existsSync(path)) { return null @@ -52,23 +88,55 @@ function readStartupFile(path: string): string | null { } } -function shellStartupFilePaths(home: string, shell: string | undefined): readonly string[] { +function shellStartupFiles( + home: string, + shell: string | undefined, + configHome: string | undefined +): ShellStartupFiles { if (!shell) { // Why: Orca's POSIX default shell is /bin/zsh when $SHELL is unset. - return zshStartupFilePaths(home) + return { paths: zshStartupFilePaths(home), syntax: 'export' } } const name = posix.basename(shell).toLowerCase() if (name === 'zsh') { - return zshStartupFilePaths(home) + return { paths: zshStartupFilePaths(home), syntax: 'export' } } if (name === 'bash') { - return BASH_LOGIN_FILES.map((file) => posix.join(home, file)) + return { + paths: BASH_LOGIN_FILES.map((file) => posix.join(home, file)), + syntax: 'export' + } } - // Why: unsupported explicit shells (fish, nushell, custom wrappers) do not - // use Orca's zsh/bash shell-ready startup files, so scanning those files + if (name === 'fish') { + return { + paths: fishStartupFilePaths(home, configHome), + syntax: 'fish-set' + } + } + // Why: unsupported explicit shells (nushell, custom wrappers) do not use + // Orca's zsh/bash/fish shell-ready startup files, so scanning those files // would mirror values the live PTY shell never sees. - return [] + return { paths: [], syntax: 'export' } +} + +function fishStartupFilePaths(home: string, configHome: string | undefined): readonly string[] { + const fishDir = posix.join(configHome?.trim() || posix.join(home, '.config'), 'fish') + return [ + ...fishSnippetPaths(posix.join(fishDir, FISH_SNIPPET_DIR)), + posix.join(fishDir, FISH_CONFIG_FILE) + ] +} + +function fishSnippetPaths(snippetDir: string): readonly string[] { + try { + return readdirSync(snippetDir) + .filter((entry) => entry.endsWith(FISH_SNIPPET_SUFFIX)) + .sort() + .map((entry) => posix.join(snippetDir, entry)) + } catch { + return [] + } } function zshStartupFilePaths(home: string): readonly string[] { @@ -139,21 +207,24 @@ const cache = new Map() * Limits (callers should treat the result as a hint, not authoritative): * - Conditionals (`[[ ... ]] && export FOO=...`), sourced files, and * `$VAR` substitution beyond `$HOME` / `${HOME}` / `~` are not evaluated. - * - Bare assignments (no `export` keyword) are ignored because POSIX shells - * do not export them to child processes. + * - Bare assignments (no `export` keyword, or fish `set` without `-x`) are + * ignored because they never reach child processes. * - Files are scanned in shell evaluation order for the user's $SHELL family - * only (zsh OR bash, not both); unsupported explicit shells scan nothing. - * LAST matching assignment wins. + * only (zsh OR bash OR fish, never mixed); unsupported explicit shells scan + * nothing. LAST matching assignment wins. + * - fish universal variables (`set -Ux` stored in fish_variables) are only + * seen when the assignment is also written in a config file. * - Windows is unsupported (PowerShell profile parsing is out of scope). * - * Results are memoized per (name, home, shell) for the process lifetime — - * shell startup files do not change mid-session in any practical scenario, - * and PTY spawn is on the hot path. + * Results are memoized per (name, home, shell, configHome) for the process + * lifetime — shell startup files do not change mid-session in any practical + * scenario, and PTY spawn is on the hot path. */ export function readShellStartupEnvVar( name: string, home = process.env.HOME, - shell = process.env.SHELL + shell = process.env.SHELL, + configHome = process.env.XDG_CONFIG_HOME ): string | undefined { if (!home || !isShellStartupEnvProbeSupported()) { return undefined @@ -164,20 +235,21 @@ export function readShellStartupEnvVar( return undefined } - const cacheKey = `${name}\0${home}\0${shell ?? ''}` + const cacheKey = `${name}\0${home}\0${shell ?? ''}\0${configHome ?? ''}` if (cache.has(cacheKey)) { return cache.get(cacheKey) } let lastMatch: string | undefined - for (const path of shellStartupFilePaths(home, shell)) { + const { paths, syntax } = shellStartupFiles(home, shell, configHome) + for (const path of paths) { const content = readStartupFile(path) if (content === null) { continue } - const match = parseExportedValue(content, name, home) + const match = parseAssignedValue(content, name, home, syntax) if (match !== undefined) { lastMatch = match } @@ -187,6 +259,34 @@ export function readShellStartupEnvVar( return lastMatch } +/** The env keys that decide which startup files a shell would actually source. */ +export type ShellStartupEnvSource = { + HOME?: string | undefined + SHELL?: string | undefined + XDG_CONFIG_HOME?: string | undefined +} + +/** + * Same probe, resolved against a session/PTY env with process.env as fallback. + * + * Why this exists rather than three inline `??` chains per call site: dropping + * XDG_CONFIG_HOME silently scans a *different* fish config than the shell will, + * so local launches and relay launches disagree about the same user's + * `set -gx` — a divergence with no visible symptom until it is wrong. + */ +export function readSessionShellStartupEnvVar( + name: string, + sessionEnv: ShellStartupEnvSource | undefined, + shellOverride?: string +): string | undefined { + return readShellStartupEnvVar( + name, + sessionEnv?.HOME ?? process.env.HOME, + shellOverride ?? sessionEnv?.SHELL ?? process.env.SHELL, + sessionEnv?.XDG_CONFIG_HOME ?? process.env.XDG_CONFIG_HOME + ) +} + /** * Test-only helper to reset the per-process cache between cases. * Why: production callers never invalidate (rc files don't change at diff --git a/src/main/pty/windows-environment-path-main-loop.test.ts b/src/main/pty/windows-environment-path-main-loop.test.ts new file mode 100644 index 00000000000..fbe185479cc --- /dev/null +++ b/src/main/pty/windows-environment-path-main-loop.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __resetPersistedWindowsPathCacheForTests, + readPersistedWindowsPathSegments +} from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' + +const CREATE_PROCESS_DELAY_MS = 160 +const delayedExecFileSync = vi.hoisted(() => + vi.fn(() => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, CREATE_PROCESS_DELAY_MS) + return ' Path REG_EXPAND_SZ C:\\Delayed' + }) +) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), + execFileSync: delayedExecFileSync +})) + +describe('persisted Windows PATH process creation', () => { + afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() + delayedExecFileSync.mockClear() + }) + + async function measureMainLoopGap(run: () => T): Promise<{ + callMs: number + maxGapMs: number + result: T + }> { + let lastTick = performance.now() + let maxGapMs = 0 + const timer = setInterval(() => { + const now = performance.now() + maxGapMs = Math.max(maxGapMs, now - lastTick - 5) + lastTick = now + }, 5) + await new Promise((resolve) => setTimeout(resolve, 20)) + + const startedAt = performance.now() + const result = run() + const callMs = performance.now() - startedAt + await new Promise((resolve) => setTimeout(resolve, 20)) + clearInterval(timer) + return { callMs, maxGapMs, result } + } + + it('proves delayed process creation blocks while native registry reads create no process', async () => { + const legacy = await measureMainLoopGap(() => + readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' }, + execFileSync: delayedExecFileSync as never + }) + ) + + expect(legacy.result).toEqual(['C:\\Delayed', 'C:\\Delayed']) + expect(delayedExecFileSync).toHaveBeenCalledTimes(2) + expect(legacy.callMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS * 2) + expect(legacy.maxGapMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS) + + __resetPersistedWindowsPathCacheForTests() + delayedExecFileSync.mockClear() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: (root) => ({ + Path: { type: 1, value: root === 1 ? 'C:\\Machine' : 'C:\\User' } + }) + })) + const native = readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' } + }) + + expect(native).toEqual(['C:\\Machine', 'C:\\User']) + expect(delayedExecFileSync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index d639ee604ce..4aaa7f91bdd 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { defaultExecFileMock, defaultExecFileSyncMock } = vi.hoisted(() => ({ defaultExecFileMock: vi.fn(), @@ -12,14 +12,33 @@ vi.mock('node:child_process', () => ({ import { __resetPersistedWindowsPathCacheForTests, + invalidatePersistedWindowsPathCache, mergePersistedWindowsPath, mergePersistedWindowsPathAsync, readPersistedWindowsPathSegments, readPersistedWindowsPathSegmentsAsync, resolvePathEnvKey } from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' -type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void +const registryGetKeyMock = vi.fn() + +function registryPath(value: string): Record { + return { Path: { type: 1, value } } +} + +beforeEach(() => { + registryGetKeyMock.mockReset() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: registryGetKeyMock + })) +}) + +afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() +}) describe('readPersistedWindowsPathSegments', () => { it('reads machine and user Path values from the Windows registry', () => { @@ -67,18 +86,17 @@ describe('readPersistedWindowsPathSegments', () => { it('caches production registry reads briefly', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -86,27 +104,185 @@ describe('readPersistedWindowsPathSegments', () => { it('force-refreshes the production registry cache', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User;C:\\Program Files\\GitHub CLI\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User;C:\\Program Files\\GitHub CLI')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ 'C:\\Machine', 'C:\\User', 'C:\\Program Files\\GitHub CLI' ]) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(4) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('invalidates the cache after Windows reports an environment change', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockReturnValueOnce(registryPath('C:\\OldUser')) + .mockReturnValueOnce(registryPath('C:\\NewMachine')) + .mockReturnValueOnce(registryPath('C:\\NewUser')) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) + + invalidatePersistedWindowsPathCache() + + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\NewUser']) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('combines a fresh hive with the other hive last known good after invalidation', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockReturnValueOnce(registryPath('C:\\OldUser')) + .mockReturnValueOnce(registryPath('C:\\NewMachine')) + .mockImplementationOnce(() => { + throw new Error('user registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) + invalidatePersistedWindowsPathCache() + + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\OldUser']) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('combines a fresh user hive with the machine hive last known good', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockReturnValueOnce(registryPath('C:\\OldUser')) + .mockImplementationOnce(() => { + throw new Error('machine registry unavailable') + }) + .mockReturnValueOnce(registryPath('C:\\NewUser')) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) + invalidatePersistedWindowsPathCache() + + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\NewUser']) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('withholds a partial first registry snapshot instead of changing PATH precedence', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockImplementationOnce(() => { + throw new Error('user registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + const env = { Path: 'C:\\UserAlias;C:\\Existing' } + + mergePersistedWindowsPath(env) + + expect(env.Path).toBe('C:\\UserAlias;C:\\Existing') + expect(readPersistedWindowsPathSegments()).toEqual([]) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('withholds the reverse partial first snapshot', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockImplementationOnce(() => { + throw new Error('machine registry unavailable') + }) + .mockReturnValueOnce(registryPath('C:\\User')) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual([]) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('does not duplicate an unresolved asynchronous refresh for a synchronous caller', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockImplementationOnce(() => { + throw new Error('user registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + const refresh = readPersistedWindowsPathSegmentsAsync() + + expect(readPersistedWindowsPathSegments()).toEqual([]) + await expect(refresh).resolves.toEqual([]) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('does not let injected test reads populate production fallback state', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\InjectedMachine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\InjectedUser\r\n') + registryGetKeyMock.mockImplementation(() => { + throw new Error('registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments({ platform: 'win32', execFileSync })).toEqual([ + 'C:\\InjectedMachine', + 'C:\\InjectedUser' + ]) + expect(readPersistedWindowsPathSegments()).toEqual([]) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -114,9 +290,9 @@ describe('readPersistedWindowsPathSegments', () => { it('keeps the last good segments when a forced read hits a blocked registry', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) .mockImplementation(() => { throw new Error('ERROR: Access is denied.') }) @@ -131,7 +307,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -139,10 +314,10 @@ describe('readPersistedWindowsPathSegments', () => { it('still clears the cache when the registry reports an empty Path', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValue(' Path REG_SZ \r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValue(registryPath('')) __resetPersistedWindowsPathCacheForTests() try { @@ -154,7 +329,33 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual([]) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('does not resurrect a successfully emptied hive when the other hive fails', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockImplementationOnce(() => { + throw new Error('machine registry unavailable') + }) + .mockReturnValueOnce(registryPath('')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + invalidatePersistedWindowsPathCache() + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine']) + invalidatePersistedWindowsPathCache() + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine']) + } finally { + __resetPersistedWindowsPathCacheForTests() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -162,13 +363,9 @@ describe('readPersistedWindowsPathSegments', () => { it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const callbacks: ExecCallback[] = [] - defaultExecFileMock.mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callbacks.push(callback) - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { @@ -177,41 +374,25 @@ describe('readPersistedWindowsPathSegments', () => { const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true }) const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true }) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) - callbacks[0]?.(null, ' Path REG_SZ C:\\Machine\r\n', '') - callbacks[1]?.(null, ' Path REG_SZ C:\\User\r\n', '') await Promise.all([first, second]) expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User') expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User') + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) - it('keeps the last good cache when bounded asynchronous reads time out', async () => { + it('keeps the last good cache when native registry reads fail', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileMock - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\Machine\r\n', '') - return {} as never - } - ) - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\User\r\n', '') - return {} as never - } - ) - .mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }), '', '') - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) __resetPersistedWindowsPathCacheForTests() try { @@ -223,7 +404,7 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([ 'C:\\Machine', 'C:\\User' @@ -232,11 +413,74 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(4) - expect(defaultExecFileMock.mock.calls[2]?.[2]).toMatchObject({ timeout: 5_000 }) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('does not let an older async read replace a newer synchronous refresh', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockReturnValueOnce(registryPath('C:\\OldUser')) + .mockReturnValueOnce(registryPath('C:\\NewMachine')) + .mockReturnValueOnce(registryPath('C:\\NewUser')) + __resetPersistedWindowsPathCacheForTests() + + try { + const olderRead = readPersistedWindowsPathSegmentsAsync({ forceRefresh: true }) + expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ + 'C:\\NewMachine', + 'C:\\NewUser' + ]) + + await expect(olderRead).resolves.toEqual(['C:\\NewMachine', 'C:\\NewUser']) + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\NewMachine', + 'C:\\NewUser' + ]) + } finally { + __resetPersistedWindowsPathCacheForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('uses a current-generation refresh before merging an invalidated read', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\OldMachine')) + .mockImplementationOnce(() => { + queueMicrotask(invalidatePersistedWindowsPathCache) + return registryPath('C:\\OldUser') + }) + .mockReturnValueOnce(registryPath('C:\\NewMachine')) + .mockReturnValueOnce(registryPath('C:\\NewUser')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + const env = { Path: 'C:\\Injected' } + await mergePersistedWindowsPathAsync(env) + + expect(env.Path).toBe('C:\\Injected;C:\\NewMachine;C:\\NewUser') + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\NewMachine', + 'C:\\NewUser' + ]) + invalidatePersistedWindowsPathCache() + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\NewMachine', + 'C:\\NewUser' + ]) + expect(registryGetKeyMock).toHaveBeenCalledTimes(6) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -255,7 +499,7 @@ describe('mergePersistedWindowsPath', () => { expect(env.Path).toBe('C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows') }) - it('appends missing persisted segments without reordering the inherited PATH', () => { + it('adopts persisted machine and user PATH ordering', () => { const execFileSync = vi .fn() .mockReturnValueOnce( @@ -279,7 +523,7 @@ describe('mergePersistedWindowsPath', () => { mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) expect(env.Path).toBe( - 'C:\\Existing;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Local\\Orca\\bin' + 'C:\\Windows\\System32;C:\\Existing;C:\\Users\\me\\AppData\\Local\\Orca\\bin' ) }) @@ -326,6 +570,73 @@ describe('mergePersistedWindowsPath', () => { expect(env).toEqual({ Path: 'C:\\Root\\Live;C:\\Machine;C:\\User' }) }) + + it('stops an inherited WindowsApps alias from shadowing a newly installed Python', () => { + const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\' + const scripts = `${python}Scripts\\` + const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32;C:\\Windows\r\n') + .mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${scripts};${windowsApps}\r\n`) + const env = { Path: `C:\\Windows\\system32;C:\\Windows;${windowsApps}` } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + const segments = env.Path.split(';') + expect(segments.indexOf(python)).toBeLessThan(segments.indexOf(windowsApps)) + expect(env.Path).toBe(`C:\\Windows\\system32;C:\\Windows;${python};${scripts};${windowsApps}`) + }) + + it('keeps injected entries ahead of the persisted PATH', () => { + const injected = 'C:\\Users\\me\\AppData\\Local\\Orca\\resources\\bin' + const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\' + const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32\r\n') + .mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${windowsApps}\r\n`) + const env = { Path: `${injected};C:\\Windows\\system32;${windowsApps}` } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path).toBe(`${injected};C:\\Windows\\system32;${python};${windowsApps}`) + }) + + it('leaves the inherited PATH untouched when both registry reads are blocked', () => { + const execFileSync = vi.fn().mockImplementation(() => { + throw new Error('ERROR: Access is denied.') + }) + const env = { Path: 'C:\\Windows\\system32;C:\\Existing' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path).toBe('C:\\Windows\\system32;C:\\Existing') + }) + + it('deduplicates segments that differ only by trailing separators', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\Tools\r\n') + .mockReturnValueOnce(' Path REG_SZ \r\n') + const env = { Path: 'C:\\Tools\\' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path.split(';')).toEqual(['C:\\Tools']) + }) + + it('canonicalizes drive-root slash style and repetition for comparison', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\\\\r\n') + .mockReturnValueOnce(' Path REG_SZ \r\n') + const env = { Path: 'C:/' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path.split(';')).toEqual(['C:\\\\']) + }) }) describe('resolvePathEnvKey', () => { diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 36032da9aec..554e8768612 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,9 +1,14 @@ -import { execFile, execFileSync } from 'node:child_process' -import { - expandWindowsEnvironmentVariables, - expandWindowsPathEnvironmentVariables -} from '../../shared/windows-environment-expansion' +import type { execFile, execFileSync } from 'node:child_process' +import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion' import { getRegExePath } from '../win32-utils' +import { mergeWindowsPathSegments } from './windows-path-segment-merge' +import { + WindowsPathRegistryFallback, + type RegistryPathRead +} from './windows-path-registry-fallback' +import { readWindowsPathRegistry } from './windows-path-registry-reader' + +export { resolvePathEnvKey } from './windows-path-segment-merge' type ExecFile = typeof execFile type ExecFileSync = typeof execFileSync @@ -16,11 +21,6 @@ type ReadWindowsPathOptions = { platform?: NodeJS.Platform } -type RegistryPathRead = { - failed: boolean - segments: string[] -} - const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], ['HKCU\\Environment', 'Path'] @@ -28,6 +28,9 @@ const WINDOWS_PATH_REGISTRY_KEYS = [ const PERSISTED_WINDOWS_PATH_CACHE_TTL_MS = 30_000 const PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS = 5_000 +const windowsPathRegistryFallback = new WindowsPathRegistryFallback( + WINDOWS_PATH_REGISTRY_KEYS.length +) let persistedWindowsPathCache: | { @@ -36,6 +39,9 @@ let persistedWindowsPathCache: } | undefined let pendingPersistedWindowsPathRefresh: Promise | undefined +let persistedWindowsPathCacheGeneration = 0 +let persistedWindowsPathReadSequence = 0 +let persistedWindowsPathCommittedSequence = 0 function parseRegistryPathValue(output: string, valueName: string): string | null { const valuePattern = new RegExp(`^\\s*${valueName}\\s+REG_\\w+\\s+(.*)$`, 'i') @@ -71,19 +77,33 @@ function registryOutputSegments( : [] } -function keepLastGoodSegments(segments: string[], failedReads: number): string[] { - if (failedReads === WINDOWS_PATH_REGISTRY_KEYS.length && persistedWindowsPathCache) { - return [...persistedWindowsPathCache.segments] - } - return segments +function readNativeRegistryPaths( + env: NodeJS.ProcessEnv, + pathDelimiter: string +): RegistryPathRead[] { + return readWindowsPathRegistry().map((read) => ({ + failed: read.failed, + segments: + read.value === null + ? [] + : splitPathSegments(expandWindowsEnvironmentVariables(read.value, env), pathDelimiter) + })) } -function cachePersistedWindowsPathSegments(segments: string[], failedReads: number): string[] { - // Why: timeouts or policy blocks must not replace a usable cache, while successful empty - // registry values still need to remove stale entries. - const kept = keepLastGoodSegments(segments, failedReads) - persistedWindowsPathCache = { readAt: Date.now(), segments: [...kept] } - return [...kept] +function cachePersistedWindowsPathReads(reads: RegistryPathRead[], readSequence: number): string[] { + if (readSequence < persistedWindowsPathCommittedSequence) { + // Why: a slower async read must not replace a newer synchronous refresh. + return persistedWindowsPathCache ? [...persistedWindowsPathCache.segments] : [] + } + persistedWindowsPathCommittedSequence = readSequence + const segments = windowsPathRegistryFallback.commitReads(reads) + if (!segments) { + // Why: unresolved hives preserve ordering, but still need negative caching on the PTY hot path. + persistedWindowsPathCache = { readAt: Date.now(), segments: [] } + return [] + } + persistedWindowsPathCache = { readAt: Date.now(), segments: [...segments] } + return [...segments] } function readRegistryPathAsync( @@ -134,46 +154,45 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [...persistedWindowsPathCache.segments] } - if ( - !options.forceRefresh && - useProductionCache && - pendingPersistedWindowsPathRefresh && - persistedWindowsPathCache - ) { + if (!options.forceRefresh && useProductionCache && pendingPersistedWindowsPathRefresh) { // Why: synchronous PTY construction cannot await the active refresh; its stale cache is // safer than duplicating the registry read on Electron's main thread. - return [...persistedWindowsPathCache.segments] + return persistedWindowsPathCache ? [...persistedWindowsPathCache.segments] : [] } - const run = options.execFileSync ?? execFileSync const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const segments: string[] = [] - let failedReads = 0 - - for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { - try { - const output = run(getRegExePath(env), ['query', key, '/v', valueName], { - encoding: 'utf8', - timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, - windowsHide: true + const readSequence = useProductionCache ? ++persistedWindowsPathReadSequence : 0 + const reads = options.execFileSync + ? WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => { + try { + const output = options.execFileSync!( + getRegExePath(env), + ['query', key, '/v', valueName], + { + encoding: 'utf8', + timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, + windowsHide: true + } + ) + return { + failed: false, + segments: registryOutputSegments(output, valueName, env, pathDelimiter) + } + } catch { + return { failed: true, segments: [] } + } }) - segments.push(...registryOutputSegments(output, valueName, env, pathDelimiter)) - } catch { - // Registry access can fail in stripped test containers or remote-like - // Windows contexts. Existing PATH remains the fallback in those cases. - failedReads += 1 - } - } - + : readNativeRegistryPaths(env, pathDelimiter) + const segments = reads.flatMap((read) => read.segments) if (!useProductionCache) { return segments } - // Why: local PTY spawn is a hot path on Windows, and each uncached read - // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps - // terminal bursts cheap while still picking up newly installed CLIs soon. - return cachePersistedWindowsPathSegments(segments, failedReads) + // Why: local PTY spawn is a hot path on Windows, and each uncached refresh performs two + // synchronous native registry reads. A short TTL keeps terminal bursts cheap while still + // picking up newly installed CLIs soon. + return cachePersistedWindowsPathReads(reads, readSequence) } export async function readPersistedWindowsPathSegmentsAsync( @@ -202,20 +221,34 @@ export async function readPersistedWindowsPathSegmentsAsync( return [...(await pendingPersistedWindowsPathRefresh)] } - const run = options.execFile ?? execFile const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const executable = getRegExePath(env) - const refresh = Promise.all( - WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => - readRegistryPathAsync(run, executable, registryValue, env, pathDelimiter) - ) + const cacheGeneration = persistedWindowsPathCacheGeneration + const readSequence = useProductionCache ? ++persistedWindowsPathReadSequence : 0 + const refresh = ( + options.execFile + ? Promise.all( + WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => + readRegistryPathAsync( + options.execFile!, + getRegExePath(env), + registryValue, + env, + pathDelimiter + ) + ) + ) + : Promise.resolve(readNativeRegistryPaths(env, pathDelimiter)) ).then((reads) => { const segments = reads.flatMap((read) => read.segments) if (!useProductionCache) { return segments } - return cachePersistedWindowsPathSegments(segments, reads.filter((read) => read.failed).length) + if (cacheGeneration !== persistedWindowsPathCacheGeneration) { + // Why: callers must not merge or inspect a snapshot invalidated while its queries ran. + return readPersistedWindowsPathSegmentsAsync() + } + return cachePersistedWindowsPathReads(reads, readSequence) }) if (!useProductionCache) { @@ -232,63 +265,18 @@ export async function readPersistedWindowsPathSegmentsAsync( } export function __resetPersistedWindowsPathCacheForTests(): void { + invalidatePersistedWindowsPathCache() + windowsPathRegistryFallback.reset() + persistedWindowsPathReadSequence = 0 + persistedWindowsPathCommittedSequence = 0 +} + +export function invalidatePersistedWindowsPathCache(): void { + persistedWindowsPathCacheGeneration += 1 persistedWindowsPathCache = undefined pendingPersistedWindowsPathRefresh = undefined } -/** Resolves the first PATH key a Windows child reads from an env block. */ -export function resolvePathEnvKey( - env: NodeJS.ProcessEnv | Record, - platform: NodeJS.Platform, - hostEnv: NodeJS.ProcessEnv = process.env -): string { - if (platform !== 'win32') { - return 'PATH' - } - // Why: the daemon receives a sparse env patch and re-merges its own block underneath it; - // matching the block's spelling stops that merge from resurrecting the other one. - return firstWindowsPathEnvKey(env) ?? firstWindowsPathEnvKey(hostEnv) ?? 'Path' -} - -// Why: Win32 resolves the first case-insensitive key, and object order preserves block order. -function firstWindowsPathEnvKey( - env: NodeJS.ProcessEnv | Record -): string | undefined { - for (const key of Object.keys(env)) { - if (key.toLowerCase() === 'path' && env[key] !== undefined) { - return key - } - } - return undefined -} - -function mergeWindowsPathSegments( - env: NodeJS.ProcessEnv, - persistedSegments: string[], - platform: NodeJS.Platform, - sourceEnv: NodeJS.ProcessEnv -): void { - expandWindowsPathEnvironmentVariables(env, platform) - const pathKey = resolvePathEnvKey(env, platform, sourceEnv) - const pathDelimiter = getPathDelimiter(platform) - const currentPath = - env[pathKey] ?? expandWindowsEnvironmentVariables(sourceEnv[pathKey] ?? '', sourceEnv) - const currentSegments = splitPathSegments(currentPath, pathDelimiter) - const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) - const missing = persistedSegments.filter((segment) => { - const normalized = segment.toLowerCase() - if (existing.has(normalized)) { - return false - } - existing.add(normalized) - return true - }) - - if (missing.length > 0) { - env[pathKey] = [...currentSegments, ...missing].join(pathDelimiter) - } -} - export function mergePersistedWindowsPath( env: NodeJS.ProcessEnv, options: ReadWindowsPathOptions = {} @@ -299,9 +287,7 @@ export function mergePersistedWindowsPath( } const sourceEnv = options.env ?? process.env - // Why: Windows broadcasts PATH changes to future processes, but a running - // Electron app keeps its old environment. Append the persisted additions so - // newly installed CLIs resolve without unexpectedly reordering existing PATH. + // Why: append-only merging lets stale entries shadow newly installed executables. mergeWindowsPathSegments(env, readPersistedWindowsPathSegments(options), platform, sourceEnv) } diff --git a/src/main/pty/windows-path-registry-change.test.ts b/src/main/pty/windows-path-registry-change.test.ts new file mode 100644 index 00000000000..e6baee2cadf --- /dev/null +++ b/src/main/pty/windows-path-registry-change.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { + installWindowsPathRegistryChangeListener, + WINDOWS_SETTING_CHANGE_MESSAGE +} from './windows-path-registry-change' + +describe('installWindowsPathRegistryChangeListener', () => { + it('invalidates the persisted PATH cache on a Windows settings change', () => { + const hooks = new Map void>() + const invalidate = vi.fn() + installWindowsPathRegistryChangeListener( + { + hookWindowMessage: (message, callback) => { + hooks.set(message, callback) + } + }, + { invalidate, platform: 'win32' } + ) + + hooks.get(WINDOWS_SETTING_CHANGE_MESSAGE)?.(Buffer.alloc(0), Buffer.alloc(0)) + + expect(invalidate).toHaveBeenCalledOnce() + }) + + it('does not hook native messages outside Windows', () => { + const hookWindowMessage = vi.fn() + + installWindowsPathRegistryChangeListener({ hookWindowMessage }, { platform: 'linux' }) + + expect(hookWindowMessage).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/windows-path-registry-change.ts b/src/main/pty/windows-path-registry-change.ts new file mode 100644 index 00000000000..604d06de713 --- /dev/null +++ b/src/main/pty/windows-path-registry-change.ts @@ -0,0 +1,19 @@ +import type { BrowserWindow } from 'electron' +import { invalidatePersistedWindowsPathCache } from './windows-environment-path' + +export const WINDOWS_SETTING_CHANGE_MESSAGE = 0x001a + +type WindowsMessageWindow = Pick + +export function installWindowsPathRegistryChangeListener( + window: Partial, + options: { invalidate?: () => void; platform?: NodeJS.Platform } = {} +): void { + if ((options.platform ?? process.platform) !== 'win32' || !window.hookWindowMessage) { + return + } + window.hookWindowMessage( + WINDOWS_SETTING_CHANGE_MESSAGE, + options.invalidate ?? invalidatePersistedWindowsPathCache + ) +} diff --git a/src/main/pty/windows-path-registry-fallback.test.ts b/src/main/pty/windows-path-registry-fallback.test.ts new file mode 100644 index 00000000000..a7db74af310 --- /dev/null +++ b/src/main/pty/windows-path-registry-fallback.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { WindowsPathRegistryFallback } from './windows-path-registry-fallback' + +const success = (segments: string[]) => ({ failed: false, segments }) +const failure = () => ({ failed: true, segments: [] }) + +describe('WindowsPathRegistryFallback', () => { + it('withholds an incomplete snapshot until every source has succeeded', () => { + const fallback = new WindowsPathRegistryFallback(2) + + expect(fallback.commitReads([success(['C:\\Machine']), failure()])).toBeUndefined() + expect(fallback.commitReads([failure(), success(['C:\\User'])])).toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + }) + + it('uses each source independently when a later read fails', () => { + const fallback = new WindowsPathRegistryFallback(2) + fallback.commitReads([success(['C:\\OldMachine']), success(['C:\\OldUser'])]) + + expect(fallback.commitReads([success(['C:\\NewMachine']), failure()])).toEqual([ + 'C:\\NewMachine', + 'C:\\OldUser' + ]) + }) + + it('treats a successful empty source as authoritative', () => { + const fallback = new WindowsPathRegistryFallback(2) + fallback.commitReads([success(['C:\\Machine']), success(['C:\\User'])]) + + expect(fallback.commitReads([failure(), success([])])).toEqual(['C:\\Machine']) + expect(fallback.commitReads([failure(), failure()])).toEqual(['C:\\Machine']) + }) + + it('clears retained sources on reset', () => { + const fallback = new WindowsPathRegistryFallback(2) + fallback.commitReads([success(['C:\\Machine']), success(['C:\\User'])]) + + fallback.reset() + + expect(fallback.commitReads([failure(), failure()])).toBeUndefined() + }) + + it('does not expose retained arrays to callers', () => { + const fallback = new WindowsPathRegistryFallback(2) + const machine = ['C:\\Machine'] + const first = fallback.commitReads([success(machine), success(['C:\\User'])])! + + machine[0] = 'C:\\MutatedInput' + first[0] = 'C:\\MutatedOutput' + + expect(fallback.commitReads([failure(), failure()])).toEqual(['C:\\Machine', 'C:\\User']) + }) +}) diff --git a/src/main/pty/windows-path-registry-fallback.ts b/src/main/pty/windows-path-registry-fallback.ts new file mode 100644 index 00000000000..c551144119c --- /dev/null +++ b/src/main/pty/windows-path-registry-fallback.ts @@ -0,0 +1,33 @@ +export type RegistryPathRead = { failed: boolean; segments: string[] } + +export class WindowsPathRegistryFallback { + private readonly lastGoodBySource: (string[] | undefined)[] + + constructor(sourceCount: number) { + this.lastGoodBySource = Array.from({ length: sourceCount }) + } + + commitReads(reads: RegistryPathRead[]): string[] | undefined { + if (reads.length !== this.lastGoodBySource.length) { + return undefined + } + + const resolved = reads.map((read, index) => { + if (!read.failed) { + const segments = [...read.segments] + this.lastGoodBySource[index] = segments + return segments + } + const lastGood = this.lastGoodBySource[index] + return lastGood ? [...lastGood] : undefined + }) + + return resolved.some((segments) => segments === undefined) + ? undefined + : resolved.flatMap((segments) => segments ?? []) + } + + reset(): void { + this.lastGoodBySource.fill(undefined) + } +} diff --git a/src/main/pty/windows-path-registry-reader.test.ts b/src/main/pty/windows-path-registry-reader.test.ts new file mode 100644 index 00000000000..387e4238b8e --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __setWindowsPathRegistryLoaderForTests, + readWindowsPathRegistry +} from './windows-path-registry-reader' + +describe('readWindowsPathRegistry', () => { + afterEach(() => __setWindowsPathRegistryLoaderForTests()) + + it('reads machine and user PATH values without creating a process', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ PATH: { type: 2, value: '%SystemRoot%\\System32' } }) + .mockReturnValueOnce({ Path: { type: 1, value: 'C:\\Users\\me\\bin' } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '%SystemRoot%\\System32' }, + { failed: false, value: 'C:\\Users\\me\\bin' } + ]) + expect(getRegistryKey).toHaveBeenNthCalledWith( + 1, + 1, + 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' + ) + expect(getRegistryKey).toHaveBeenNthCalledWith(2, 2, 'Environment') + }) + + it('distinguishes an empty PATH from a failed registry read', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ Path: { type: 1, value: '' } }) + .mockReturnValueOnce({ Path: { type: 4, value: 1 } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '' }, + { failed: true, value: null } + ]) + }) + + it('treats a missing PATH value in a readable hive as empty', () => { + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: vi.fn(() => ({})) + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '' }, + { failed: false, value: '' } + ]) + }) + + it('treats a present but unreadable PATH entry as a failed read', () => { + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: vi.fn(() => ({ Path: undefined })) + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) + + it('fails closed when the optional native module is unavailable', () => { + __setWindowsPathRegistryLoaderForTests(() => { + throw new Error('native module unavailable') + }) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) +}) diff --git a/src/main/pty/windows-path-registry-reader.ts b/src/main/pty/windows-path-registry-reader.ts new file mode 100644 index 00000000000..cff14c6ff3e --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.ts @@ -0,0 +1,69 @@ +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' + +export type WindowsPathRegistryRead = { + failed: boolean + value: string | null +} + +const MACHINE_ENVIRONMENT_KEY = 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' +const USER_ENVIRONMENT_KEY = 'Environment' +const PATH_VALUE = 'Path' + +let windowsRegistryLoader = loadWindowsNativeRegistry + +function readRegistryPath( + registry: WindowsNativeRegistryModule, + root: number, + key: string +): WindowsPathRegistryRead { + try { + const values = registry.getRegistryKey(root, key) + if (!values || typeof values !== 'object') { + return { failed: true, value: null } + } + const matchingEntry = Object.entries(values).find( + ([name]) => name.toLowerCase() === PATH_VALUE.toLowerCase() + ) + if (!matchingEntry) { + return { failed: false, value: '' } + } + const entry = matchingEntry[1] + if ( + !entry || + (entry.type !== WINDOWS_REG_SZ && entry.type !== WINDOWS_REG_EXPAND_SZ) || + typeof entry.value !== 'string' + ) { + return { failed: true, value: null } + } + return { failed: false, value: entry.value } + } catch { + return { failed: true, value: null } + } +} + +export function readWindowsPathRegistry(): WindowsPathRegistryRead[] { + let registry: WindowsNativeRegistryModule + try { + registry = windowsRegistryLoader() + } catch { + return [ + { failed: true, value: null }, + { failed: true, value: null } + ] + } + return [ + readRegistryPath(registry, registry.HK.LM, MACHINE_ENVIRONMENT_KEY), + readRegistryPath(registry, registry.HK.CU, USER_ENVIRONMENT_KEY) + ] +} + +export function __setWindowsPathRegistryLoaderForTests( + loader?: () => WindowsNativeRegistryModule +): void { + windowsRegistryLoader = loader ?? loadWindowsNativeRegistry +} diff --git a/src/main/pty/windows-path-segment-merge.ts b/src/main/pty/windows-path-segment-merge.ts new file mode 100644 index 00000000000..790a6cd06c9 --- /dev/null +++ b/src/main/pty/windows-path-segment-merge.ts @@ -0,0 +1,83 @@ +import { + expandWindowsEnvironmentVariables, + expandWindowsPathEnvironmentVariables +} from '../../shared/windows-environment-expansion' + +export function resolvePathEnvKey( + env: NodeJS.ProcessEnv | Record, + platform: NodeJS.Platform, + hostEnv: NodeJS.ProcessEnv = process.env +): string { + if (platform !== 'win32') { + return 'PATH' + } + // Why: match the daemon's env-block spelling so its merge cannot resurrect another PATH key. + return firstWindowsPathEnvKey(env) ?? firstWindowsPathEnvKey(hostEnv) ?? 'Path' +} + +// Why: Win32 uses the first case-insensitive key, and object order preserves block order. +function firstWindowsPathEnvKey( + env: NodeJS.ProcessEnv | Record +): string | undefined { + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path' && env[key] !== undefined) { + return key + } + } + return undefined +} + +function normalizeSegmentKey(segment: string): string { + const trimmed = segment.replace(/[\\/]+$/, '') + // Why: roots keep one separator because `C:\` and drive-relative `C:` differ. + return (/^[a-z]:$/i.test(trimmed) && trimmed !== segment ? `${trimmed}\\` : trimmed).toLowerCase() +} + +function dedupeSegments(segments: string[]): string[] { + const seen = new Set() + return segments.filter((segment) => { + const key = normalizeSegmentKey(segment) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +function splitPathSegments(pathValue: string, pathDelimiter: string): string[] { + return pathValue + .split(pathDelimiter) + .map((segment) => segment.trim()) + .filter(Boolean) +} + +export function mergeWindowsPathSegments( + env: NodeJS.ProcessEnv, + persistedSegments: string[], + platform: NodeJS.Platform, + sourceEnv: NodeJS.ProcessEnv +): void { + expandWindowsPathEnvironmentVariables(env, platform) + const pathKey = resolvePathEnvKey(env, platform, sourceEnv) + const pathDelimiter = platform === 'win32' ? ';' : ':' + const currentPath = + env[pathKey] ?? expandWindowsEnvironmentVariables(sourceEnv[pathKey] ?? '', sourceEnv) + const currentSegments = splitPathSegments(currentPath, pathDelimiter) + + if (persistedSegments.length === 0) { + return + } + + const persisted = dedupeSegments(persistedSegments) + const persistedKeys = new Set(persisted.map(normalizeSegmentKey)) + // Why: keep launch-time and Orca-injected entries that have no persisted position. + const injected = dedupeSegments( + currentSegments.filter((segment) => !persistedKeys.has(normalizeSegmentKey(segment))) + ) + const merged = [...injected, ...persisted].join(pathDelimiter) + + if (merged !== currentPath) { + env[pathKey] = merged + } +} diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 15c6b43cf44..21a3f69d1cd 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -48,6 +48,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', + ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', @@ -56,6 +57,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_AGENT_HOOK_ENV: 'dev', ORCA_AGENT_HOOK_VERSION: '1', + ORCA_WSL_HOOK_INSTANCE: 'testinstance', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' @@ -67,6 +69,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') + expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u') @@ -75,6 +78,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u') + expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u') diff --git a/src/main/rate-limits/claude-rate-limit-target.test.ts b/src/main/rate-limits/claude-rate-limit-target.test.ts deleted file mode 100644 index 969124655ad..00000000000 --- a/src/main/rate-limits/claude-rate-limit-target.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/types' -import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' - -function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings { - const next = { ...settings } as Partial - delete next.localAccountRuntime - return next as GlobalSettings -} - -describe('getInitialClaudeRateLimitTarget', () => { - it('uses the configured WSL account runtime before agent detection runtime', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Fedora', - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) - }) - - it('uses the single selected WSL account distro when account runtime is WSL default', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - activeClaudeManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale terminal WSL distro when account runtime is WSL default', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: null, - terminalWindowsWslDistro: 'Debian', - activeClaudeManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: {} - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: null }) - }) - - it('uses the global WSL project runtime default when account runtime is unset', () => { - expect( - getInitialClaudeRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => { - expect( - getInitialClaudeRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'windows-host' }, - localAgentRuntime: 'wsl', - localAgentWslDistro: 'Ubuntu', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('uses a single WSL-only active account after restart', () => { - expect( - getInitialClaudeRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }) - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto follows the global WSL project runtime default', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto resolves to host when the global project runtime is windows-host', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('does not let a stale WSL-only selection override an auto host target', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' }, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('keeps explicit host runtime on host', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'host', - localAgentRuntime: 'host', - terminalWindowsShell: 'wsl.exe', - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu' - }, - 'linux' - ) - ).toEqual({ runtime: 'host' }) - }) -}) diff --git a/src/main/rate-limits/claude-rate-limit-target.ts b/src/main/rate-limits/claude-rate-limit-target.ts index 199250cd3e5..dba2e396669 100644 --- a/src/main/rate-limits/claude-rate-limit-target.ts +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -1,71 +1,21 @@ import type { GlobalSettings } from '../../shared/types' -import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getClaudeWslSelectionKey, normalizeClaudeRuntimeSelection, type ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' -import { - getProjectRuntimeRateLimitTarget, - normalizeOptionalDistro -} from './project-runtime-rate-limit-target' - -function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { - const selection = normalizeClaudeRuntimeSelection(settings) - const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => - Boolean(accountId) - ) - if (selectedWslEntries.length !== 1) { - return null - } - const [distroKey] = selectedWslEntries[0] - return distroKey === getClaudeWslSelectionKey(null) ? null : distroKey -} +import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target' export function getInitialClaudeRateLimitTarget( settings: GlobalSettings, platform: NodeJS.Platform = process.platform ): ClaudeAccountSelectionTarget { - if (settings.localAccountRuntime === 'host') { - return { runtime: 'host' } - } - if (settings.localAccountRuntime === 'wsl') { - if (platform !== 'win32') { - return { runtime: 'host' } - } - return { - runtime: 'wsl', - wslDistro: - normalizeOptionalDistro(settings.localAccountWslDistro) ?? - getSingleSelectedWslDistro(settings) - } - } - if (settings.localAccountRuntime === 'auto') { - const target = resolveLocalAccountRuntimeTarget(settings, platform) - return target.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } - } - - // Why: pre-setting profiles used account selection as their startup fallback. - const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) - if (projectRuntimeTarget) { - return projectRuntimeTarget - } - - const selection = normalizeClaudeRuntimeSelection(settings) - if (!selection.host) { - const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => - Boolean(accountId) - ) - if (selectedWslEntries.length === 1) { - const [distroKey] = selectedWslEntries[0] - return { - runtime: 'wsl', - wslDistro: distroKey === getClaudeWslSelectionKey(null) ? null : distroKey - } - } - } - - return { runtime: 'host' } + return getInitialAccountRateLimitTarget( + settings, + { + getWslSelectionKey: getClaudeWslSelectionKey, + normalizeRuntimeSelection: normalizeClaudeRuntimeSelection + }, + platform + ) } diff --git a/src/main/rate-limits/codex-rate-limit-target.test.ts b/src/main/rate-limits/codex-rate-limit-target.test.ts deleted file mode 100644 index b3b39f027f9..00000000000 --- a/src/main/rate-limits/codex-rate-limit-target.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/types' -import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' - -function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings { - const next = { ...settings } as Partial - delete next.localAccountRuntime - return next as GlobalSettings -} - -describe('getInitialCodexRateLimitTarget', () => { - it('uses the configured WSL account runtime before agent detection runtime', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Fedora', - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) - }) - - it('uses the single selected WSL account distro when account runtime is WSL default', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - activeCodexManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale terminal WSL distro when account runtime is WSL default', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: null, - terminalWindowsWslDistro: 'Debian', - activeCodexManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: {} - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: null }) - }) - - it('uses the global WSL project runtime default when account runtime is unset', () => { - expect( - getInitialCodexRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => { - expect( - getInitialCodexRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'windows-host' }, - localAgentRuntime: 'wsl', - localAgentWslDistro: 'Ubuntu', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('uses a single WSL-only active account after restart', () => { - expect( - getInitialCodexRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }) - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto follows the global WSL project runtime default', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto resolves to host when the global project runtime is windows-host', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('does not let a stale WSL-only selection override an auto host target', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' }, - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('keeps explicit host runtime on host', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'host', - localAgentRuntime: 'host', - terminalWindowsShell: 'wsl.exe', - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu' - }, - 'darwin' - ) - ).toEqual({ runtime: 'host' }) - }) -}) diff --git a/src/main/rate-limits/codex-rate-limit-target.ts b/src/main/rate-limits/codex-rate-limit-target.ts index 65c459ad7d3..61c116bab2b 100644 --- a/src/main/rate-limits/codex-rate-limit-target.ts +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -1,73 +1,18 @@ import type { GlobalSettings } from '../../shared/types' -import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getWslSelectionKey, normalizeCodexRuntimeSelection, type CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import { - getProjectRuntimeRateLimitTarget, - normalizeOptionalDistro -} from './project-runtime-rate-limit-target' - -function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { - const selection = normalizeCodexRuntimeSelection(settings) - const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => - Boolean(accountId) - ) - if (selectedWslEntries.length !== 1) { - return null - } - const [distroKey] = selectedWslEntries[0] - return distroKey === getWslSelectionKey(null) ? null : distroKey -} +import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target' export function getInitialCodexRateLimitTarget( settings: GlobalSettings, platform: NodeJS.Platform = process.platform ): CodexAccountSelectionTarget { - if (settings.localAccountRuntime === 'host') { - return { runtime: 'host' } - } - if (settings.localAccountRuntime === 'wsl') { - if (platform !== 'win32') { - return { runtime: 'host' } - } - return { - runtime: 'wsl', - wslDistro: - normalizeOptionalDistro(settings.localAccountWslDistro) ?? - getSingleSelectedWslDistro(settings) - } - } - if (settings.localAccountRuntime === 'auto') { - const target = resolveLocalAccountRuntimeTarget(settings, platform) - return target.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } - } - - // Why: pre-setting profiles used account selection as their startup fallback. - const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) - if (projectRuntimeTarget) { - return projectRuntimeTarget - } - - const selection = normalizeCodexRuntimeSelection(settings) - if (!selection.host) { - const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => - Boolean(accountId) - ) - if (selectedWslEntries.length === 1) { - const [distroKey] = selectedWslEntries[0] - // Why: after restart there is no last-clicked switcher target, but a - // single WSL-only active account is the least surprising quota context. - return { - runtime: 'wsl', - wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey - } - } - } - - return { runtime: 'host' } + return getInitialAccountRateLimitTarget( + settings, + { getWslSelectionKey, normalizeRuntimeSelection: normalizeCodexRuntimeSelection }, + platform + ) } diff --git a/src/main/rate-limits/initial-account-rate-limit-target.test.ts b/src/main/rate-limits/initial-account-rate-limit-target.test.ts new file mode 100644 index 00000000000..528a7527968 --- /dev/null +++ b/src/main/rate-limits/initial-account-rate-limit-target.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' +import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' + +type RuntimeSelection = { + host: string | null + wsl: Record +} + +type Scenario = { + name: string + settings: Partial + expected: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string | null } + legacy?: boolean + platform?: NodeJS.Platform + selection?: RuntimeSelection +} + +const providers = [ + { + name: 'Codex', + getTarget: getInitialCodexRateLimitTarget, + selectionSettings: (selection: RuntimeSelection) => ({ + activeCodexManagedAccountIdsByRuntime: selection + }) + }, + { + name: 'Claude', + getTarget: getInitialClaudeRateLimitTarget, + selectionSettings: (selection: RuntimeSelection) => ({ + activeClaudeManagedAccountIdsByRuntime: selection + }) + } +] as const + +const scenarios: Scenario[] = [ + { + name: 'uses the trimmed configured WSL distro before the selected account', + settings: { + localAccountRuntime: 'wsl', + localAccountWslDistro: ' Fedora ', + localAgentRuntime: 'host', + terminalWindowsWslDistro: 'Debian' + }, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'wsl', wslDistro: 'Fedora' } + }, + { + name: 'uses the single selected distro for an unpinned WSL runtime', + settings: { localAccountRuntime: 'wsl' }, + selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'ignores a stale terminal distro for an unpinned WSL runtime', + settings: { + localAccountRuntime: 'wsl', + localAccountWslDistro: null, + terminalWindowsWslDistro: 'Debian' + }, + selection: { host: 'host-account-1', wsl: {} }, + expected: { runtime: 'wsl', wslDistro: null } + }, + { + name: 'keeps an explicit host runtime on host', + settings: { localAccountRuntime: 'host', terminalWindowsShell: 'wsl.exe' }, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'host' } + }, + { + name: 'ignores an explicit WSL runtime on non-Windows hosts', + settings: { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }, + platform: 'linux', + expected: { runtime: 'host' } + }, + { + name: 'auto follows the global WSL runtime before stale selections', + settings: { + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + selection: { host: null, wsl: { Debian: 'wsl-account-1' } }, + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'auto keeps the global host runtime before stale selections', + settings: { + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'host' } + }, + { + name: 'legacy settings use the global WSL runtime before selections', + settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }, + selection: { host: null, wsl: { Debian: 'wsl-account-1' } }, + legacy: true, + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'legacy settings ignore stale agent and terminal WSL values', + settings: { + localWindowsRuntimeDefault: { kind: 'windows-host' }, + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }, + legacy: true, + expected: { runtime: 'host' } + }, + { + name: 'legacy settings preserve a single WSL selection on macOS', + settings: {}, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + legacy: true, + platform: 'darwin', + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'legacy settings map the provider default WSL key to no distro', + settings: {}, + selection: { host: null, wsl: { __default__: 'wsl-account-1' } }, + legacy: true, + platform: 'linux', + expected: { runtime: 'wsl', wslDistro: null } + }, + { + name: 'legacy selection fallback requires no selected host account', + settings: {}, + selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } }, + legacy: true, + expected: { runtime: 'host' } + }, + { + name: 'legacy selection fallback requires exactly one selected WSL account', + settings: {}, + selection: { + host: null, + wsl: { Debian: 'wsl-account-1', Fedora: null, Ubuntu: 'wsl-account-2' } + }, + legacy: true, + expected: { runtime: 'host' } + } +] + +describe.each(providers)('$name initial rate-limit target', ({ getTarget, selectionSettings }) => { + it.each(scenarios)('$name', (scenario) => { + const settings = { + ...getDefaultSettings('/tmp'), + ...scenario.settings, + ...(scenario.selection ? selectionSettings(scenario.selection) : {}) + } + if (scenario.legacy) { + delete (settings as Partial).localAccountRuntime + } + + expect(getTarget(settings, scenario.platform ?? 'win32')).toEqual(scenario.expected) + }) +}) diff --git a/src/main/rate-limits/initial-account-rate-limit-target.ts b/src/main/rate-limits/initial-account-rate-limit-target.ts new file mode 100644 index 00000000000..d60dab69daf --- /dev/null +++ b/src/main/rate-limits/initial-account-rate-limit-target.ts @@ -0,0 +1,80 @@ +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' +import type { GlobalSettings } from '../../shared/types' + +type AccountRuntimeSelection = { + host: string | null + wsl: Record +} + +type AccountRuntimeSelectionProvider = { + getWslSelectionKey: (wslDistro: string | null | undefined) => string + normalizeRuntimeSelection: (settings: GlobalSettings) => AccountRuntimeSelection +} + +type WslRateLimitRuntimeTarget = { runtime: 'wsl'; wslDistro: string | null } + +export type AccountRateLimitRuntimeTarget = { runtime: 'host' } | WslRateLimitRuntimeTarget + +export function getInitialAccountRateLimitTarget( + settings: GlobalSettings, + provider: AccountRuntimeSelectionProvider, + platform: NodeJS.Platform = process.platform +): AccountRateLimitRuntimeTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host' } + } + + if (settings.localAccountRuntime === 'wsl') { + if (platform !== 'win32') { + return { runtime: 'host' } + } + const configuredTarget = resolveLocalAccountRuntimeTarget(settings, platform) + return { + runtime: 'wsl', + wslDistro: + configuredTarget.wslDistro ?? + getSingleSelectedWslTarget( + provider.normalizeRuntimeSelection(settings), + provider.getWslSelectionKey + )?.wslDistro ?? + null + } + } + + // Pre-setting profiles omit the policy and fall back to project/account selection. + const resolvedTarget = toRateLimitTarget(resolveLocalAccountRuntimeTarget(settings, platform)) + if (settings.localAccountRuntime === 'auto' || resolvedTarget.runtime === 'wsl') { + return resolvedTarget + } + + const selection = provider.normalizeRuntimeSelection(settings) + return selection.host + ? { runtime: 'host' } + : (getSingleSelectedWslTarget(selection, provider.getWslSelectionKey) ?? { runtime: 'host' }) +} + +function getSingleSelectedWslTarget( + selection: AccountRuntimeSelection, + getWslSelectionKey: AccountRuntimeSelectionProvider['getWslSelectionKey'] +): WslRateLimitRuntimeTarget | null { + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length !== 1) { + return null + } + + const [distroKey] = selectedWslEntries[0] + return { + runtime: 'wsl', + wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey + } +} + +function toRateLimitTarget( + target: ReturnType +): AccountRateLimitRuntimeTarget { + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } +} diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts index 34dcd47130b..ecda8329a2e 100644 --- a/src/main/rate-limits/minimax-request-context.ts +++ b/src/main/rate-limits/minimax-request-context.ts @@ -45,7 +45,7 @@ function parseCookiePairs(cookie: string): { name: string; value: string }[] { .map((part) => { const normalizedPart = part.replace(/^Cookie:\s*/i, '') const eq = normalizedPart.indexOf('=') - if (eq < 0) { + if (eq === -1) { return null } return { diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index 934d1e72b42..f2ea68b7588 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -47,7 +47,7 @@ function parseAuthCookies(raw: string): { name: string; value: string }[] { .map((p) => p.trim()) .map((pair) => { const eq = pair.indexOf('=') - if (eq < 0) { + if (eq === -1) { return null } const name = pair.slice(0, eq).trim() diff --git a/src/main/rate-limits/project-runtime-rate-limit-target.ts b/src/main/rate-limits/project-runtime-rate-limit-target.ts deleted file mode 100644 index b6504235aa3..00000000000 --- a/src/main/rate-limits/project-runtime-rate-limit-target.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { GlobalSettings } from '../../shared/types' -import { normalizeGlobalWindowsRuntimeDefault } from '../../shared/project-execution-runtime' - -export type AccountRateLimitRuntimeTarget = - | { runtime: 'host' } - | { runtime: 'wsl'; wslDistro: string | null } - -export function getProjectRuntimeRateLimitTarget( - settings: GlobalSettings, - platform: NodeJS.Platform -): AccountRateLimitRuntimeTarget | null { - if (platform !== 'win32') { - return null - } - - const runtimeDefault = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault) - if (runtimeDefault.kind !== 'wsl') { - return null - } - - // Why: account quota polling has no project id, so its best default is the - // global project runtime instead of stale terminal/agent WSL settings. - return { runtime: 'wsl', wslDistro: runtimeDefault.distro } -} - -export function normalizeOptionalDistro(value: string | null | undefined): string | null { - const trimmed = value?.trim() - return trimmed ? trimmed : null -} diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index 03de06f15e3..b808dce4483 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -214,6 +214,36 @@ describe('detectRepoIcon', () => { }) }) + it('keeps the renamed fork own owner avatar while storing the upstream metadata', async () => { + const repoPath = await makeTempRepoDir() + await gitExecFileAsync(['init'], { cwd: repoPath }) + await gitExecFileAsync(['remote', 'add', 'origin', 'git@github.com:acme/rocket-pro.git'], { + cwd: repoPath + }) + await gitExecFileAsync( + ['remote', 'add', 'upstream', 'git@github.com:upstream-org/rocket.git'], + { + cwd: repoPath + } + ) + + await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({ + gitRemoteIdentity: { + canonicalKey: 'github.com/upstream-org/rocket', + remoteName: 'upstream', + remoteUrl: 'git@github.com:upstream-org/rocket.git' + }, + // Why: a renamed fork is its own project, so the avatar stays on the origin owner. + repoIcon: { + type: 'image', + src: 'https://github.com/acme.png?size=64', + source: 'github', + label: 'acme/rocket-pro' + }, + upstream: { owner: 'upstream-org', repo: 'rocket', host: 'github.com' } + }) + }) + it('detects a provider-neutral git remote identity for non-GitHub remotes', async () => { const repoPath = await makeTempRepoDir() await gitExecFileAsync(['init'], { cwd: repoPath }) diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index e0dcd15c839..5fa931f06c5 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -1,6 +1,11 @@ import { readFile, stat } from 'node:fs/promises' import type { GitHubRepositoryIdentity, RepoKind } from '../shared/types' -import { faviconUrlFromWebsite, githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' +import { + faviconUrlFromWebsite, + githubAvatarIcon, + githubAvatarSlug, + type RepoIcon +} from '../shared/repo-icon' import { getRepoSlug, getRepoUpstream } from './github/client' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' @@ -71,14 +76,13 @@ async function detectRemotePackageHomepageIcon( } } -async function detectGitHubAvatarIcon( +export async function detectGitHubAvatarIcon( repoPath: string, connectionId?: string | null, upstream?: GitHubRepositoryIdentity | null ): Promise { try { - // Why: a fork's origin is the personal copy, so prefer the upstream owner. - const slug = upstream ?? (await getRepoSlug(repoPath, connectionId)) + const slug = githubAvatarSlug(await getRepoSlug(repoPath, connectionId), upstream) return slug ? githubAvatarIcon(slug) : null } catch { return null diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 88818b3cb1d..b5f0c7a6c8d 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -162,7 +162,7 @@ export class DeviceRegistry { setRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) - if (index < 0 || binding.relayDeviceId !== deviceId) { + if (index === -1 || binding.relayDeviceId !== deviceId) { return false } const nextDevices = this.devices.map((device, candidateIndex) => @@ -175,7 +175,7 @@ export class DeviceRegistry { setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean { const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) - if (index < 0 || this.devices[index]?.scope !== 'mobile') { + if (index === -1 || this.devices[index]?.scope !== 'mobile') { return false } // Why: persist before swapping memory so a failed write does not leave a @@ -208,7 +208,7 @@ export class DeviceRegistry { updateLastSeen(deviceId: string): void { const index = this.devices.findIndex((d) => d.deviceId === deviceId) - if (index < 0) { + if (index === -1) { return } // Why: persist before memory swap so a failed write cannot leave a scanned @@ -230,7 +230,7 @@ export class DeviceRegistry { */ updateLastSeenDeferred(deviceId: string): void { const index = this.devices.findIndex((d) => d.deviceId === deviceId) - if (index < 0) { + if (index === -1) { return } if (this.devices[index]!.lastSeenAt === 0) { diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index 14c18115636..fc2d761c059 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -66,7 +66,7 @@ function fullRemoteFetchOptions(cwd: string): { cwd: string; timeout: number } { return { cwd, timeout: 60_000 } } -function mockFetchResults(results: (Promise | unknown)[]): void { +function mockFetchResults(results: unknown[]): void { let fetchIndex = 0 gitExecFileAsyncMock.mockImplementation((argv: string[]) => { if (argv[0] === 'rev-parse') { diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index 71c18b9412a..d986fa54938 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -134,6 +134,126 @@ describe('folder workspaces sharing one directory', () => { expect([...(inventory?.livePtyIds ?? [])]).toEqual([PTY_B]) }) + it('keeps the first resolved worktree when normalized identities collide', async () => { + const firstId = `${ROOT_ID}/duplicate/` + const equivalentId = `${ROOT_ID}/duplicate` + const laterId = `${ROOT_ID}/later` + const internals = createRuntimeInternals({ + sessions: [ + { id: 'later-owner-pty', worktreeId: laterId, cwd: FOLDER_PATH, title: 'shell' }, + { id: 'duplicate-owner-pty', worktreeId: equivalentId, cwd: FOLDER_PATH, title: 'shell' } + ] + }) + const resolvedWorktrees = [firstId, equivalentId, laterId].map((id) => + internals.buildResolvedWorktreeFromId(id) + ) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.get('duplicate-owner-pty')?.worktreeId).toBe(firstId) + }) + + it('stops indexing after a sparse owner match', async () => { + const ownerId = `${ROOT_ID}/first` + const internals = createRuntimeInternals({ + sessions: [{ id: 'sparse-owner-pty', worktreeId: ownerId, cwd: FOLDER_PATH, title: 'shell' }] + }) + let identityReads = 0 + const resolvedWorktrees = [ownerId, `${ROOT_ID}/unused-a`, `${ROOT_ID}/unused-b`].map((id) => { + const worktree = internals.buildResolvedWorktreeFromId(id) + if (!worktree || typeof worktree !== 'object') { + throw new Error(`Failed to resolve ${id}`) + } + return Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + identityReads += 1 + return id + } + }) + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.get('sparse-owner-pty')?.worktreeId).toBe(ownerId) + expect(identityReads).toBe(2) + }) + + it('keeps parsed and raw identity domains distinct', async () => { + const parsedId = `${ROOT_ID}/collision` + const rawId = `${REPO_ID}\0${FOLDER_PATH}/collision` + const internals = createRuntimeInternals({ + sessions: [{ id: 'raw-owner-pty', worktreeId: rawId, cwd: FOLDER_PATH, title: 'shell' }] + }) + const parsedWorktree = internals.buildResolvedWorktreeFromId(parsedId) + if (!parsedWorktree || typeof parsedWorktree !== 'object') { + throw new Error(`Failed to resolve ${parsedId}`) + } + const rawWorktree = { ...parsedWorktree, id: rawId } + + await internals.refreshPtyWorktreeRecordsWithControllerInventory([parsedWorktree, rawWorktree]) + + expect(internals.ptysById.get('raw-owner-pty')?.worktreeId).toBe(rawId) + }) + + it('bounds provider and persisted worktree resolution to linear identity reads', async () => { + const worktreeCount = 128 + const worktreeIds = Array.from( + { length: worktreeCount }, + (_, index) => `${ROOT_ID}/worktree-${index}` + ) + const persistedSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: Object.fromEntries( + worktreeIds.map((worktreeId, index) => [ + worktreeId, + [ + { + id: `indexed-tab-${index}`, + ptyId: `indexed-pty-${index}`, + worktreeId, + title: 'shell' + } + ] + ]) + ) as never + } + const internals = createRuntimeInternals({ + session: persistedSession, + sessions: worktreeIds.map((worktreeId, index) => ({ + id: `indexed-pty-${index}`, + worktreeId, + cwd: `${FOLDER_PATH}/worktree-${index}`, + title: 'shell' + })) + }) + let identityReads = 0 + const resolvedWorktrees = worktreeIds.map((id) => { + const worktree = internals.buildResolvedWorktreeFromId(id) + if (!worktree || typeof worktree !== 'object') { + throw new Error(`Failed to resolve ${id}`) + } + return Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + identityReads += 1 + return id + } + }) + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.size).toBe(worktreeCount) + expect(internals.ptysById.get('indexed-pty-0')?.worktreeId).toBe(worktreeIds[0]) + expect(internals.ptysById.get(`indexed-pty-${worktreeCount - 1}`)?.worktreeId).toBe( + worktreeIds[worktreeCount - 1] + ) + expect(identityReads).toBe(worktreeCount * 2) + }) + it('reports live PTYs per workspace instance and for the folder root separately', () => { const internals = createRuntimeInternals() internals.recordPtyWorktree(PTY_A, WORKSPACE_A, { connected: true }) diff --git a/src/main/runtime/headless-terminal-split-layout.test.ts b/src/main/runtime/headless-terminal-split-layout.test.ts index 000434ee3d7..0fa0d33b1c0 100644 --- a/src/main/runtime/headless-terminal-split-layout.test.ts +++ b/src/main/runtime/headless-terminal-split-layout.test.ts @@ -2,9 +2,27 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from '../../shared/types' import { buildHeadlessTerminalSplitLayout, - countTerminalLayoutLeaves + countTerminalLayoutLeaves, + terminalLayoutContainsLeaf } from './headless-terminal-split-layout' +describe('terminalLayoutContainsLeaf', () => { + it('requires the leaf in the layout tree', () => { + expect( + terminalLayoutContainsLeaf( + { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + 'leaf-b' + ) + ).toBe(true) + expect(terminalLayoutContainsLeaf({ type: 'leaf', leafId: 'leaf-a' }, 'leaf-b')).toBe(false) + }) +}) + describe('buildHeadlessTerminalSplitLayout (headless split persistence)', () => { it('splits a single-leaf tab into a 2-leaf split tree', () => { const existing: TerminalLayoutSnapshot = { @@ -84,6 +102,64 @@ describe('buildHeadlessTerminalSplitLayout (headless split persistence)', () => }) }) + it('replaces the provisional admission leaf with the requested split direction', () => { + const next = buildHeadlessTerminalSplitLayout( + { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-c' } + }, + activeLeafId: 'leaf-c', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-c': 'provisional-pty' } + }, + { leafId: 'leaf-c', ptyId: 'pty-c', splitFromLeafId: 'leaf-a', direction: 'horizontal' } + ) + + expect(next.root).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-c' } + }) + expect(next.ptyIdsByLeafId).toEqual({ 'leaf-a': 'pty-a', 'leaf-c': 'pty-c' }) + }) + + it('preserves a sibling committed by a concurrent split', () => { + const next = buildHeadlessTerminalSplitLayout( + { + root: { + type: 'split', + direction: 'vertical', + first: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + second: { type: 'leaf', leafId: 'leaf-c' } + }, + activeLeafId: 'leaf-c', + expandedLeafId: null, + ptyIdsByLeafId: { + 'leaf-a': 'pty-a', + 'leaf-b': 'pty-b', + 'leaf-c': 'provisional-pty' + } + }, + { leafId: 'leaf-c', ptyId: 'pty-c', splitFromLeafId: 'leaf-a', direction: 'vertical' } + ) + + expect(countTerminalLayoutLeaves(next.root)).toBe(3) + expect(next.ptyIdsByLeafId).toEqual({ + 'leaf-a': 'pty-a', + 'leaf-b': 'pty-b', + 'leaf-c': 'pty-c' + }) + }) + it('does not collapse — the persisted layout keeps both leaves (regression guard)', () => { // Why: the reported bug was the split collapsing back to one pane. After a // split, a rebuild reads this persisted layout, so it MUST stay multi-leaf. diff --git a/src/main/runtime/headless-terminal-split-layout.ts b/src/main/runtime/headless-terminal-split-layout.ts index c3947334f77..834625f9ef3 100644 --- a/src/main/runtime/headless-terminal-split-layout.ts +++ b/src/main/runtime/headless-terminal-split-layout.ts @@ -1,5 +1,18 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../shared/types' +export function terminalLayoutContainsLeaf( + node: TerminalPaneLayoutNode | null | undefined, + leafId: string +): boolean { + if (!node) { + return false + } + return node.type === 'leaf' + ? node.leafId === leafId + : terminalLayoutContainsLeaf(node.first, leafId) || + terminalLayoutContainsLeaf(node.second, leafId) +} + /** * Insert a newly split-off leaf into a terminal tab's persisted layout tree. * @@ -17,7 +30,23 @@ export function buildHeadlessTerminalSplitLayout( direction: 'horizontal' | 'vertical' } ): TerminalLayoutSnapshot { - const existingRoot: TerminalPaneLayoutNode = existing?.root ?? { + const removeProvisionalLeaf = (node: TerminalPaneLayoutNode): TerminalPaneLayoutNode | null => { + if (node.type === 'leaf') { + return node.leafId === args.leafId ? null : node + } + const first = removeProvisionalLeaf(node.first) + const second = removeProvisionalLeaf(node.second) + if (!first) { + return second + } + if (!second) { + return first + } + return { ...node, first, second } + } + // Why: PTY admission durably appends a fallback vertical leaf before this exact-direction commit. + const currentRoot = existing?.root ? removeProvisionalLeaf(existing.root) : null + const existingRoot: TerminalPaneLayoutNode = currentRoot ?? { type: 'leaf', leafId: args.splitFromLeafId } @@ -35,13 +64,15 @@ export function buildHeadlessTerminalSplitLayout( } return { ...node, first: insertSplit(node.first), second: insertSplit(node.second) } } + const ptyIdsByLeafId = { ...existing?.ptyIdsByLeafId } + delete ptyIdsByLeafId[args.leafId] return { ...existing, root: insertSplit(existingRoot), activeLeafId: args.leafId, expandedLeafId: existing?.expandedLeafId ?? null, ptyIdsByLeafId: { - ...existing?.ptyIdsByLeafId, + ...ptyIdsByLeafId, [args.leafId]: args.ptyId } } diff --git a/src/main/runtime/linear-save-issue.test.ts b/src/main/runtime/linear-save-issue.test.ts index add8a54d1ab..eb8ed4093d2 100644 --- a/src/main/runtime/linear-save-issue.test.ts +++ b/src/main/runtime/linear-save-issue.test.ts @@ -23,7 +23,7 @@ const issue = { type SaveIssueInternals = { resolveLinearAssignee(input: string, teamId: string, workspaceId: string): Promise - resolveLinearAgentState(input: string, states: unknown[]): unknown | null + resolveLinearAgentState(input: string, states: unknown[]): unknown buildLinearSaveUpdate( params: { labels?: string[] }, current: typeof issue, diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts new file mode 100644 index 00000000000..582837826ac --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMobileSessionTabsAgentStatusHeartbeat, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS +} from './mobile-session-tabs-agent-status-heartbeat' + +describe('mobile session-tabs agent-status heartbeat', () => { + const worktreesByPtyId = new Map() + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + worktreesByPtyId.clear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('refreshes continuously active PTYs through one globally capped FIFO', () => { + const emitted: { worktreeId: string; at: number }[] = [] + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push({ worktreeId, at: Date.now() }) + ) + const ptyIds = Array.from({ length: 24 }, (_, index) => `pty-${index}`) + for (const ptyId of ptyIds) { + worktreesByPtyId.set(ptyId, [ptyId.replace('pty', 'worktree')]) + heartbeat.observeSemanticTitle(ptyId) + } + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - 1) + for (const ptyId of ptyIds) { + heartbeat.scheduleDecorativeHeartbeat(ptyId) + } + vi.runOnlyPendingTimers() + expect(emitted).toEqual([]) + + vi.advanceTimersByTime(1) + for (const ptyId of ptyIds) { + heartbeat.scheduleDecorativeHeartbeat(ptyId) + } + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS * (ptyIds.length - 1)) + + expect(emitted.map(({ worktreeId }) => worktreeId)).toEqual( + ptyIds.map((ptyId) => ptyId.replace('pty', 'worktree')) + ) + for (let index = 1; index < emitted.length; index += 1) { + expect(emitted[index]!.at - emitted[index - 1]!.at).toBeGreaterThanOrEqual( + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + ) + } + for (const ptyId of ptyIds) { + heartbeat.scheduleDecorativeHeartbeat(ptyId) + } + vi.runOnlyPendingTimers() + expect(emitted).toHaveLength(ptyIds.length) + heartbeat.dispose() + expect(vi.getTimerCount()).toBe(0) + }) + + it('lets semantic transitions bypass and cancel queued heartbeats', () => { + const emitted: string[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-2']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push(worktreeId) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + heartbeat.observeSemanticTitle('pty-2') + vi.runAllTimers() + + expect(emitted).toEqual(['worktree-1']) + heartbeat.removePty('pty-1') + heartbeat.removePty('pty-2') + expect(vi.getTimerCount()).toBe(0) + }) + + it('deduplicates sibling PTYs because one snapshot refreshes their worktree', () => { + const emitted: string[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-1']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push(worktreeId) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + heartbeat.removePty('pty-1') + vi.runAllTimers() + + expect(emitted).toEqual(['worktree-1']) + }) + + it('does not slip spacing-delayed worktrees to the next stale boundary', () => { + const emitted: { worktreeId: string; at: number }[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-2']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push({ worktreeId, at: Date.now() }) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS) + expect(emitted).toEqual([ + { worktreeId: 'worktree-1', at: SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS }, + { + worktreeId: 'worktree-2', + at: + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + } + ]) + + vi.advanceTimersByTime( + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + ) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.runOnlyPendingTimers() + expect(emitted.at(-1)).toEqual({ + worktreeId: 'worktree-1', + at: SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 + }) + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS - 1) + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.runOnlyPendingTimers() + expect(emitted).toHaveLength(3) + + vi.advanceTimersByTime(1) + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.runOnlyPendingTimers() + expect(emitted.at(-1)).toEqual({ + worktreeId: 'worktree-2', + at: + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 + + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + }) + }) + + it('drops removed worktrees and pending work during teardown', () => { + const emitted: string[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-2']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push(worktreeId) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + expect(vi.getTimerCount()).toBe(1) + heartbeat.removePty('pty-1') + vi.runAllTimers() + expect(emitted).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + + heartbeat.scheduleDecorativeHeartbeat('pty-2') + expect(vi.getTimerCount()).toBe(1) + heartbeat.removeWorktree('worktree-2') + expect(vi.getTimerCount()).toBe(0) + + heartbeat.scheduleDecorativeHeartbeat('pty-1') + expect(vi.getTimerCount()).toBe(1) + heartbeat.cancelPending() + expect(vi.getTimerCount()).toBe(0) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.dispose() + vi.runAllTimers() + expect(emitted).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts new file mode 100644 index 00000000000..6c80cf4a35d --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts @@ -0,0 +1,133 @@ +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' + +// Refresh halfway through the stale lease so a bounded global queue has ample drain time. +export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS = AGENT_STATUS_STALE_AFTER_MS / 2 +export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS = 50 + +export type MobileSessionTabsAgentStatusHeartbeat = { + observeSemanticTitle: (ptyId: string) => void + scheduleDecorativeHeartbeat: (ptyId: string) => void + removePty: (ptyId: string) => void + removeWorktree: (worktreeId: string) => void + cancelPending: () => void + dispose: () => void +} + +export function createMobileSessionTabsAgentStatusHeartbeat( + resolveWorktreeIds: (ptyId: string) => Iterable, + emit: (worktreeId: string) => void +): MobileSessionTabsAgentStatusHeartbeat { + const lastEligibilityCheckAtByPtyId = new Map() + const lastRefreshAtByWorktreeId = new Map() + const pendingPtyIdsByWorktreeId = new Map>() + let lastGlobalHeartbeatAt: number | null = null + let timer: ReturnType | null = null + + const clearTimer = (): void => { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + const arm = (): void => { + if (timer !== null || pendingPtyIdsByWorktreeId.size === 0) { + return + } + const now = Date.now() + const delay = + lastGlobalHeartbeatAt === null + ? 0 + : Math.max( + 0, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS - (now - lastGlobalHeartbeatAt) + ) + timer = setTimeout(() => { + timer = null + const worktreeId = pendingPtyIdsByWorktreeId.keys().next().value + if (typeof worktreeId !== 'string') { + return + } + const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) + pendingPtyIdsByWorktreeId.delete(worktreeId) + const emittedAt = Date.now() + lastRefreshAtByWorktreeId.set(worktreeId, emittedAt) + for (const ptyId of pendingPtyIds ?? []) { + lastEligibilityCheckAtByPtyId.set(ptyId, emittedAt) + } + lastGlobalHeartbeatAt = emittedAt + emit(worktreeId) + arm() + }, delay) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + return { + observeSemanticTitle(ptyId: string): void { + const observedAt = Date.now() + lastEligibilityCheckAtByPtyId.set(ptyId, observedAt) + for (const worktreeId of resolveWorktreeIds(ptyId)) { + lastRefreshAtByWorktreeId.set(worktreeId, observedAt) + pendingPtyIdsByWorktreeId.delete(worktreeId) + } + if (pendingPtyIdsByWorktreeId.size === 0) { + clearTimer() + } + }, + scheduleDecorativeHeartbeat(ptyId: string): void { + const now = Date.now() + const lastEligibilityCheckAt = lastEligibilityCheckAtByPtyId.get(ptyId) + if ( + lastEligibilityCheckAt !== undefined && + now - lastEligibilityCheckAt < SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + ) { + return + } + lastEligibilityCheckAtByPtyId.set(ptyId, now) + for (const worktreeId of resolveWorktreeIds(ptyId)) { + const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId) + if ( + lastRefreshAt === undefined || + now - lastRefreshAt >= SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + ) { + const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) ?? new Set() + pendingPtyIds.add(ptyId) + pendingPtyIdsByWorktreeId.set(worktreeId, pendingPtyIds) + } + } + arm() + }, + removePty(ptyId: string): void { + lastEligibilityCheckAtByPtyId.delete(ptyId) + for (const [worktreeId, pendingPtyIds] of pendingPtyIdsByWorktreeId) { + pendingPtyIds.delete(ptyId) + if (pendingPtyIds.size === 0) { + pendingPtyIdsByWorktreeId.delete(worktreeId) + } + } + if (pendingPtyIdsByWorktreeId.size === 0) { + clearTimer() + } + }, + removeWorktree(worktreeId: string): void { + lastRefreshAtByWorktreeId.delete(worktreeId) + pendingPtyIdsByWorktreeId.delete(worktreeId) + if (pendingPtyIdsByWorktreeId.size === 0) { + clearTimer() + } + }, + cancelPending(): void { + clearTimer() + pendingPtyIdsByWorktreeId.clear() + }, + dispose(): void { + clearTimer() + pendingPtyIdsByWorktreeId.clear() + lastEligibilityCheckAtByPtyId.clear() + lastRefreshAtByWorktreeId.clear() + lastGlobalHeartbeatAt = null + } + } +} diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index 1c641334016..50fd09a4639 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -304,6 +304,8 @@ describe('paired runtime navigation isolation', () => { const clientB = await authenticate(offerB.pairingUrl) sessions.push(clientB) expect(offerA.deviceId).not.toBe(offerB.deviceId) + expect(parsePairingCode(offerA.pairingUrl)?.pairedDeviceId).toBe(offerA.deviceId) + expect(parsePairingCode(offerB.pairingUrl)?.pairedDeviceId).toBe(offerB.deviceId) expect(parsePairingCode(offerA.pairingUrl)?.deviceToken).not.toBe( parsePairingCode(offerB.pairingUrl)?.deviceToken ) @@ -318,12 +320,92 @@ describe('paired runtime navigation isolation', () => { focusTerminal, clientA, clientB, + deviceIdA: offerA.deviceId, + deviceIdB: offerB.deviceId, pairingUrlA: offerA.pairingUrl, readerA, readerB } } + it('attributes workspace creation to each authenticated device across reconnect', async () => { + const harness = await startHarness() + const createManagedWorktree = vi + .spyOn(harness.runtime, 'createManagedWorktree') + .mockImplementation(async (args) => { + const creatorProvenance = (args as unknown as Record).creatorProvenance + return { + worktree: { + id: worktreeId(args.name), + creatorProvenance + } + } as never + }) + + send(harness.clientA, { + id: 'status-a', + method: 'status.get' + }) + send(harness.clientB, { + id: 'status-b', + method: 'status.get' + }) + const [statusA, statusB] = await Promise.all([ + harness.readerA.next('status-a'), + harness.readerB.next('status-b') + ]) + expect(statusA).toMatchObject({ + ok: true, + result: { pairedDeviceId: harness.deviceIdA } + }) + expect(statusB).toMatchObject({ + ok: true, + result: { pairedDeviceId: harness.deviceIdB } + }) + + send(harness.clientA, { + id: 'create-a', + method: 'worktree.create', + params: { + repo: REPO_ID, + name: 'created-a', + creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB } + } + }) + send(harness.clientB, { + id: 'create-b', + method: 'worktree.create', + params: { repo: REPO_ID, name: 'created-b' } + }) + await Promise.all([harness.readerA.next('create-a'), harness.readerB.next('create-b')]) + expect(createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'created-a', + creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdA } + }) + ) + expect(createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'created-b', + creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB } + }) + ) + + await new Promise((resolve) => { + harness.clientA.ws.once('close', () => resolve()) + harness.clientA.ws.close() + }) + const reconnectedA = await authenticate(harness.pairingUrlA) + sessions.push(reconnectedA) + const reconnectedReaderA = createReader(reconnectedA) + readers.push(reconnectedReaderA) + send(reconnectedA, { id: 'status-a2', method: 'status.get' }) + await expect(reconnectedReaderA.next('status-a2')).resolves.toMatchObject({ + ok: true, + result: { pairedDeviceId: harness.deviceIdA } + }) + }) + it('keeps worktree navigation local to each paired runtime client by default', async () => { const harness = await startHarness() const clientSelections = { diff --git a/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts b/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts new file mode 100644 index 00000000000..23f7adc4df8 --- /dev/null +++ b/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest' +import type { EmulatorSessionInfo } from '../emulator/emulator-types' +import type { FolderWorkspace } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +const FOLDER_WORKSPACE_ID = 'folder-workspace-1' +const FOLDER_WORKSPACE_KEY = `folder:${FOLDER_WORKSPACE_ID}` +const EMULATOR_INFO: EmulatorSessionInfo = { + deviceUdid: 'emulator-5554', + streamUrl: 'scrcpy://emulator-5554', + wsUrl: '', + streamCodec: 'h264', + backend: 'android' +} + +function makeFolderWorkspace(): FolderWorkspace { + return { + id: FOLDER_WORKSPACE_ID, + projectGroupId: 'project-group-1', + name: 'Mobile app', + folderPath: '/tmp/mobile-app', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } +} + +describe('RuntimeEmulatorCommands folder workspace routing', () => { + it.each([FOLDER_WORKSPACE_KEY, `id:${FOLDER_WORKSPACE_KEY}`])( + 'registers and publishes an attached device for selector %s', + async (selector) => { + const bridge = { + acquireHelperForDevice: vi.fn(async () => ({ + info: EMULATOR_INFO, + release: vi.fn(async () => {}) + })), + getReusableActiveForWorktree: vi.fn(async () => null), + registerActiveEmulator: vi.fn(), + stopActiveForSwitch: vi.fn(async () => null) + } + const send = vi.fn() + const runtime = new OrcaRuntimeService({ + getFolderWorkspaces: () => [makeFolderWorkspace()], + getAllWorktreeMeta: () => new Map(), + getRepo: () => null, + getRepos: () => [], + getSettings: () => ({ + mobileEmulatorEnabled: true, + mobileEmulatorDefaultDeviceUdid: null, + androidSdkPath: null + }) + } as never) + runtime.setEmulatorBridge(bridge as never) + Object.assign(runtime, { + getAuthoritativeWindow: () => ({ webContents: { send } }) + }) + + await expect( + runtime.emulatorAttach({ + device: 'emulator-5554', + worktree: selector, + focus: true + }) + ).resolves.toEqual({ attached: true, info: EMULATOR_INFO }) + + expect(bridge.acquireHelperForDevice).toHaveBeenCalledOnce() + expect(bridge.registerActiveEmulator).toHaveBeenCalledWith( + FOLDER_WORKSPACE_KEY, + EMULATOR_INFO, + { managed: true } + ) + expect(send.mock.calls).toEqual([ + ['ui:emulatorAutoAttach', { worktreeId: FOLDER_WORKSPACE_KEY, info: EMULATOR_INFO }], + ['emulator:pane-focus', { worktreeId: FOLDER_WORKSPACE_KEY }] + ]) + } + ) + + it.each([FOLDER_WORKSPACE_KEY, `id:${FOLDER_WORKSPACE_KEY}`])( + 'cleans up an active emulator after selector %s is deleted', + async (selector) => { + let folderWorkspaces = [makeFolderWorkspace()] + const shutdownActiveManagedForWorktree = vi.fn(async () => EMULATOR_INFO.deviceUdid) + const runtime = new OrcaRuntimeService({ + getFolderWorkspaces: () => folderWorkspaces, + getAllWorktreeMeta: () => new Map(), + getRepo: () => null, + getRepos: () => [], + getSettings: () => ({ androidSdkPath: null }) + } as never) + runtime.setEmulatorBridge({ shutdownActiveManagedForWorktree } as never) + + folderWorkspaces = [] + + await expect( + runtime.emulatorShutdown({ worktree: selector, managedOnly: true }) + ).resolves.toEqual({ ok: true, deviceUdid: EMULATOR_INFO.deviceUdid }) + expect(shutdownActiveManagedForWorktree).toHaveBeenCalledWith(FOLDER_WORKSPACE_KEY) + } + ) + + it('cleans up an attach that finishes after its folder workspace is deleted', async () => { + let folderWorkspaces = [makeFolderWorkspace()] + const release = vi.fn(async () => {}) + let finishHelperStart: + | ((lease: { info: EmulatorSessionInfo; release: typeof release }) => void) + | undefined + const bridge = { + acquireHelperForDevice: vi.fn( + () => + new Promise<{ info: EmulatorSessionInfo; release: typeof release }>((resolve) => { + finishHelperStart = resolve + }) + ), + getReusableActiveForWorktree: vi.fn(async () => null), + registerActiveEmulator: vi.fn(), + shutdownActiveManagedForWorktree: vi.fn(async () => null), + stopActiveForSwitch: vi.fn(async () => null) + } + const runtime = new OrcaRuntimeService({ + getFolderWorkspaces: () => folderWorkspaces, + getAllWorktreeMeta: () => new Map(), + getRepo: () => null, + getRepos: () => [], + getSettings: () => ({ + mobileEmulatorEnabled: true, + mobileEmulatorDefaultDeviceUdid: null, + androidSdkPath: null + }) + } as never) + runtime.setEmulatorBridge(bridge as never) + Object.assign(runtime, { + getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) + }) + + const attach = runtime.emulatorAttach({ + device: EMULATOR_INFO.deviceUdid, + worktree: FOLDER_WORKSPACE_KEY + }) + await vi.waitFor(() => expect(bridge.acquireHelperForDevice).toHaveBeenCalledOnce()) + folderWorkspaces = [] + await expect( + runtime.emulatorShutdown({ worktree: FOLDER_WORKSPACE_KEY, managedOnly: true }) + ).resolves.toEqual({ ok: true, deviceUdid: undefined }) + finishHelperStart?.({ info: EMULATOR_INFO, release }) + await expect(attach).rejects.toMatchObject({ + code: 'emulator_no_active', + message: 'The workspace changed while the emulator was starting. Reattach the emulator.' + }) + + expect(bridge.registerActiveEmulator).not.toHaveBeenCalled() + expect(bridge.shutdownActiveManagedForWorktree).toHaveBeenCalledWith(FOLDER_WORKSPACE_KEY) + expect(release).toHaveBeenCalledWith({ cleanupIfUnused: true }) + }) +}) diff --git a/src/main/runtime/orca-runtime-emulator.ts b/src/main/runtime/orca-runtime-emulator.ts index ac67464a533..7c82980c2a3 100644 --- a/src/main/runtime/orca-runtime-emulator.ts +++ b/src/main/runtime/orca-runtime-emulator.ts @@ -22,7 +22,8 @@ type EmulatorHostSettings = Pick< // Why: dedicated file for "one surface" separation (emulator), parallel to orca-runtime-browser.ts. Keeps OrcaRuntimeService focused; emulator routing easy to scan. No max-lines disable (split further if grows; per AGENTS + plan Phase 3). export type RuntimeEmulatorCommandHost = { getEmulatorBridge(): EmulatorBridge | null - resolveWorktreeSelector(selector: string): Promise<{ id: string }> + resolveEmulatorWorkspaceId(selector: string): Promise + resolveEmulatorCleanupWorkspaceId(selector: string): Promise getAuthoritativeWindow(): BrowserWindow getSettings(): EmulatorHostSettings } @@ -157,13 +158,36 @@ export class RuntimeEmulatorCommands { // slow-to-boot Android emulator alive for instant switch-back. await bridge.stopActiveForSwitch(worktreeId) } - const info = await bridge.startHelperForDevice(device) + const lease = await bridge.acquireHelperForDevice(device) + const { info } = lease if (worktreeId) { + try { + const currentWorktreeId = await this.resolveWorktreeId(params.worktree) + if (currentWorktreeId !== worktreeId) { + throw new EmulatorError( + 'emulator_no_active', + 'The workspace changed while the emulator was starting. Reattach the emulator.' + ) + } + } catch (error) { + // Why: the workspace can disappear while a slow Android device boots. + await lease.release({ cleanupIfUnused: true }).catch(() => {}) + if (error instanceof Error && error.message === 'selector_not_found') { + throw new EmulatorError( + 'emulator_no_active', + 'The workspace changed while the emulator was starting. Reattach the emulator.' + ) + } + throw error + } bridge.registerActiveEmulator(worktreeId, info, { managed: true }) + await lease.release() this.notifyRendererEmulatorAutoAttach(worktreeId, info) if (params.focus) { this.notifyRendererEmulatorPaneFocus(worktreeId) } + } else { + await lease.release() } // Default: no auto steal (mirror browser tab create/switch). --focus sends emulator:pane-focus only when requested. return { attached: true, info } @@ -176,7 +200,7 @@ export class RuntimeEmulatorCommands { async emulatorUnregisterActive(params: { worktree?: string }): Promise<{ ok: true }> { const bridge = this.requireEmulatorBridge() - const worktreeId = await this.resolveWorktreeId(params.worktree) + const worktreeId = await this.resolveCleanupWorktreeId(params.worktree) if (worktreeId) { bridge.unregisterActiveEmulator(worktreeId) } @@ -203,7 +227,11 @@ export class RuntimeEmulatorCommands { } private async resolveWorktreeId(worktree?: string): Promise { - return worktree ? (await this.host.resolveWorktreeSelector(worktree)).id : undefined + return worktree ? await this.host.resolveEmulatorWorkspaceId(worktree) : undefined + } + + private async resolveCleanupWorktreeId(worktree?: string): Promise { + return worktree ? await this.host.resolveEmulatorCleanupWorkspaceId(worktree) : undefined } async emulatorInstall( @@ -272,7 +300,7 @@ export class RuntimeEmulatorCommands { worktree?: string }): Promise<{ ok: true; deviceUdid: string }> { const bridge = this.requireEmulatorBridge() - const worktreeId = await this.resolveWorktreeId(params.worktree) + const worktreeId = await this.resolveCleanupWorktreeId(params.worktree) const killedUdid = await bridge.kill(params.device ?? params.emulator, worktreeId) return { ok: true, deviceUdid: killedUdid } } @@ -284,7 +312,7 @@ export class RuntimeEmulatorCommands { managedOnly?: boolean }): Promise<{ ok: true; deviceUdid?: string }> { const bridge = this.requireEmulatorBridge() - const worktreeId = await this.resolveWorktreeId(params.worktree) + const worktreeId = await this.resolveCleanupWorktreeId(params.worktree) if (params.managedOnly && worktreeId && !params.device && !params.emulator) { const shutdownUdid = await bridge.shutdownActiveManagedForWorktree(worktreeId) return { ok: true, deviceUdid: shutdownUdid ?? undefined } diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e5c8a18479e..ce96eddb5e0 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -22,6 +22,7 @@ const { closeWatcherInWatcherProcessMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, + searchWithGitGrepMock, wslAwareSpawnMock, watchMock } = vi.hoisted(() => ({ @@ -35,6 +36,7 @@ const { statMock: vi.fn(), watchInWatcherProcessMock: vi.fn(), closeWatcherInWatcherProcessMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), wslAwareSpawnMock: vi.fn(), watchMock: vi.fn() })) @@ -91,6 +93,10 @@ vi.mock('../ipc/local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock })) +vi.mock('../ipc/filesystem-search-git', () => ({ + searchWithGitGrep: searchWithGitGrepMock +})) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn(), onSshFilesystemProviderRegistered: () => () => undefined, @@ -202,6 +208,12 @@ function createRuntimeSearchChild(): MockRuntimeSearchChild { return child } +async function flushRuntimeSearchMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + describe('RuntimeFileCommands', () => { const originalPlatform = process.platform @@ -217,6 +229,7 @@ describe('RuntimeFileCommands', () => { closeWatcherInWatcherProcessMock.mockReset() watchMock.mockReset() checkRgAvailableMock.mockReset() + searchWithGitGrepMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() resetSshConnectionGenerations() getLocalGitOptionsForRegisteredWorktreeMock.mockReset() @@ -754,6 +767,68 @@ describe('RuntimeFileCommands', () => { expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + }) + + it.each(['error-first', 'close-first'] as const)( + 'falls back once when runtime rg native launch failure is %s', + async (order) => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: undefined }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => child.emit('error', error)).not.toThrow() + child.emit('close', -2, null) + } else { + child.emit('close', -2, null) + expect(() => child.emit('error', error)).not.toThrow() + } + + await expect(resultPromise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } + ) + + it("falls back when a runtime native launcher exits outside ripgrep's contract", async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: 1 }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + child.emit('close', 127, null) + + await expect(resultPromise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) }) it('routes runtime rg searches through the registered WSL project runtime', async () => { @@ -767,6 +842,7 @@ describe('RuntimeFileCommands', () => { })) const { commands, store } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: 1 }) resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) @@ -779,7 +855,7 @@ describe('RuntimeFileCommands', () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() - child.emit('close') + child.emit('close', 127, null) await expect(resultPromise).resolves.toMatchObject({ files: [] }) expect(getLocalGitOptionsForRegisteredWorktreeMock).toHaveBeenCalledWith( @@ -788,6 +864,7 @@ describe('RuntimeFileCommands', () => { 'C:\\repo' ) expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(searchWithGitGrepMock).not.toHaveBeenCalled() expect(wslAwareSpawnMock).toHaveBeenCalledWith( 'rg', expect.any(Array), @@ -798,6 +875,25 @@ describe('RuntimeFileCommands', () => { ) }) + it('keeps the runtime WSL preflight and falls back before starting real rg', async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const fallback = { files: [], totalMatches: 0, truncated: false } + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + searchWithGitGrepMock.mockResolvedValue(fallback) + + await expect( + commands.searchRuntimeFiles('id:wt-1', { query: 'needle', maxResults: 10 }) + ).resolves.toBe(fallback) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + describe('resolveTerminalPath', () => { let tempDirs: string[] = [] diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 68000f1b297..f85bff8abde 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -58,6 +58,11 @@ import { listQuickOpenFiles } from '../ipc/filesystem-list-files' import { searchWithGitGrep } from '../ipc/filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' import { checkRgAvailable } from '../ipc/rg-availability' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../shared/ripgrep-process-availability' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths @@ -1920,26 +1925,33 @@ export class RuntimeFileCommands { 1, Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) - const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + const wslInfo = parseWslPath(authorizedRootPath) + if ( + (wslInfo || localGitOptions.wslDistro) && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions) } - return new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) - this.activeRuntimeTextSearches.get(searchKey)?.kill() + const previousChild = this.activeRuntimeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } const acc = createAccumulator() let stdoutBuffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false let child: ChildProcess | null = null - const wslInfo = parseWslPath(authorizedRootPath) const transformAbsPath = wslInfo ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) : undefined - const resolveOnce = (): void => { + const finish = (result: SearchResult | PromiseLike): void => { if (resolved) { return } @@ -1948,8 +1960,11 @@ export class RuntimeFileCommands { this.activeRuntimeTextSearches.delete(searchKey) } cleanupListeners() - resolvePromise(finalize(acc)) + resolvePromise(result) } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)) let killTimeout: ReturnType | null = null const cleanupListeners = (): void => { @@ -1961,6 +1976,12 @@ export class RuntimeFileCommands { child?.stderr?.off('data', onStderrData) child?.off('error', onError) child?.off('close', onClose) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } } const processLine = (line: string): void => { @@ -1971,8 +1992,8 @@ export class RuntimeFileCommands { maxResults, transformAbsPath ) - if (verdict === 'stop') { - child?.kill() + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) } } @@ -1996,8 +2017,25 @@ export class RuntimeFileCommands { const onStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. } - const onError = (): void => resolveOnce() - const onClose = (): void => { + const onError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } + resolveOnce() + } + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro) + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } if (stdoutBuffer) { processLine(stdoutBuffer) } @@ -2011,7 +2049,9 @@ export class RuntimeFileCommands { killTimeout = setTimeout(() => { acc.truncated = true - child?.kill() + if (child) { + killSpawnedRipgrepProcess(child) + } resolveOnce() }, SEARCH_TIMEOUT_MS) }) diff --git a/src/main/runtime/orca-runtime-git-branch-diff.test.ts b/src/main/runtime/orca-runtime-git-branch-diff.test.ts new file mode 100644 index 00000000000..4c469758760 --- /dev/null +++ b/src/main/runtime/orca-runtime-git-branch-diff.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/types' +import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +function makeWorktree(path: string): ResolvedRuntimeGitWorktree { + const worktree = { + id: 'wt-1', + repoId: 'repo-1', + path, + linkedIssue: null, + git: { + path, + branch: 'main', + isBare: false, + isMainWorktree: false, + head: 'a'.repeat(40) + } + } satisfies Partial + return worktree as unknown as ResolvedRuntimeGitWorktree +} + +describe('RuntimeGitCommands branch diff', () => { + beforeEach(() => { + getSshGitProviderMock.mockReset() + }) + + it('forwards the pinned head through normalized nested SSH paths', async () => { + const mergeBase = 'a'.repeat(40) + const headOid = 'b'.repeat(40) + const result = { + kind: 'text', + originalContent: 'left', + modifiedContent: 'right', + originalIsBinary: false, + modifiedIsBinary: false + } + const provider = { getBranchDiff: vi.fn().mockResolvedValue([result]) } + getSshGitProviderMock.mockReturnValue(provider) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree('/remote/repo'), + connectionId: 'conn-1' + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await expect( + commands.getRuntimeGitBranchDiff( + 'id:wt-1', + { mergeBase, headOid }, + 'src\\file.ts', + 'src\\old-file.ts' + ) + ).resolves.toEqual(result) + + expect(provider.getBranchDiff).toHaveBeenCalledWith('/remote/repo', mergeBase, { + includePatch: true, + headOid, + filePath: 'src/file.ts', + oldPath: 'src/old-file.ts' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 88fb102f7e9..022b53ac948 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -79,7 +79,7 @@ import type { import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment' import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { normalizeRuntimeRelativePath } from './runtime-relative-paths' -import { gitExecFileAsync } from '../git/runner' +import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync } from '../git/runner' import type { GitRuntimeOptions } from '../git/git-runtime-options' import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' import { @@ -538,6 +538,7 @@ export class RuntimeGitCommands { } const results = await provider.getBranchDiff(target.worktree.path, compare.mergeBase, { includePatch: true, + headOid: compare.headOid, filePath: relativePath, oldPath: oldRelativePath }) @@ -985,6 +986,7 @@ export class RuntimeGitCommands { } return provider.getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line) } + await awaitWindowsHostGitEnvironmentReady({ cwd: target.worktree.path }) return getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line) } @@ -1000,6 +1002,7 @@ export class RuntimeGitCommands { } return provider.getRemoteCommitUrl(target.worktree.path, sha) } + await awaitWindowsHostGitEnvironmentReady({ cwd: target.worktree.path }) return getRemoteCommitUrl(target.worktree.path, sha) } } diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 47715ea7c5d..7981366cadb 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -230,6 +230,65 @@ describe('headless hook agent-status projection (#11761)', () => { ) }) + it('does not carry a shell-obscured question into the next working interval', async () => { + const rows = [hookRow()] + const runtime = await createRuntimeWithHookRows(rows) + const questionAt = rows[0]!.receivedAt + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(questionAt + 1) + try { + const initial = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const initialTab = initial.tabs[0] + if (initialTab?.type !== 'terminal' || !initialTab.terminal) { + throw new Error('expected a live terminal handle') + } + await runtime.renameTerminal(initialTab.terminal, 'bash') + observePaneTitle(runtime, 'bash') + let result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + let tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).toEqual( + expect.objectContaining({ state: 'waiting', interactivePrompt: ASK_PROMPT }) + ) + + dateNow.mockReturnValue(questionAt + 2) + observePaneTitle(runtime, '⠋ Claude') + result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).toEqual( + expect.objectContaining({ state: 'working', prompt: '' }) + ) + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') + } finally { + dateNow.mockRestore() + } + }) + + it('does not carry a hook question across a provider generation reset', async () => { + const runtime = await createRuntimeWithHookRows([hookRow()]) + const internals = runtime as unknown as { + ptysById: Map + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void + } + internals.ptysById.get(PTY_ID)!.title = 'bash' + internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID) + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') + }) + + it('does not carry a hook question across an identity-only owner title', async () => { + const runtime = await createRuntimeWithHookRows([hookRow()]) + const internals = runtime as unknown as { + ptysById: Map + } + internals.ptysById.get(PTY_ID)!.title = 'bash' + observePaneTitle(runtime, 'Cursor Agent') + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') + }) + // The title path is refreshed live; an older hook `done` must not erase it. it('keeps the title-derived working state when the hook row predates the title', async () => { const now = Date.now() diff --git a/src/main/runtime/orca-runtime-path-candidate-history.test.ts b/src/main/runtime/orca-runtime-path-candidate-history.test.ts index 80b09ed81e3..516abfa1255 100644 --- a/src/main/runtime/orca-runtime-path-candidate-history.test.ts +++ b/src/main/runtime/orca-runtime-path-candidate-history.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { appendRecentPtyPathCandidates } from './orca-runtime' +import { appendRecentPtyPathCandidates } from './terminal-output-path-candidates' function fullCandidateHistory(): string[] { return Array.from( diff --git a/src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts b/src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts new file mode 100644 index 00000000000..06a5f1cfcd0 --- /dev/null +++ b/src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PtyProcessInfo } from '../providers/pty-process-info' +import { OrcaRuntimeService } from './orca-runtime' + +const SSH_SCOPE = JSON.stringify({ kind: 'ssh', targetId: 'ssh-1' }) +const PROCESS_INCARNATION = 'remote:ssh-1:pty-1:inc-1' + +function runtimeWithInventory( + listProcesses: (connectionId?: string | null) => Promise +): OrcaRuntimeService { + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses + }) + return runtime +} + +describe('terminal process incarnation liveness', () => { + it('classifies only an exact incarnation as live on its owning provider', async () => { + const listProcesses = vi.fn().mockResolvedValue([ + { + id: 'remote:ssh-1:pty-1', + incarnationId: 'inc-1', + cwd: '', + title: 'worker' + } + ]) + const runtime = runtimeWithInventory(listProcesses) + + await expect( + runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE) + ).resolves.toBe('live') + await expect( + runtime.inspectTerminalProcessIncarnationLiveness('remote:ssh-1:pty-1:inc-old', SSH_SCOPE) + ).resolves.toBe('dead') + expect(listProcesses).toHaveBeenNthCalledWith(1, 'ssh-1') + expect(listProcesses).toHaveBeenNthCalledWith(2, 'ssh-1') + }) + + it('keeps missing or malformed identity and unavailable inventory unknown', async () => { + const listProcesses = vi + .fn() + .mockResolvedValueOnce([{ id: 'remote:ssh-1:pty-1', cwd: '', title: 'worker' }]) + .mockResolvedValueOnce([ + { id: 'remote:ssh-1:pty-1', incarnationId: ' inc-1 ', cwd: '', title: 'worker' } + ]) + .mockRejectedValueOnce(new Error('provider unavailable')) + const runtime = runtimeWithInventory(listProcesses) + + await expect( + runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE) + ).resolves.toBe('unknown') + await expect( + runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE) + ).resolves.toBe('unknown') + await expect( + runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE) + ).resolves.toBe('unknown') + }) + + it('does not inspect an unproven host scope', async () => { + const listProcesses = vi.fn().mockResolvedValue([]) + const runtime = runtimeWithInventory(listProcesses) + + await expect( + runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, null) + ).resolves.toBe('unknown') + await expect( + runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, '{"kind":"ssh"}') + ).resolves.toBe('unknown') + expect(listProcesses).not.toHaveBeenCalled() + }) + + it.each([ + [{ kind: 'local', hostId: 'local' }, null], + [{ kind: 'wsl', hostId: 'local', distro: 'Ubuntu' }, null] + ] as const)('uses the local provider inventory for %s scope', async (scope, connectionId) => { + const listProcesses = vi.fn().mockResolvedValue([]) + const runtime = runtimeWithInventory(listProcesses) + + await expect( + runtime.inspectTerminalProcessIncarnationLiveness('local-pty:inc-1', JSON.stringify(scope)) + ).resolves.toBe('dead') + expect(listProcesses).toHaveBeenCalledWith(connectionId) + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts new file mode 100644 index 00000000000..c13f8825380 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import type { TerminalLayoutSnapshot, WorkspaceSessionState } from '../../shared/types' +import { makePaneKey } from '../../shared/stable-pane-id' +import { OrcaRuntimeService } from './orca-runtime' + +const REPO_ID = 'repo-1' +const WORKTREE_ID = `${REPO_ID}::/workspace` +const TAB_ID = 'tab-remote' +const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SOURCE_PTY_ID = 'pty-source' +const SPLIT_PTY_ID = 'pty-split' + +function sourceLayout(): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: SOURCE_LEAF_ID }, + activeLeafId: SOURCE_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SOURCE_LEAF_ID]: SOURCE_PTY_ID } + } +} + +function persistedSession(includeSource = true): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: includeSource + ? { + [WORKTREE_ID]: [ + { + id: TAB_ID, + ptyId: SOURCE_PTY_ID, + worktreeId: WORKTREE_ID, + title: 'Remote terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + : {}, + terminalLayoutsByTabId: includeSource ? { [TAB_ID]: sourceLayout() } : {} + } +} + +function remoteSnapshot(): RuntimeMobileSessionTabsSnapshot { + const layout = sourceLayout() + return { + worktree: WORKTREE_ID, + publicationEpoch: 'remote-viewer', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `${TAB_ID}::${SOURCE_LEAF_ID}`, + activeTabType: 'terminal', + tabGroups: [{ id: 'group-1', activeTabId: TAB_ID, tabOrder: [TAB_ID] }], + tabs: [ + { + type: 'terminal', + id: `${TAB_ID}::${SOURCE_LEAF_ID}`, + parentTabId: TAB_ID, + leafId: SOURCE_LEAF_ID, + ptyId: SOURCE_PTY_ID, + title: 'Remote terminal', + parentLayout: layout, + isActive: true + } + ] + } +} + +function createHarness(includeSource = true) { + let session = persistedSession(includeSource) + const repo = { + id: REPO_ID, + path: '/workspace', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + const store = { + getRepos: () => [repo], + getRepo: (id: string) => (id === REPO_ID ? repo : undefined), + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + persistPtyBinding: () => true + } + const spawn = vi.fn(async () => ({ id: SPLIT_PTY_ID })) + const kill = vi.fn(() => true) + const revealTerminalSession = vi + .fn() + .mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`)) + const runtime = new OrcaRuntimeService(store as never) + Object.assign(runtime, { + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/workspace', + connectionId: null, + repo, + folderWorkspace: null + })) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ revealTerminalSession } as never) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: includeSource ? [remoteSnapshot()] : [] + }) + runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: SOURCE_LEAF_ID + }) + const internals = runtime as unknown as { + getTerminalHandleForPaneKey: (paneKey: string) => string | null + issuePtyHandle: (pty: unknown) => string + mobileSessionTabsByWorktree: Map + ptysById: Map + } + const handle = + internals.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, SOURCE_LEAF_ID)) ?? + internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID)) + return { + runtime, + handle, + spawn, + kill, + revealTerminalSession, + getSession: () => session, + getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID) + } +} + +describe('remote runtime terminal split authority', () => { + it('splits a persisted tab without consulting an unmounted host renderer', async () => { + const harness = createHarness() + + const outcome = await harness.runtime + .splitTerminal(harness.handle, { direction: 'vertical' }) + .then((split) => ({ ok: true as const, split })) + .catch((error: unknown) => ({ ok: false as const, error })) + + expect.soft(outcome).toMatchObject({ + ok: true, + split: { tabId: TAB_ID, handle: expect.stringMatching(/^term_/) } + }) + expect.soft(harness.spawn).toHaveBeenCalledTimes(1) + expect.soft(harness.kill).not.toHaveBeenCalled() + expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled() + const persistedLayout = harness.getSession().terminalLayoutsByTabId[TAB_ID] + expect(persistedLayout).toMatchObject({ + root: { type: 'split', direction: 'vertical' }, + ptyIdsByLeafId: { + [SOURCE_LEAF_ID]: SOURCE_PTY_ID + } + }) + expect(Object.values(persistedLayout!.ptyIdsByLeafId!)).toContain(SPLIT_PTY_ID) + const siblingSurfaces = harness + .getSnapshot()! + .tabs.filter( + (tab): tab is Extract => + tab.type === 'terminal' && tab.parentTabId === TAB_ID + ) + expect(siblingSurfaces).toHaveLength(2) + expect(siblingSurfaces.every((tab) => tab.parentLayout?.root?.type === 'split')).toBe(true) + }) + + it('rejects an unowned split source before spawning a PTY', async () => { + const harness = createHarness(false) + + const outcome = await harness.runtime + .splitTerminal(harness.handle, { direction: 'vertical' }) + .then(() => 'resolved') + .catch((error: unknown) => (error instanceof Error ? error.message : String(error))) + + expect.soft(outcome).toBe('terminal_split_source_not_found') + expect.soft(harness.spawn).not.toHaveBeenCalled() + expect.soft(harness.kill).not.toHaveBeenCalled() + expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ec656c70144..ec4d930ad79 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -61,14 +61,16 @@ import type { MessagePriority, MessageRow, MessageType } from './orchestration/t import { AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS, appendNormalizedToTailBuffer, - appendRecentPtyPathCandidates, buildPreview, OrcaRuntimeService, - recentTerminalPathCandidatesIncludePath, - recentTerminalOutputIncludesPath, resolveWorktreeScanCacheTtlMs, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' +import { + appendRecentPtyPathCandidates, + recentTerminalPathCandidatesIncludePath, + recentTerminalOutputIncludesPath +} from './terminal-output-path-candidates' import { RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { HeadlessEmulator } from '../daemon/headless-emulator' import { @@ -232,6 +234,7 @@ const { invalidateAuthorizedRootsCacheMock, prepareLocalWorktreeRootForRepoMock, createHostedReviewMock, + createStackedHostedReviewMock, getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, getPRForBranchMock, @@ -339,6 +342,7 @@ const { invalidateAuthorizedRootsCacheMock: vi.fn(), prepareLocalWorktreeRootForRepoMock: vi.fn(), createHostedReviewMock: vi.fn(), + createStackedHostedReviewMock: vi.fn(), getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), getPRForBranchMock: vi.fn().mockResolvedValue(null), @@ -511,6 +515,10 @@ vi.mock('../source-control/hosted-review-creation', () => ({ getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock })) +vi.mock('../source-control/stacked-hosted-review-creation', () => ({ + createStackedHostedReview: createStackedHostedReviewMock +})) + vi.mock('../source-control/hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) @@ -730,6 +738,14 @@ function resetRuntimeTestMocks(): void { number: 1, url: 'https://example.com/pull/1' }) + createStackedHostedReviewMock.mockReset() + createStackedHostedReviewMock.mockResolvedValue({ + ok: true, + number: 2, + url: 'https://example.com/pull/2', + stackNumber: 10, + parentReview: { number: 1, url: 'https://example.com/pull/1' } + }) getHostedReviewCreationEligibilityMock.mockReset() getHostedReviewCreationEligibilityMock.mockResolvedValue({ provider: 'github', @@ -1509,6 +1525,7 @@ function makeRuntimeStoreWithWorkspaceSession( } } } + return true } ) } @@ -1815,7 +1832,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ disabledTuiAgents: ['claude'] }), expect.objectContaining({ shouldContinue: expect.any(Function), - shouldHydrateShellPath: process.platform !== 'win32' + shouldHydrateShellPath: true }) ) }) @@ -3308,6 +3325,37 @@ describe('OrcaRuntimeService', () => { expect(split.tabs.filter((tab) => tab.type === 'terminal')).toHaveLength(2) }) + it('resolves projected split authority from the parent layout PTY binding', () => { + const runtime = createRuntime() + const tabId = 'projected-parent-tab' + const leafId = 'projected-leaf' + const ptyId = 'projected-pty' + const internals = runtime as unknown as { + mobileSessionTabsByWorktree: Map + resolveTerminalSplitSourceAuthority: ( + worktreeId: string, + tabId: string, + leafId: string, + ptyId: string + ) => { persisted: boolean; rendererMounted: boolean } | null + } + internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, { + tabs: [ + { + type: 'terminal', + parentTabId: tabId, + leafId, + ptyId: null, + parentLayout: { ptyIdsByLeafId: { [leafId]: ptyId } } + } + ] + }) + + expect( + internals.resolveTerminalSplitSourceAuthority(TEST_WORKTREE_ID, tabId, leafId, ptyId) + ).toMatchObject({ persisted: false, rendererMounted: false }) + }) + it('keeps targeted terminal lists from adopting controller PTYs for other worktrees', async () => { vi.mocked(listWorktrees).mockResolvedValue([ ...MOCK_GIT_WORKTREES, @@ -6634,6 +6682,24 @@ describe('OrcaRuntimeService', () => { ) }) + it('forwards check-details cancellation without local Git overrides', async () => { + const runtime = new OrcaRuntimeService(store) + const signal = new AbortController().signal + + await runtime.getRepoPRCheckDetails('id:repo-1', { checkRunId: 9 }, signal) + + expect(getGitHubPRCheckDetailsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + { + checkRunId: 9, + prRepo: null + }, + null, + {}, + signal + ) + }) + it('routes runtime GitHub PR details and actions through the selected WSL project runtime', async () => { setPlatform('win32') const runtimeStore = { @@ -6657,6 +6723,7 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(runtimeStore as never) const localGitOptions = { wslDistro: 'Ubuntu' } const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme.test' } + const checkDetailsSignal = new AbortController().signal await runtime.getRepoPRForBranch('id:repo-1', 'feature/wsl', 42, 43) await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') @@ -6668,13 +6735,17 @@ describe('OrcaRuntimeService', () => { failedOnly: true, prRepo }) - await runtime.getRepoPRCheckDetails('id:repo-1', { - checkRunId: 9, - workflowRunId: 8, - checkName: 'lint', - url: 'https://example.com/check', - prRepo - }) + await runtime.getRepoPRCheckDetails( + 'id:repo-1', + { + checkRunId: 9, + workflowRunId: 8, + checkName: 'lint', + url: 'https://example.com/check', + prRepo + }, + checkDetailsSignal + ) await runtime.getRepoPRComments('id:repo-1', 42, prRepo, { noCache: true }) await runtime.getRepoPRFileContents('id:repo-1', { prNumber: 42, @@ -6776,7 +6847,8 @@ describe('OrcaRuntimeService', () => { prRepo }, null, - localGitOptions + localGitOptions, + checkDetailsSignal ) expect(getGitHubPRCommentsMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -6979,6 +7051,15 @@ describe('OrcaRuntimeService', () => { body: '', draft: false }) + await runtime.createStackedHostedReview({ + repoSelector: `id:${TEST_REPO_ID}`, + provider: 'github', + base: 'stack/parent', + head: 'feature/ssh', + title: 'Feature SSH', + body: '', + draft: false + }) expect(getHostedReviewCreationEligibilityMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -6996,6 +7077,16 @@ describe('OrcaRuntimeService', () => { }), 'ssh-1' ) + expect(createStackedHostedReviewMock).toHaveBeenCalledWith( + '/remote/repo', + expect.objectContaining({ + provider: 'github', + base: 'stack/parent', + head: 'feature/ssh' + }), + 'ssh-1', + {} + ) }) it('routes local WSL project hosted review flows through runtime git options', async () => { @@ -7058,6 +7149,15 @@ describe('OrcaRuntimeService', () => { body: '', draft: false }) + await runtime.createStackedHostedReview({ + repoSelector: `id:${TEST_REPO_ID}`, + provider: 'github', + base: 'stack/parent', + head: 'feature/wsl', + title: 'Feature WSL', + body: '', + draft: false + }) expect(getHostedReviewCreationEligibilityMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -7086,6 +7186,16 @@ describe('OrcaRuntimeService', () => { null, { localGitExecOptions: { wslDistro: 'Ubuntu' } } ) + expect(createStackedHostedReviewMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + expect.objectContaining({ + provider: 'github', + base: 'stack/parent', + head: 'feature/wsl' + }), + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) }) it('treats SSH worktree drift as unknown without local git probes', async () => { @@ -7152,6 +7262,7 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(runtimeStore as never) const wslGitOptions = { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + let driftCounts = '1\t2\n' const asyncGitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { if (args[0] === 'symbolic-ref') { return { stdout: 'refs/remotes/origin/main\n', stderr: '' } @@ -7168,19 +7279,14 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } + if (args[0] === 'rev-list') { + return { stdout: driftCounts, stderr: '' } + } + if (args[0] === 'log') { + return { stdout: 'base commit 2\nbase commit 1\n', stderr: '' } + } throw new Error(`unexpected git call: ${args.join(' ')}`) }) - const syncGitSpy = vi - .spyOn(gitRunner, 'gitExecFileSync') - .mockImplementation((args: string[]) => { - if (args[0] === 'rev-list') { - return '1\t2\n' - } - if (args[0] === 'log') { - return 'base commit 2\nbase commit 1\n' - } - throw new Error(`unexpected sync git call: ${args.join(' ')}`) - }) try { const result = await runtime.probeWorktreeDrift(`id:${TEST_WORKTREE_ID}`) @@ -7203,17 +7309,30 @@ describe('OrcaRuntimeService', () => { ...wslGitOptions, timeout: 60_000 }) - expect(syncGitSpy).toHaveBeenCalledWith( + expect(asyncGitSpy).toHaveBeenCalledWith( ['rev-list', '--left-right', '--count', 'HEAD...origin/main'], - { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu' } + { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu', timeout: 15_000 } ) - expect(syncGitSpy).toHaveBeenCalledWith( + expect(asyncGitSpy).toHaveBeenCalledWith( ['log', '--format=%s', '-n', '5', 'HEAD..origin/main'], - { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu' } + { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu', timeout: 15_000 } ) + + driftCounts = '3\t0\n' + asyncGitSpy.mockClear() + + await expect(runtime.probeWorktreeDrift(`id:${TEST_WORKTREE_ID}`)).resolves.toEqual({ + base: 'origin/main', + behind: 0, + recentSubjects: [] + }) + expect(asyncGitSpy).toHaveBeenCalledWith( + ['rev-list', '--left-right', '--count', 'HEAD...origin/main'], + { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu', timeout: 15_000 } + ) + expect(asyncGitSpy.mock.calls.some(([args]) => args[0] === 'log')).toBe(false) } finally { asyncGitSpy.mockRestore() - syncGitSpy.mockRestore() } }) @@ -7726,7 +7845,7 @@ describe('OrcaRuntimeService', () => { it('keeps project clone setup on the cloned host-qualified repo', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const repos: Record[] = [] getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) const runtimeStore = { @@ -7751,7 +7870,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => { + setImmediate(() => { mkdirSync(clonePath, { recursive: true }) execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) proc.emit('close', 0, null) @@ -7793,14 +7912,16 @@ describe('OrcaRuntimeService', () => { const existingFolder = join(destination, 'orca') mkdirSync(existingFolder, { recursive: true }) execFileSync('git', ['init'], { cwd: existingFolder, stdio: 'ignore' }) - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn').mockImplementation(() => { - // Why: unreachable while the guard holds; stubbed so a regression records the call - // instead of shelling out to a real network clone. - const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } - proc.stderr = new EventEmitter() - queueMicrotask(() => proc.emit('close', 1, null)) - return proc as never - }) + const spawnSpy = vi + .spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + .mockImplementation(() => { + // Why: unreachable while the guard holds; stubbed so a regression records the call + // instead of shelling out to a real network clone. + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + setImmediate(() => proc.emit('close', 1, null)) + return proc as never + }) const repos: Record[] = [] const runtimeStore = { ...store, @@ -7849,7 +7970,7 @@ describe('OrcaRuntimeService', () => { it('adopts public clone repos into host-qualified project setup', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const repos: Record[] = [] getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) const runtimeStore = { @@ -7874,7 +7995,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => { + setImmediate(() => { mkdirSync(clonePath, { recursive: true }) execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) proc.emit('close', 0, null) @@ -7919,7 +8040,7 @@ describe('OrcaRuntimeService', () => { it('keeps project clone repos split by runtime host on the same clone path', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const repos: Record[] = [ { id: 'repo-host-a', @@ -7954,7 +8075,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => { + setImmediate(() => { mkdirSync(clonePath, { recursive: true }) execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) proc.emit('close', 0, null) @@ -8091,7 +8212,7 @@ describe('OrcaRuntimeService', () => { }) it('defaults runtime cloneRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const added: Record[] = [] const colorStore = { ...store, @@ -8104,7 +8225,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => proc.emit('close', 0, null)) + setImmediate(() => proc.emit('close', 0, null)) return proc as never }) const runtime = new OrcaRuntimeService(colorStore as never) @@ -8127,7 +8248,7 @@ describe('OrcaRuntimeService', () => { }) it('drops a same-path negative submodule cache before runtime cloneRepo', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-reclone-')) const clonePath = join(destination, 'reclone') const added: Record[] = [] @@ -8140,7 +8261,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => { + setImmediate(() => { void mkdir(clonePath, { recursive: true }) .then(() => writeFile(join(clonePath, '.gitmodules'), '[submodule "lib"]\n\tpath = vendor/lib\n') @@ -8164,11 +8285,11 @@ describe('OrcaRuntimeService', () => { }) it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => proc.emit('close', 0, null)) + setImmediate(() => proc.emit('close', 0, null)) return proc as never }) const existing = { @@ -8262,7 +8383,7 @@ describe('OrcaRuntimeService', () => { }) it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const runtime = createRuntime() const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const destination = join(tempRoot, 'destination') @@ -8279,7 +8400,7 @@ describe('OrcaRuntimeService', () => { }) it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const runtime = createRuntime() const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const destination = join(tempRoot, 'destination') @@ -8296,10 +8417,10 @@ describe('OrcaRuntimeService', () => { }) it('removes an owned runtime clone target when git exits unsuccessfully', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - spawnSpy.mockReturnValue(proc as never) + spawnSpy.mockResolvedValue(proc as never) const runtime = createRuntime() const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const clonePath = join(destination, 'repo-badge-color') @@ -8323,10 +8444,10 @@ describe('OrcaRuntimeService', () => { }) it('preserves an existing runtime clone target when git exits unsuccessfully', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - spawnSpy.mockReturnValue(proc as never) + spawnSpy.mockResolvedValue(proc as never) const runtime = createRuntime() const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const clonePath = join(destination, 'repo-badge-color') @@ -8350,10 +8471,10 @@ describe('OrcaRuntimeService', () => { }) it('skips runtime clone failure cleanup when the owned target is replaced', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - spawnSpy.mockReturnValue(proc as never) + spawnSpy.mockResolvedValue(proc as never) const runtime = createRuntime() const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const clonePath = join(destination, 'repo-badge-color') @@ -8379,10 +8500,10 @@ describe('OrcaRuntimeService', () => { }) it('serializes concurrent runtime clones for the same target', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } firstProc.stderr = new EventEmitter() - spawnSpy.mockReturnValueOnce(firstProc as never) + spawnSpy.mockResolvedValueOnce(firstProc as never) const added: Record[] = [] const colorStore = { ...store, @@ -11032,6 +11153,19 @@ describe('OrcaRuntimeService', () => { expect(snapshot?.cwd).toBe('/home/me/repo/src') }) + it('projects frame-independent live state through main buffer snapshots', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-frame-state', TEST_WORKTREE_ID) + runtime.onPtyData('pty-frame-state', '\x1b[?1049h\x1b[?1004h\x1b[?25lSTATIC-FRAME', 123) + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-frame-state') + + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot?.frameRestoreAnsi).not.toContain('STATIC-FRAME') + expect(snapshot?.data).toContain('STATIC-FRAME') + }) + it('keeps Windows SSH OSC7 cwd as a drive path when the desktop runtime is POSIX', () => { setPlatform('darwin') const runtime = new OrcaRuntimeService(store) @@ -11298,6 +11432,57 @@ describe('OrcaRuntimeService', () => { }) }) + it('keeps blocked prompt text authoritative over an OpenCode marker', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'opencode' + }) + const leafId = '11111111-1111-4111-8111-111111111111' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'OC | Native session', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: 'OC | Native session' + } + ] + }) + runtime.onPtyData( + 'pty-1', + 'Permission required\nThis command requires permission\nAllow once\nAllow always\nReject\n', + 123 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + isRunningAgent: true, + status: 'permission' + }) + await expect( + runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + satisfied: false, + blockedReason: 'codex-interactive-prompt' + }) + }) + it('reports permission from blocked wait text over title-only working state', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -14655,6 +14840,205 @@ describe('OrcaRuntimeService', () => { } }) + it('keeps a persisted split when mounted renderer adoption rejects', async () => { + const tabId = 'persisted-mounted-tab' + const ptyId = 'persisted-mounted-pty' + const splitPtyId = 'persisted-mounted-split-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: tabId, + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + ) + const revealTerminalSession = vi.fn().mockRejectedValue(new Error('renderer rejected')) + const kill = vi.fn(() => true) + let resolveSpawn!: (result: { id: string }) => void + const spawn = vi.fn( + (_args: unknown) => + new Promise<{ id: string }>((resolve) => { + resolveSpawn = resolve + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ revealTerminalSession } as never) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { + tabId, + leafId: HEADLESS_LEAF_ID, + incarnationId: 'live-source-incarnation' + }) + const internals = runtime as unknown as { + issuePtyHandle: (pty: unknown) => string + ptysById: Map + } + const handle = internals.issuePtyHandle(internals.ptysById.get(ptyId)) + const split = runtime.splitTerminal(handle, { direction: 'horizontal' }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + const splitSpawn = spawn.mock.calls[0]?.[0] as + | { expectedSourceBinding?: { incarnationId?: string } } + | undefined + expect(splitSpawn?.expectedSourceBinding).not.toHaveProperty('incarnationId') + + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: { type: 'leaf', leafId: HEADLESS_LEAF_ID } + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId + } + ] + }) + resolveSpawn({ id: splitPtyId }) + + await expect(split).resolves.toMatchObject({ + tabId, + handle: expect.stringMatching(/^term_/) + }) + + expect(revealTerminalSession).toHaveBeenCalledOnce() + expect(kill).not.toHaveBeenCalled() + expect(getSession().terminalLayoutsByTabId[tabId]?.root).toMatchObject({ + type: 'split', + direction: 'horizontal' + }) + }) + + it('rejects a persisted split closed during spawn without recreating its tab', async () => { + const tabId = 'closing-persisted-tab' + const ptyId = 'closing-persisted-pty' + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: tabId, + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Closing terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + ) + let resolveSpawn!: (result: { id: string }) => void + const spawn = vi.fn( + (_args: unknown) => + new Promise<{ id: string }>((resolve) => { + resolveSpawn = resolve + }) + ) + const kill = vi.fn(() => false) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { + tabId, + leafId: HEADLESS_LEAF_ID + }) + const internals = runtime as unknown as { + issuePtyHandle: (pty: unknown) => string + ptysById: Map + } + const handle = internals.issuePtyHandle(internals.ptysById.get(ptyId)) + const split = runtime.splitTerminal(handle, { direction: 'vertical' }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + + setSession(getDefaultWorkspaceSession()) + runtimeStore.persistPtyBinding.mockReturnValue(false) + resolveSpawn({ id: 'rejected-split-pty' }) + + await expect(split).rejects.toThrow('terminal_split_source_not_found') + expect(spawn.mock.calls[0]?.[0]).toMatchObject({ + persistHostSessionBinding: true, + expectedSourceBinding: { + worktreeId: TEST_WORKTREE_ID, + tabId, + leafId: HEADLESS_LEAF_ID, + ptyId + } + }) + expect(kill).toHaveBeenCalledWith('rejected-split-pty') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toBeUndefined() + expect(getSession().terminalLayoutsByTabId[tabId]).toBeUndefined() + }) + + it('rejects a projected split retired during spawn before publishing the new pane', async () => { + let resolveSplitSpawn!: (result: { id: string }) => void + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'projected-source-pty' }) + .mockImplementationOnce( + () => + new Promise<{ id: string }>((resolve) => { + resolveSplitSpawn = resolve + }) + ) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const split = runtime.splitTerminal(handle, { direction: 'vertical' }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + + runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID) + resolveSplitSpawn({ id: 'retired-projected-split-pty' }) + + await expect(split).rejects.toThrow('terminal_split_source_not_found') + expect(kill).toHaveBeenCalledWith('retired-projected-split-pty') + expect(runtime['mobileSessionTabsByWorktree'].has(TEST_WORKTREE_ID)).toBe(false) + }) + it('splits folder workspace pty-backed terminal sessions with folder cwd and env', async () => { const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-split-')) const spawn = vi @@ -14733,6 +15117,80 @@ describe('OrcaRuntimeService', () => { }) }) + it('atomically admits persisted SSH splits in the SSH host partition', async () => { + const tabId = 'ssh-split-tab' + const sourcePtyId = 'ssh:ssh-1@@source-pty' + const splitPtyId = 'ssh:ssh-1@@split-pty' + const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: tabId, + ptyId: sourcePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'SSH terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: sourcePtyId }) + } + }), + 'ssh:ssh-1' + ) + const spawn = vi.fn().mockResolvedValue({ id: splitPtyId }) + const runtime = new OrcaRuntimeService({ + ...runtimeStore, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.registerPty(sourcePtyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId, + leafId: HEADLESS_LEAF_ID + }) + const internals = runtime as unknown as { + issuePtyHandle: (pty: unknown) => string + ptysById: Map + } + const handle = internals.issuePtyHandle(internals.ptysById.get(sourcePtyId)) + + await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).resolves.toMatchObject({ + tabId, + handle: expect.stringMatching(/^term_/) + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: 'ssh-1', + worktreeId: TEST_WORKTREE_ID, + persistHostSessionBinding: true, + expectedSourceBinding: expect.objectContaining({ + worktreeId: TEST_WORKTREE_ID, + tabId, + leafId: HEADLESS_LEAF_ID, + ptyId: sourcePtyId + }) + }) + ) + expect(getSession().terminalLayoutsByTabId[tabId]?.root).toMatchObject({ + type: 'split', + direction: 'vertical' + }) + }) + it('returns an actionable discoverability warning when default adoption fails after spawn', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const revealTerminalSession = vi.fn().mockRejectedValue(new Error('Renderer timed out')) @@ -15207,6 +15665,28 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves live-leaf tui-idle from an OpenCode native title', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'remote:pty-1', { + tabTitle: 'repo terminal', + paneTitle: 'ssh build-host | OC | Native session' + }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect( + runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle: terminal.handle, + condition: 'tui-idle', + status: 'running' + }) + }) + it('does not treat a Codex launch title as tui-idle readiness', async () => { vi.useFakeTimers() try { @@ -19068,6 +19548,71 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('terminal_orphan_competing_owner') }) + it('preserves concurrent workspace-session changes when async orphan persistence fails', async () => { + const session = { + ...getDefaultWorkspaceSession(), + activeRepoId: TEST_REPO_ID, + activeWorktreeId: TEST_WORKTREE_ID, + tabsByWorktree: { [TEST_WORKTREE_ID]: [] } + } + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) + const durableWrite = deferred() + const durableWriteStarted = deferred() + const runtime = new OrcaRuntimeService({ + ...runtimeStore, + flushPendingOrThrowAsync: vi.fn(() => { + durableWriteStarted.resolve() + return durableWrite.promise + }) + } as never) + runtime.setPtyController({ + write: vi.fn(() => true), + kill: vi.fn(() => true), + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-async-rollback', + incarnationId: 'inc-async-rollback', + terminalHandle: 'term_async_rollback', + title: 'Async rollback', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + wslDistro: null + } + ] + }) + const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + + const adoption = runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: before.topologyRevisions?.[TEST_WORKTREE_ID] ?? 0, + claims: [ + { + terminal: 'term_async_rollback', + ptyId: 'pty-async-rollback', + incarnationId: 'inc-async-rollback', + tabId: 'tab-async-rollback', + leafId: HEADLESS_LEAF_ID + } + ] + }) + await durableWriteStarted.promise + setSession({ + ...getSession(), + activeTabIdByWorktree: { + ...getSession().activeTabIdByWorktree, + 'concurrent-worktree': 'concurrent-tab' + } + }) + durableWrite.reject(new Error('disk unavailable')) + + await expect(adoption).rejects.toThrow('disk unavailable') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['tab-async-rollback']).toBeUndefined() + expect(getSession().activeTabIdByWorktree?.['concurrent-worktree']).toBe('concurrent-tab') + expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID] ?? 0).toBe(0) + }) + function publishLegacyWorkerReveal( runtime: OrcaRuntimeService, identity: { worktreeId: string; tabId: string; leafId: string; ptyId: string }, @@ -19834,17 +20379,23 @@ describe('OrcaRuntimeService', () => { } } } - const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) + const durableWrite = deferred() + const durableWriteStarted = deferred() let flushCount = 0 - const flushOrThrow = vi.fn(() => { + const flushPendingOrThrowAsync = vi.fn(() => { flushCount += 1 - if (flushCount === 1 || flushCount === 3) { - throw new Error('disk unavailable') + if (flushCount === 1) { + return Promise.resolve() } + durableWriteStarted.resolve() + return durableWrite.promise }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never, undefined, { - canRecoverPersistentLocalPtys: () => true - }) + const runtime = new OrcaRuntimeService( + { ...runtimeStore, flushPendingOrThrowAsync } as never, + undefined, + { canRecoverPersistentLocalPtys: () => true } + ) runtime.setOrchestrationDb({ listLegacyWorkerTerminalRecoveryRows: () => [ { @@ -19893,18 +20444,35 @@ describe('OrcaRuntimeService', () => { } as never) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect( - runtime.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) - ).resolves.toMatchObject({ + const recovery = runtime.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) + await durableWriteStarted.promise + const concurrentPaneKey = `concurrent:${HEADLESS_SECOND_LEAF_ID}` + setSession({ + ...getSession(), + sleepingAgentSessionsByPaneKey: { + ...getSession().sleepingAgentSessionsByPaneKey, + [concurrentPaneKey]: { + ...session.sleepingAgentSessionsByPaneKey![workerPaneKey]!, + paneKey: concurrentPaneKey, + tabId: 'concurrent-tab' + } + } + }) + durableWrite.reject(new Error('disk unavailable')) + + await expect(recovery).resolves.toMatchObject({ adoptedDispatchIds: [], exitedDispatchIds: [], deferredDispatchIds: ['dispatch-persistence-failure'] }) - expect(flushOrThrow).toHaveBeenCalledTimes(3) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2) expect(revealTerminalSession).toHaveBeenCalledOnce() expect( getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey?.[concurrentPaneKey]?.tabId).toBe( + 'concurrent-tab' + ) expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled() warn.mockRestore() }) @@ -19931,8 +20499,17 @@ describe('OrcaRuntimeService', () => { } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const durableWrite = deferred() + const durableWriteStarted = deferred() + const flushPendingOrThrowAsync = vi.fn(() => { + durableWriteStarted.resolve() + return durableWrite.promise + }) + const flushOrThrow = vi.fn(() => { + throw new Error('synchronous persistence must not run') + }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + { ...runtimeStore, flushOrThrow, flushPendingOrThrowAsync } as never, undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -19964,12 +20541,24 @@ describe('OrcaRuntimeService', () => { const resolveLegacyWorkerTerminalRecovery = vi.fn() runtime.setNotifier({ resolveLegacyWorkerTerminalRecovery } as never) - await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({ + const recovery = runtime.reconcileLegacyWorkerTerminals() + await durableWriteStarted.promise + + expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled() + expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched') + durableWrite.resolve() + + await expect(recovery).resolves.toMatchObject({ adoptedDispatchIds: [], exitedDispatchIds: [started.dispatch.id], deferredDispatchIds: [] }) + expect(flushPendingOrThrowAsync).toHaveBeenCalledOnce() + expect(flushPendingOrThrowAsync).toHaveBeenCalledWith({ + drainToStableGeneration: false + }) + expect(flushOrThrow).not.toHaveBeenCalled() expect(db.getDispatchContextById(started.dispatch.id)).toMatchObject({ status: 'failed', failure_count: 1 @@ -19988,7 +20577,7 @@ describe('OrcaRuntimeService', () => { } }) - it('retries missing-worker recovery when clearing its persisted resume fence fails', async () => { + it('waits for durability before retry settles a resolution already present in memory', async () => { const workerPaneKey = `legacy-missing-retry:${HEADLESS_LEAF_ID}` const incarnationId = '34343434-3434-4434-8434-343434343434' const session: WorkspaceSessionState = { @@ -20009,17 +20598,26 @@ describe('OrcaRuntimeService', () => { } } } - const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const flushOrThrow = vi - .fn() - .mockImplementationOnce(() => undefined) - .mockImplementationOnce(() => { - throw new Error('disk unavailable') - }) - .mockImplementation(() => undefined) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never, undefined, { - canRecoverPersistentLocalPtys: () => true + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) + const firstDurableWrite = deferred() + const firstDurableWriteStarted = deferred() + const retryDurableWrite = deferred() + const retryDurableWriteStarted = deferred() + let flushCount = 0 + const flushPendingOrThrowAsync = vi.fn(() => { + flushCount += 1 + if (flushCount === 1) { + firstDurableWriteStarted.resolve() + return firstDurableWrite.promise + } + retryDurableWriteStarted.resolve() + return retryDurableWrite.promise }) + const runtime = new OrcaRuntimeService( + { ...runtimeStore, flushPendingOrThrowAsync } as never, + undefined, + { canRecoverPersistentLocalPtys: () => true } + ) const db = new OrchestrationDb(':memory:') try { const task = db.createTask({ spec: 'retry missing worker recovery' }) @@ -20049,20 +20647,30 @@ describe('OrcaRuntimeService', () => { runtime.setNotifier({ resolveLegacyWorkerTerminalRecovery } as never) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({ + const firstRecovery = runtime.reconcileLegacyWorkerTerminals() + await firstDurableWriteStarted.promise + setSession({ ...getSession(), sleepingAgentSessionsByPaneKey: undefined }) + firstDurableWrite.reject(new Error('disk unavailable')) + + await expect(firstRecovery).resolves.toMatchObject({ exitedDispatchIds: [], deferredDispatchIds: [started.dispatch.id] }) expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched') expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('ready') - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey).toBeUndefined() - await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({ + const retry = runtime.reconcileLegacyWorkerTerminals() + await retryDurableWriteStarted.promise + expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched') + expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('ready') + retryDurableWrite.resolve() + + await expect(retry).resolves.toMatchObject({ exitedDispatchIds: [started.dispatch.id], deferredDispatchIds: [] }) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2) expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('abandoned') expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() expect(resolveLegacyWorkerTerminalRecovery).toHaveBeenCalledWith( @@ -22264,6 +22872,65 @@ describe('OrcaRuntimeService', () => { }) }) + it('does not authorize an OpenCode marker left on a shell pane', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'OC | zsh' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + isRunningAgent: false, + status: null + }) + }) + + it('authorizes a hookless OpenCode marker with an OpenCode foreground process', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'opencode' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'OC | Native session' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(true) + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + isRunningAgent: true, + status: 'idle' + }) + }) + + it('does not authorize an OpenCode marker left on a runtime PTY shell', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'bash', + title: 'OC | zsh' + }) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: false, + status: null + }) + }) + // Why: a leaf with no PTY is the same no-evidence case as an unreadable foreground — // nothing was even asked, so the bare title is all that is left. The corroborating // foreground here is deliberately unreachable: no ptyId means no read. @@ -24538,7 +25205,7 @@ describe('OrcaRuntimeService', () => { unsubscribe() }) - it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => { + it('keeps decorative Pi frames queued behind the foreground owner probe', async () => { const foregroundProcess = deferred() const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) const getForegroundProcess = vi.fn(() => foregroundProcess.promise) @@ -24557,9 +25224,9 @@ describe('OrcaRuntimeService', () => { const events: RuntimeMobileSessionTabsResult[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) - runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + runtime.onPtyData('pty-typed-omp', '\x1b]0;⠋ Pi\x07', 123) await new Promise((resolve) => setImmediate(resolve)) - runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + runtime.onPtyData('pty-typed-omp', '\x1b]0;⠙ Pi\x07', 124) await new Promise((resolve) => setImmediate(resolve)) expect(getForegroundProcess).toHaveBeenCalledTimes(1) @@ -24574,18 +25241,22 @@ describe('OrcaRuntimeService', () => { tabs: [ expect.objectContaining({ type: 'terminal', - title: 'OMP ready', + title: '⠋ OMP', agentStatus: expect.objectContaining({ - state: 'done', + state: 'working', agentType: 'omp', terminalHandle: terminal.handle, - terminalTitle: 'OMP ready' + terminalTitle: '⠋ OMP' }) }) ] }) ]) + runtime.onPtyData('pty-typed-omp', '\x1b]0;⠹ Pi\x07', 125) + await new Promise((resolve) => setImmediate(resolve)) + expect(events).toHaveLength(1) + unsubscribe() }) @@ -39309,6 +39980,35 @@ describe('OrcaRuntimeService', () => { expect(reposChanged).not.toHaveBeenCalled() }) + it('persists changed worktree order once and emits targeted invalidations', () => { + const firstId = `${TEST_REPO_ID}::/tmp/first` + const secondId = `${TEST_REPO_ID}::/tmp/second` + const metaById: Record> = { + [firstId]: { sortOrder: 200 }, + [secondId]: { sortOrder: 100 } + } + const setWorktreeMeta = vi.fn((id: string, updates: Partial) => { + metaById[id] = { ...metaById[id], ...updates } + }) + const runtime = new OrcaRuntimeService({ + ...store, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta + } as never) + const events: { type: string; repoId?: string }[] = [] + const unsubscribe = runtime.onClientEvent((event) => events.push(event)) + + expect(runtime.persistManagedWorktreeSortOrder([firstId, secondId])).toEqual({ updated: 0 }) + expect(setWorktreeMeta).not.toHaveBeenCalled() + expect(events).toEqual([]) + + expect(runtime.persistManagedWorktreeSortOrder([secondId, firstId])).toEqual({ updated: 2 }) + unsubscribe() + + expect(setWorktreeMeta).toHaveBeenCalledTimes(2) + expect(events).toEqual([{ type: 'worktreesChanged', repoId: TEST_REPO_ID }]) + }) + it('worktree scan cache: folder metadata invalidation preserves raw scans', async () => { vi.mocked(listWorktrees).mockClear() const runtime = createRuntime() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 85888b94615..7cad4ad94bc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6,10 +6,13 @@ import { isClaudeManagementTitle, isCursorAgentTitle, isCursorNativeAgentTitle, + isOpenCodeNativeTitle, + isQuarterCircleSpinnerOnlyAgentTitle, isShellProcess, normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' +import { planWorktreeSortOrderUpdates } from '../../shared/worktree-sort-order-update' import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate' import { sortDirEntries } from '../../shared/file-name-sort' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' @@ -27,10 +30,10 @@ import type { } from './remote-terminal-source-range-consumer' import { createTerminalTitleTracker, - stripBrailleSpinnerGlyphs, type TerminalTitleFactMeta, type TerminalTitleTracker } from '../../shared/terminal-output-side-effects' +import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature' import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' import type { TerminalSideEffectBatch, @@ -38,6 +41,7 @@ import type { } from '../../shared/terminal-side-effect-facts' import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' import { AGENT_STATUS_STALE_AFTER_MS, isFreshNonDoneAgentStatus, @@ -93,7 +97,12 @@ import { AGENT_PROMPT_SUBMIT_DELAY_MS, buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' -import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner' +import { + awaitWindowsHostGitEnvironmentReady, + gitExecFileAsync, + gitSpawnAfterWindowsEnvironmentReady, + nonInteractiveGitEnv +} from '../git/runner' import { runWithGitReadCacheInvalidation } from '../git/status' import { cleanupClaimedCloneTarget, @@ -111,6 +120,12 @@ import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' import { reconcileRequestedWorkerTerminalReleases } from './orchestration/worker-terminal-release-reconciliation' +import { + classifyWorkerTerminalProcessIncarnation, + parseWorkerTerminalHostScope, + type WorkerTerminalHostScope +} from './orchestration/worker-terminal-process-liveness' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' import { OrchestrationError } from './orchestration/orchestration-error' import { planLegacyWorkerTerminalRecovery, @@ -147,6 +162,7 @@ import type { OrchestrationEnvironmentTransport, OrchestrationWorkerServer } from './orchestration/environment-transport' +import { clearFederationAckCheckpoints } from './orchestration/federation-ack-checkpoints' import { syncFederatedDispatch } from './orchestration/federation-sync' import { formatMessagePointer } from './orchestration/formatter' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' @@ -228,6 +244,7 @@ import type { GitHubPullRequestStateUpdate, GitHubPRFile, GitHubPRReviewCommentInput, + GitHubReactionContent, GitLabIssueUpdate, GitLabMRInlineCommentInput, GitLabProjectRef, @@ -247,6 +264,7 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' import { getRegisteredSshState } from '../ipc/ssh' import type { AgentProviderSessionMetadata, @@ -490,16 +508,11 @@ import { listAiVaultSessions } from '../ai-vault/cached-session-list' import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' -import { - readAiVaultSessionIdentity, - resolveAiVaultSessionLiveness -} from '../ai-vault/session-liveness' -import type { AiVaultAgent, AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import type { AiVaultSessionTitleRequest, AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' -import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult @@ -522,7 +535,8 @@ import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-termina import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' import { buildHeadlessTerminalSplitLayout, - countTerminalLayoutLeaves + countTerminalLayoutLeaves, + terminalLayoutContainsLeaf } from './headless-terminal-split-layout' import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { @@ -596,6 +610,7 @@ import { getPRCheckDetails, rerunPRChecks, getPRComments, + setPRCommentReaction, getIssue, resolveReviewThread, setPRFileViewed, @@ -670,16 +685,18 @@ import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-im import type { CreateHostedReviewInput, CreateHostedReviewResult, + CreateStackedHostedReviewInput, + CreateStackedHostedReviewResult, HostedReviewCreationEligibility, HostedReviewCreationEligibilityArgs, HostedReviewInfo } from '../../shared/hosted-review' import { getHostedReviewForBranch as getHostedReviewForBranchFromRepo } from '../source-control/hosted-review' -import type { ForgeProviderId } from '../source-control/forge-provider' import { createHostedReview as createHostedReviewFromRepo, getHostedReviewCreationEligibility as getHostedReviewCreationEligibilityFromRepo } from '../source-control/hosted-review-creation' +import { createStackedHostedReview as createStackedHostedReviewFromRepo } from '../source-control/stacked-hosted-review-creation' import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions, @@ -914,7 +931,7 @@ import { WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS } from '../worktree-create-candidates' import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' -import type { Store } from '../persistence' +import type { PtyBindingSourceExpectation, Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { AgentDetector } from '../stats/agent-detector' import { @@ -985,7 +1002,29 @@ import { createMobileSessionTabsNotifyCoalescer, type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' +import { + createMobileSessionTabsAgentStatusHeartbeat, + type MobileSessionTabsAgentStatusHeartbeat +} from './mobile-session-tabs-agent-status-heartbeat' import { TerminalFocusNavigationCoalescer } from './terminal-focus-navigation-coalescer' +import { + appendRecentPtyPathCandidates, + recentTerminalOutputIncludesPath, + recentTerminalPathCandidatesIncludePath +} from './terminal-output-path-candidates' +import { + getSelectedReviewBranch, + getSelectedReviewLookupHints, + isAllowedPushTargetRemoteConflict, + isMatchingSelectedGitHubPr, + type SelectedReviewBranchInput +} from './selected-review-branch' +import { + getRuntimeFolderWorkspaceInstanceId, + getRuntimeFolderWorkspaceRootId, + isRuntimeFolderWorkspaceIdForRepo, + mergeRuntimeFolderWorkspace +} from './runtime-folder-workspace' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -998,9 +1037,8 @@ import { getSshGitProviderGeneration, requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' +import { detectGitHubAvatarIcon, detectRepoIconAndUpstream } from '../repo-icon-autodetect' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' -import { githubAvatarIcon } from '../../shared/repo-icon' import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService, @@ -1103,6 +1141,7 @@ type RuntimeStore = { getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] setWorkspaceSession?: Store['setWorkspaceSession'] flushOrThrow?: Store['flushOrThrow'] + flushPendingOrThrowAsync?: Store['flushPendingOrThrowAsync'] persistPtyBinding?: Store['persistPtyBinding'] getSshRemotePtyLeases?: Store['getSshRemotePtyLeases'] getUI?: Store['getUI'] @@ -1279,6 +1318,8 @@ type RuntimePtyWorktreeRecord = { paneKey: string | null launchConfig: SleepingAgentLaunchConfig | null launchToken: string | null + // Why: provider PTY IDs can be reused; launch identity belongs only to the process that received the token. + launchIncarnationId: PtyIncarnationId | null launchAgent: TuiAgent | null foregroundAgent: TuiAgent | null connected: boolean @@ -1287,6 +1328,9 @@ type RuntimePtyWorktreeRecord = { lastAgentStatus: AgentStatus | null /** False until a live OSC frame sets the status; restore seeds never set it. */ lastAgentStatusObservedLive: boolean + lastAgentStatusStartedAtEpochMs: number | null + // A later semantic title interval cannot inherit rich fields from an earlier task. + lastAgentStatusRichInvalidatedAtEpochMs: number | null lastOscTitle: string | null lastOscTitleAt: number | null // Why a second stamp: `lastOscTitleAt` is a title-observation sequence number, @@ -1496,12 +1540,8 @@ type RuntimePtyTitleTrackerEntry = { tracker: TerminalTitleTracker // Why: onPtyData batches the mobile session-tab touch to once per chunk; // the stale-working-title timer fires between chunks and must touch - // immediately. These flags route the tracker callback to the right mode. + // immediately. This flag routes the tracker callback to the right mode. applyingChunk: boolean - // Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the - // synthetic path gates mobile snapshot fan-out on a non-decorative title - // change (spinner glyph + status comparison key kept below). - applyingSyntheticFrame: boolean lastMobileTitleGateKey: string | null chunkTouchedSessionTabs: boolean // Why: facts observed while applying a chunk are batched into one @@ -1576,6 +1616,8 @@ type ProviderBufferAcquisition = { type RuntimeTerminalBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -1586,6 +1628,9 @@ type RuntimeTerminalBufferSnapshot = { alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string + /** Effective kitty flags proven at this snapshot's own `seq`. Absent means + * the winning source could not prove them. */ + kittyKeyboardFlags?: number } type HeadlessSeedMetadata = { @@ -1646,6 +1691,7 @@ type RuntimePtyController = { sessionId?: string isNewSession?: boolean persistHostSessionBinding?: boolean + expectedSourceBinding?: PtyBindingSourceExpectation terminalColorQueryReplies?: { foreground?: string; background?: string } agentSessionEnsure?: { claim: AgentSessionExecutionClaim @@ -1698,7 +1744,14 @@ type RuntimePtyController = { serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - ): Promise<{ data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null> + ): Promise<{ + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } | null> /** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */ serializeProviderBuffer?( ptyId: string, @@ -1775,9 +1828,6 @@ const BRACKETED_PASTE_QUIET_MS = 1500 const DRAFT_PASTE_READY_TIMEOUT_MS = 8000 const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 -const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 -const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 -const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024 const SSH_PANE_RECOVERY_GRACE_MS = 30_000 // Why: long enough that a keystroke burst to a proven-dead leaf probes once, // short enough that a recreated session id regains writability quickly even if @@ -1955,10 +2005,7 @@ export type OrchestrationCompatibilityTerminalAuthority = { processIncarnation: string | null paneKey: string | null launchTokenHash: string | null - hostScope: - | { kind: 'local'; hostId: 'local' } - | { kind: 'wsl'; hostId: 'local'; distro: string } - | { kind: 'ssh'; targetId: string } + hostScope: WorkerTerminalHostScope } export type LegacyWorkerTerminalRecoveryResult = { @@ -1968,6 +2015,11 @@ export type LegacyWorkerTerminalRecoveryResult = { deferredDispatchIds: string[] } +type LegacyWorkerTerminalRecoveryResolution = { + candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] + resolution: 'adopted' | 'exited' +} + export type OrchestrationCompatibilityCallerAuthority = Readonly<{ hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'] paneKey: string @@ -2098,6 +2150,8 @@ type RuntimeWorktreeRemovalInFlight = { } type PreservedBranchCleanupTarget = { + worktreeId: string + hostId?: ExecutionHostId branchName: string head: string pushTarget?: GitPushTarget @@ -2114,10 +2168,6 @@ function getRuntimeWorktreeRemovalOptionsKey( return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}:${ptyKey}` } -function getRuntimeFolderWorkspaceRootId(repo: Repo): string { - return `${repo.id}::${repo.path}` -} - // Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime // host can adopt a legacy (unstamped) repo. But an unstamped repo with a connectionId is an SSH repo // (resolves to ssh:), so it must not be adopted/matched by a runtime host at the same path. @@ -2147,71 +2197,11 @@ function assertProjectHostSetupHostIsSupported(hostId: ExecutionHostId | null | ) } -function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { - return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` -} - function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() } -function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { - const rootId = getRuntimeFolderWorkspaceRootId(repo) - return ( - worktreeId === rootId || - worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) - ) -} - -function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { - return { - id: worktreeId, - ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), - repoId: repo.id, - ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), - ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), - ...(meta.projectHostSetupId !== undefined - ? { projectHostSetupId: meta.projectHostSetupId } - : {}), - path: repo.path, - head: '', - branch: '', - isBare: false, - isMainWorktree: worktreeId === getRuntimeFolderWorkspaceRootId(repo), - displayName: meta.displayName || repo.displayName, - comment: meta.comment || '', - linkedIssue: meta.linkedIssue ?? null, - linkedPR: meta.linkedPR ?? null, - linkedLinearIssue: meta.linkedLinearIssue ?? null, - linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, - linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, - linkedGitLabMR: meta.linkedGitLabMR ?? null, - linkedGitLabIssue: meta.linkedGitLabIssue ?? null, - linkedBitbucketPR: meta.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: meta.linkedGiteaPR ?? null, - linkedWorkItem: meta.linkedWorkItem ?? null, - linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, - isArchived: meta.isArchived ?? false, - isUnread: meta.isUnread ?? false, - isPinned: meta.isPinned ?? false, - sortOrder: meta.sortOrder ?? 0, - ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), - lastActivityAt: meta.lastActivityAt ?? 0, - ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), - ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), - ...(meta.automationProvenance !== undefined - ? { automationProvenance: meta.automationProvenance } - : {}), - ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), - ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), - workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, - diffComments: meta.diffComments, - mobileDiffReview: meta.mobileDiffReview - } -} - function listRuntimeFolderWorkspaces( store: Pick, repo: Repo @@ -2372,94 +2362,6 @@ function getLocalGitHubPrForBranch( : getPRForBranch(repoPath, branchName) } -type SelectedReviewBranchInput = { - branchNameOverride?: string - linkedPR?: number | null - linkedGitLabMR?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null - pushTarget?: GitPushTarget -} - -type SelectedReviewBranch = { - provider: ForgeProviderId - number: number -} - -function getSelectedReviewBranch(args: SelectedReviewBranchInput): SelectedReviewBranch | null { - if (typeof args.linkedPR === 'number') { - return { provider: 'github', number: args.linkedPR } - } - if (typeof args.linkedGitLabMR === 'number') { - return { provider: 'gitlab', number: args.linkedGitLabMR } - } - if (typeof args.linkedBitbucketPR === 'number') { - return { provider: 'bitbucket', number: args.linkedBitbucketPR } - } - if (typeof args.linkedAzureDevOpsPR === 'number') { - return { provider: 'azure-devops', number: args.linkedAzureDevOpsPR } - } - if (typeof args.linkedGiteaPR === 'number') { - return { provider: 'gitea', number: args.linkedGiteaPR } - } - return null -} - -function isSelectedGitHubPrBranchOverride( - args: SelectedReviewBranchInput, - branchName: string -): boolean { - return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName -} - -function isSelectedReviewBranchOverride( - args: SelectedReviewBranchInput, - branchName: string -): boolean { - return getSelectedReviewBranch(args) !== null && args.branchNameOverride === branchName -} - -function isMatchingSelectedGitHubPr( - existingPR: Awaited>, - args: SelectedReviewBranchInput, - branchName: string -): boolean { - return Boolean( - existingPR && - isSelectedGitHubPrBranchOverride(args, branchName) && - existingPR.number === args.linkedPR - ) -} - -function isAllowedPushTargetRemoteConflict( - conflictKind: 'local' | 'remote' | null, - branchName: string, - args: SelectedReviewBranchInput -): boolean { - return ( - conflictKind === 'remote' && - isSelectedReviewBranchOverride(args, branchName) && - args.pushTarget?.branchName === branchName - ) -} - -function getSelectedReviewLookupHints(args: SelectedReviewBranchInput): { - linkedGitHubPR?: number | null - linkedGitLabMR?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null -} { - return { - linkedGitHubPR: args.linkedPR ?? null, - linkedGitLabMR: args.linkedGitLabMR ?? null, - linkedBitbucketPR: args.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null - } -} - async function getSelectedHostedReviewForBranch( repo: Pick, branchName: string, @@ -2800,7 +2702,10 @@ export class OrcaRuntimeService { private managedHookReconciliationTail: Promise = Promise.resolve() private readonly orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport | null private readonly orchestrationFederationTimers = new Map>() - private readonly orchestrationFederationSyncs = new Map>() + private readonly orchestrationFederationSyncs = new Map< + string, + { db: OrchestrationDb; promise: Promise } + >() private readonly orchestrationFederationWarnings = new Set() private rendererGraphEpoch = 0 private graphStatus: RuntimeGraphStatus = 'unavailable' @@ -2888,6 +2793,11 @@ export class OrcaRuntimeService { createMobileSessionTabsNotifyCoalescer((worktreeId) => this.notifyMobileSessionTabsChangedNow(worktreeId) ) + private readonly mobileSessionTabsAgentStatusHeartbeat: MobileSessionTabsAgentStatusHeartbeat = + createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => this.getMobileSessionWorktreeIdsForPty(ptyId), + (worktreeId) => this.touchMobileSessionTabsForWorktree(worktreeId) + ) // Why: concurrent host terminal.focus storms (CLI switch fan-out / bulk open) // each await a full host reveal; only one terminal can be focused, so latest-wins // single-flight bounds host work. Does not replace cheaper activation or @@ -3308,7 +3218,7 @@ export class OrcaRuntimeService { private canonicalFetchKeyCache = new Map() private optimisticReconcileTokens = new Map() private removeManagedWorktreeInFlight = new Map() - private preservedBranchCleanupByWorktreeId = new Map() + private preservedBranchCleanupByScope = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null private readonly getSshProviderFn: ((connectionId: string) => IPtyProvider | undefined) | null private readonly onPtyStopped: ((ptyId: string) => void) | null @@ -3612,7 +3522,7 @@ export class OrcaRuntimeService { return } await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, { - shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32', + shouldHydrateShellPath: app.isPackaged, onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const current = this.store?.getSettings() @@ -3928,6 +3838,9 @@ export class OrcaRuntimeService { } setOrchestrationDb(db: OrchestrationDb): void { + clearFederationAckCheckpoints(this) + this.orchestrationFederationSyncs.clear() + this.orchestrationFederationWarnings.clear() this._orchestrationDb = db } @@ -3945,7 +3858,11 @@ export class OrcaRuntimeService { prepareLegacyWorkerTerminalRecovery(): LegacyWorkerTerminalRecoveryPlan { const plan = this.getLegacyWorkerTerminalRecoveryPlan() const store = this.store - if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.flushOrThrow) { + if ( + !store?.getWorkspaceSession || + !store.setWorkspaceSession || + (!store.flushPendingOrThrowAsync && !store.flushOrThrow) + ) { return plan } const sessions = new Map< @@ -4000,13 +3917,25 @@ export class OrcaRuntimeService { for (const [hostId, state] of changed) { store.setWorkspaceSession(state.next, hostId) } - store.flushOrThrow() } catch (error) { - console.warn('[orchestration] failed to persist legacy worker resume fence', error) + console.warn('[orchestration] failed to stage legacy worker resume fence', error) } return plan } + private async flushWorkspaceSessionOrThrowAsync(): Promise { + const store = this.store + if (store?.flushPendingOrThrowAsync) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return + } + if (store?.flushOrThrow) { + store.flushOrThrow() + return + } + throw new Error('workspace_session_persistence_unavailable') + } + async reconcileLegacyWorkerTerminals( options: { connectionId?: string; materializeRenderer?: boolean } = {} ): Promise { @@ -4106,33 +4035,71 @@ export class OrcaRuntimeService { ) } - private persistLegacyWorkerTerminalRecoveryResolution( - candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number], - resolution: 'adopted' | 'exited' - ): boolean { + private async persistLegacyWorkerTerminalRecoveryBatch( + resolutions: readonly LegacyWorkerTerminalRecoveryResolution[] + ): Promise> { const store = this.store - const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) - if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) { - return false + if ( + !store?.getWorkspaceSession || + !store.setWorkspaceSession || + (!store.flushPendingOrThrowAsync && !store.flushOrThrow) + ) { + return new Set() } - const record = session.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] - if (!record || !runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { - return true - } - const next = structuredClone(session) - delete next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] + const originalSessions = new Map() + const stagedSessions = new Map() + const stagedDispatchIds = new Set() try { - this.setWorkspaceSessionForWorktree(candidate.worktreeId, next) - store.flushOrThrow() - return true + for (const { candidate, resolution } of resolutions) { + const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(candidate.worktreeId) + const session = hostId ? store.getWorkspaceSession(hostId) : null + if (!hostId || !session) { + continue + } + originalSessions.set(hostId, originalSessions.get(hostId) ?? session) + let next = + resolution === 'exited' + ? retireTerminalSurfaceFromPersistence(session, { + worktreeId: candidate.worktreeId, + parentTabId: candidate.tabId, + leafId: candidate.leafId, + ptyId: candidate.ptyId, + incarnationId: candidate.incarnationId + }) + : session + const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] + if (record && runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { + const sleepingAgentSessionsByPaneKey = { ...next.sleepingAgentSessionsByPaneKey } + delete sleepingAgentSessionsByPaneKey[candidate.paneKey] + next = { ...next, sleepingAgentSessionsByPaneKey } + } + if (next !== session) { + store.setWorkspaceSession(next, hostId) + } + stagedSessions.set(hostId, store.getWorkspaceSession(hostId)) + stagedDispatchIds.add(candidate.dispatchId) + } + if (stagedDispatchIds.size > 0) { + await this.flushWorkspaceSessionOrThrowAsync() + } + return stagedDispatchIds } catch (error) { - this.setWorkspaceSessionForWorktree(candidate.worktreeId, session) - console.warn('[orchestration] failed to persist legacy worker recovery resolution', { - dispatchId: candidate.dispatchId, - resolution, + for (const [hostId, original] of originalSessions) { + const staged = stagedSessions.get(hostId) + const current = store.getWorkspaceSession(hostId) + if (!staged || !current) { + continue + } + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(original, staged, current) + if (rolledBack !== current) { + store.setWorkspaceSession(rolledBack, hostId) + } + } + console.warn('[orchestration] failed to persist legacy worker recovery batch', { + dispatchIds: [...stagedDispatchIds], error }) - return false + return new Set() } } @@ -4157,48 +4124,9 @@ export class OrcaRuntimeService { } } - private resolveExitedLegacyWorkerTerminal( - candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] - ): boolean { - if ( - !this.rollbackLegacyWorkerTerminalSurface(candidate) || - !this.persistLegacyWorkerTerminalRecoveryResolution(candidate, 'exited') || - !this.reconcileMissingLegacyWorkerTerminal(candidate) - ) { - return false - } - this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'exited') - return true - } - private rollbackLegacyWorkerTerminalSurface( candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] - ): boolean { - const store = this.store - const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) - if (store?.setWorkspaceSession && store.flushOrThrow && session) { - const retired = retireTerminalSurfaceFromPersistence(session, { - worktreeId: candidate.worktreeId, - parentTabId: candidate.tabId, - leafId: candidate.leafId, - ptyId: candidate.ptyId, - incarnationId: candidate.incarnationId - }) - if (retired !== session) { - try { - this.setWorkspaceSessionForWorktree(candidate.worktreeId, retired) - store.flushOrThrow() - } catch (error) { - this.setWorkspaceSessionForWorktree(candidate.worktreeId, session) - console.warn('[orchestration] failed to persist legacy worker surface rollback', { - dispatchId: candidate.dispatchId, - error - }) - return false - } - } - } - + ): void { const snapshot = this.mobileSessionTabsByWorktree.get(candidate.worktreeId) if (snapshot) { const retired = retireTerminalSurfacesFromSnapshot({ @@ -4246,13 +4174,6 @@ export class OrcaRuntimeService { 'rolled_back', candidate.ptyId ) - return true - } - - private resolveReplacedLegacyWorkerTerminal( - candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] - ): boolean { - return this.resolveExitedLegacyWorkerTerminal(candidate) } private updateLegacyWorkerTerminalRecoveryRetry( @@ -4332,6 +4253,7 @@ export class OrcaRuntimeService { const adoptedDispatchIds: string[] = [] const exitedDispatchIds: string[] = [] const deferredDispatchIds = new Set(plan.ambiguousDispatchIds) + const pendingResolutions: LegacyWorkerTerminalRecoveryResolution[] = [] const recoveryCandidatesByProvider = new Map< string, { @@ -4398,11 +4320,7 @@ export class OrcaRuntimeService { } for (const { candidate, workspace } of provider.entries) { if (!inventory.livePtyIds.has(candidate.ptyId)) { - if (this.resolveExitedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const controllerIdentity = inventory.terminalIdentityByPtyId.get(candidate.ptyId) @@ -4414,11 +4332,7 @@ export class OrcaRuntimeService { controllerIdentity.handle !== candidate.terminalHandle || controllerIdentity.incarnationId !== candidate.incarnationId ) { - if (this.resolveReplacedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const preAdoptionInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( @@ -4432,11 +4346,7 @@ export class OrcaRuntimeService { continue } if (!preAdoptionInventory.livePtyIds.has(candidate.ptyId)) { - if (this.resolveExitedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const preAdoptionIdentity = preAdoptionInventory.terminalIdentityByPtyId.get( @@ -4450,11 +4360,7 @@ export class OrcaRuntimeService { preAdoptionIdentity.handle !== candidate.terminalHandle || preAdoptionIdentity.incarnationId !== candidate.incarnationId ) { - if (this.resolveReplacedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) @@ -4585,11 +4491,7 @@ export class OrcaRuntimeService { if (!finalInventory.livePtyIds.has(candidate.ptyId)) { this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) this.onPtyExit(candidate.ptyId, 0, candidate.incarnationId) - if (this.resolveExitedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const finalIdentity = finalInventory.terminalIdentityByPtyId.get(candidate.ptyId) @@ -4603,21 +4505,32 @@ export class OrcaRuntimeService { finalIdentity.incarnationId !== candidate.incarnationId ) { this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) - if (this.resolveReplacedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } - continue - } - if (!this.persistLegacyWorkerTerminalRecoveryResolution(candidate, 'adopted')) { - deferredDispatchIds.add(candidate.dispatchId) + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } + pendingResolutions.push({ candidate, resolution: 'adopted' }) + } + } + const persistedDispatchIds = + await this.persistLegacyWorkerTerminalRecoveryBatch(pendingResolutions) + for (const { candidate, resolution } of pendingResolutions) { + if (!persistedDispatchIds.has(candidate.dispatchId)) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + if (resolution === 'adopted') { this.legacyWorkerRecoveredPtys.add(candidate.ptyId) this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'adopted') adoptedDispatchIds.push(candidate.dispatchId) + continue } + this.rollbackLegacyWorkerTerminalSurface(candidate) + if (!this.reconcileMissingLegacyWorkerTerminal(candidate)) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'exited') + exitedDispatchIds.push(candidate.dispatchId) } const result = { blockedPaneCount: plan.blockedPanes.length, @@ -4758,29 +4671,46 @@ export class OrcaRuntimeService { ) } - private syncOrchestrationFederatedDispatch(dispatchId: string): Promise { + syncOrchestrationFederatedDispatch(dispatchId: string): Promise { + const db = this.getOrchestrationDb() const current = this.orchestrationFederationSyncs.get(dispatchId) - if (current) { - return current + if (current?.db === db) { + return current.promise } const sync = syncFederatedDispatch(this, dispatchId) .then(() => { - this.orchestrationFederationWarnings.delete(dispatchId) + if (this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync) { + this.orchestrationFederationWarnings.delete(dispatchId) + } }) .catch((error: unknown) => { - if (!this.orchestrationFederationWarnings.has(dispatchId)) { + if ( + this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync && + !this.orchestrationFederationWarnings.has(dispatchId) + ) { console.warn(`[orchestration] Federation sync failed for ${dispatchId}:`, error) this.orchestrationFederationWarnings.add(dispatchId) } throw error }) .finally(() => { - this.orchestrationFederationSyncs.delete(dispatchId) + if (this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync) { + this.orchestrationFederationSyncs.delete(dispatchId) + } }) - this.orchestrationFederationSyncs.set(dispatchId, sync) + this.orchestrationFederationSyncs.set(dispatchId, { db, promise: sync }) return sync } + async syncOrchestrationFederatedDispatchAfterCurrent(dispatchId: string): Promise { + const db = this.getOrchestrationDb() + const current = this.orchestrationFederationSyncs.get(dispatchId) + if (current?.db === db) { + await current.promise.catch(() => undefined) + } + await this.syncOrchestrationFederatedDispatch(dispatchId) + } + ensureOrchestrationFederationRelay(runId?: string): void { if (!this.orchestrationEnvironmentTransport) { return @@ -4815,6 +4745,8 @@ export class OrcaRuntimeService { } this.orchestrationFederationTimers.clear() this.orchestrationFederationWarnings.clear() + this.orchestrationFederationSyncs.clear() + clearFederationAckCheckpoints(this) } getStartedAt(): number { @@ -4990,74 +4922,6 @@ export class OrcaRuntimeService { return resolveLocalAiVaultSessionTitles(requests, signal) } - async getAiVaultSessionLiveness(target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string - }): Promise { - const provider = this.getLocalProviderFn?.() - if (!provider || !this.getAgentProviderSessionSnapshotFn) { - return 'unknown' - } - const identity = await readAiVaultSessionIdentity(target) - if (identity.outcome !== 'found') { - return 'unknown' - } - const deadlineMs = Date.now() + 3_000 - return await resolveAiVaultSessionLiveness( - { - agent: target.agent, - sessionId: identity.sessionId - }, - { - deadlineMs, - listProcesses: async () => { - const result = await withTimeoutResult( - provider.listProcesses({ deadlineMs }), - Math.max(1, deadlineMs - Date.now()) - ) - if (!result.ok) { - throw new Error('agent_session_ownership_unknown') - } - return result.value - }, - getStatusSnapshot: this.getAgentProviderSessionSnapshotFn, - inspectForegroundProcess: async (ptyId) => { - const result = await withTimeoutResult( - provider.getForegroundProcess(ptyId), - Math.max(1, deadlineMs - Date.now()) - ) - return result.ok - ? { available: true, process: result.value } - : { available: false, process: null } - }, - getStatusPtyId: (status) => { - if (status.terminalHandle) { - const live = this.getLivePtyForHandle(status.terminalHandle) - if (live) { - return live.pty.ptyId - } - for (const [ptyId, handle] of this.handleByPtyId) { - if (handle === status.terminalHandle) { - return ptyId - } - } - } - return this.getPtyRecordForPaneKey(status.paneKey)?.ptyId ?? null - }, - getAgentHint: (process) => { - const pty = this.ptysById.get(process.id) - const runtimeHint = pty?.foregroundAgent ?? pty?.launchAgent - if (runtimeHint) { - return runtimeHint - } - const ownerAgents = new Set(process.agentSessionOwners?.map((owner) => owner.claim.agent)) - return ownerAgents.size === 1 ? ([...ownerAgents][0] ?? null) : null - } - } - ) - } - prepareAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs ): Promise { @@ -6744,6 +6608,21 @@ export class OrcaRuntimeService { } } + private getMobileSessionWorktreeIdsForPty(ptyId: string): string[] { + const worktreeIds: string[] = [] + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const hasPtyBackedTab = snapshot.tabs.some( + (tab) => + tab.type === 'terminal' && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) + ) + if (hasPtyBackedTab) { + worktreeIds.push(worktreeId) + } + } + return worktreeIds + } + /** Bump the snapshot version and emit, coalesced unless `immediate`. * Why the bump: clients gate mirrored snapshots on a strictly increasing * `snapshotVersion`, so a re-emit at the same version is silently dropped. */ @@ -7802,10 +7681,10 @@ export class OrcaRuntimeService { ptyId: string splitFromLeafId: string direction: 'horizontal' | 'vertical' - }): void { + }): boolean { const session = this.getWorkspaceSessionForWorktree(args.worktreeId) if (!session || !this.store?.setWorkspaceSession) { - return + return false } const existing = session.terminalLayoutsByTabId?.[args.tabId] const nextLayout = buildHeadlessTerminalSplitLayout( @@ -7819,6 +7698,7 @@ export class OrcaRuntimeService { [args.tabId]: nextLayout } }) + return true } private persistHeadlessTerminalActiveLeaf( @@ -9291,6 +9171,9 @@ export class OrcaRuntimeService { // subscriber closing mid-window still receives the latest settled state. this.mobileSessionTabsNotifyCoalescer.flushAll() this.mobileSessionTabListeners.delete(subscription) + if (this.mobileSessionTabListeners.size === 0) { + this.mobileSessionTabsAgentStatusHeartbeat.cancelPending() + } } } @@ -9513,6 +9396,7 @@ export class OrcaRuntimeService { isTuiAgent(agentLaunchAuthority.launchAgent) ) { pty.launchToken = agentLaunchAuthority.launchToken + pty.launchIncarnationId = binding.incarnationId pty.launchAgent = agentLaunchAuthority.launchAgent } const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) @@ -10077,13 +9961,11 @@ export class OrcaRuntimeService { ingestSyntheticTitleFrame(ptyId: string, data: string): void { const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) entry.applyingChunk = true - entry.applyingSyntheticFrame = true entry.chunkTouchedSessionTabs = false try { entry.tracker.applySyntheticTitleFrame(data) } finally { entry.applyingChunk = false - entry.applyingSyntheticFrame = false this.flushPendingTerminalSideEffectFacts(ptyId, entry) } if (entry.chunkTouchedSessionTabs) { @@ -10321,13 +10203,13 @@ export class OrcaRuntimeService { return { ...snapshot, lastTitle: tracked } } - /** Decorative comparison key: spinner frame glyphs stripped, derived agent - * status kept so a working→idle flip with an otherwise-equal label still - * counts as a change. */ + /** Decorative comparison key: only recognized agent titles fold leading spinner frames. */ private makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { - return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs( - normalizedTitle - )}` + // Stable Pi/Gemini/Grok display normalization also defines their semantic gate. + const normalizedSignature = + rawTitle === normalizedTitle ? null : getDecorativeAgentTitleSignature(normalizedTitle) + const signature = normalizedSignature ?? getDecorativeAgentTitleSignature(rawTitle) + return signature === null ? `literal\u0000${normalizedTitle}` : `agent\u0000${signature}` } private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { @@ -10358,28 +10240,42 @@ export class OrcaRuntimeService { ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) }) const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) - // Why: an identity-only cursor title records nothing, so on a fresh pane `changed` is - // false — but the tracker title is that pane's only Cursor identity and still has to - // fan out to the sidebar and mobile (#10258). - if (!changed && !this.isLiveCursorNativeTitle(rawTitle, meta)) { - return - } + const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const live = this.ptyTitleTrackersByPtyId.get(ptyId) const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey if (live) { live.lastMobileTitleGateKey = gateKey } + const tracksReplicatedStatus = + live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0 + const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null + if ( + tracksReplicatedStatus && + decorativeOnly && + !this.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId) && + (titleStatus === 'working' || titleStatus === 'permission') + ) { + // Normalized Pi/Gemini/Grok frames still renew the replicated status lease. + this.mobileSessionTabsAgentStatusHeartbeat.scheduleDecorativeHeartbeat(ptyId) + } + // Why: an identity-only cursor title records nothing, but the tracker + // title is that pane's only Cursor identity and must still fan out (#10258). + if (!changed && !identityOnlyTitle) { + return + } if (live?.applyingChunk) { // Why: synthetic spinner ticks change only the braille glyph // ~12.5x/sec; fanning out full mobile session snapshots per frame // is pure churn. Raw lastOscTitle updates above stay cheap. - if (!(live.applyingSyntheticFrame && decorativeOnly)) { + if (!decorativeOnly) { + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) live.chunkTouchedSessionTabs = true } } else { // Stale-working-title timer path — fires between chunks, so the // per-chunk batching in onPtyData cannot pick it up. + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) this.touchMobileSessionSnapshotsForPty(ptyId) } }, @@ -10423,7 +10319,6 @@ export class OrcaRuntimeService { const entry: RuntimePtyTitleTrackerEntry = { tracker, applyingChunk: false, - applyingSyntheticFrame: false, lastMobileTitleGateKey: null, chunkTouchedSessionTabs: false, pendingFacts: [], @@ -10465,11 +10360,22 @@ export class OrcaRuntimeService { const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle const observedAt = this.nextTitleObservationSequence() + const observedAtEpochMs = identityOnlyTitle ? null : Date.now() pty.lastOscTitle = recordedTitle pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt - pty.lastOscTitleEpochMs = identityOnlyTitle ? null : Date.now() + pty.lastOscTitleEpochMs = observedAtEpochMs pty.lastAgentStatus = agentStatus pty.lastAgentStatusObservedLive = true + if (prevStatus !== agentStatus) { + pty.lastAgentStatusStartedAtEpochMs = observedAtEpochMs + } + if ( + identityOnlyTitle || + terminalTitleBlocksExplicitAgentStatus(recordedTitle) || + (prevStatus !== null && agentStatus !== null && prevStatus !== agentStatus) + ) { + pty.lastAgentStatusRichInvalidatedAtEpochMs = observedAtEpochMs ?? Date.now() + } if (identityOnlyTitle) { pty.managementTitle = null pty.managementTitleAt = null @@ -10549,6 +10455,8 @@ export class OrcaRuntimeService { private disposePtyTitleTracker(ptyId: string): void { this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() this.ptyTitleTrackersByPtyId.delete(ptyId) + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId) for (const titleGateKeys of this.terminalSideEffectTitleGateKeysByClientEventListener.values()) { titleGateKeys.delete(ptyId) } @@ -10570,6 +10478,8 @@ export class OrcaRuntimeService { // Why: the prior process's live frames say nothing about the replacement, // so the seed a same-id restore applies must not inherit its authority. pty.lastAgentStatusObservedLive = false + pty.lastAgentStatusStartedAtEpochMs = null + pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now() pty.managementTitle = null pty.managementTitleAt = null } @@ -11230,6 +11140,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -11248,6 +11159,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -11698,6 +11610,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -11707,6 +11620,7 @@ export class OrcaRuntimeService { oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number } | null> { if (this.providerSnapshotPreferredPtys.has(ptyId)) { // Why: pre-attach stream bytes only form a suffix of restored state. A @@ -11748,6 +11662,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -11755,6 +11670,7 @@ export class OrcaRuntimeService { lastTitle?: string source?: 'renderer' oscLinks?: TerminalOscLinkRange[] + kittyKeyboardFlags?: number } | null> { if (this.ptyController?.hasRendererSerializer?.(ptyId) === false) { return null @@ -11767,6 +11683,7 @@ export class OrcaRuntimeService { cwd?: string | null lastTitle?: string oscLinks?: TerminalOscLinkRange[] + kittyKeyboardFlags?: number } | null = null try { // Why: recovery/read fallback wants visible alt-screen content (e.g. an @@ -12149,6 +12066,7 @@ export class OrcaRuntimeService { oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean scrollbackAnsi?: string + kittyKeyboardFlags?: number // Why: dangling mid-escape tail the restorer must write LAST, after any // reset, so the next live chunk completes it instead of rendering it // literally (Bug E / #7329). @@ -12168,6 +12086,7 @@ export class OrcaRuntimeService { return data.length > 0 || opts.includeEmpty === true ? this.preferTrackedLastTitle(ptyId, { data, + frameRestoreAnsi: snapshot.frameRestoreAnsi, cols: snapshot.cols, rows: snapshot.rows, cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), @@ -12176,6 +12095,12 @@ export class OrcaRuntimeService { source: 'headless' as const, oscLinks: snapshot.oscLinks, scrollbackAnsi: snapshot.scrollbackAnsi, + // Why beside outputSequence and never re-read later: the flags must + // describe the same stream position as the image, or replay would + // apply push/pop transitions twice or out of order. + ...(parseTerminalKittyKeyboardFlags(snapshot.modes?.kittyKeyboardFlags) !== undefined + ? { kittyKeyboardFlags: snapshot.modes.kittyKeyboardFlags } + : {}), ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), @@ -12481,6 +12406,7 @@ export class OrcaRuntimeService { } this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) pty.launchToken = null + pty.launchIncarnationId = null const paneKeys = new Set() if (pty.paneKey && parsePaneKey(pty.paneKey)) { paneKeys.add(pty.paneKey) @@ -15618,6 +15544,24 @@ export class OrcaRuntimeService { } } + async inspectTerminalProcessIncarnationLiveness( + processIncarnation: string, + serializedHostScope: string | null + ): Promise<'live' | 'dead' | 'unknown'> { + const hostScope = parseWorkerTerminalHostScope(serializedHostScope) + if (!hostScope || !this.ptyController?.listProcesses) { + return 'unknown' + } + const listed = await withTimeoutResult( + this.ptyController.listProcesses(hostScope.kind === 'ssh' ? hostScope.targetId : null), + PTY_CONTROLLER_LIST_TIMEOUT_MS + ) + if (!listed.ok) { + return 'unknown' + } + return classifyWorkerTerminalProcessIncarnation(processIncarnation, listed.value) + } + private getTerminalTopologyRevision(worktreeId: string): number { const repoId = getRepoIdFromWorktreeId(worktreeId) return ( @@ -15657,7 +15601,11 @@ export class OrcaRuntimeService { const { livePtyIds, terminalIdentityByPtyId } = inventory const store = this.store const session = this.getWorkspaceSessionForWorktree(workspace.id) - if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) { + if ( + !store?.setWorkspaceSession || + (!store.flushPendingOrThrowAsync && !store.flushOrThrow) || + !session + ) { throw new Error('workspace_session_unavailable') } const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id) @@ -16077,11 +16025,19 @@ export class OrcaRuntimeService { [workspace.id]: activeGroup.id } const persisted = advanceTerminalTopologyRevision(next, workspace.id) + let staged: WorkspaceSessionState | null = null try { this.setWorkspaceSessionForWorktree(workspace.id, persisted) - store.flushOrThrow() + staged = this.getWorkspaceSessionForWorktree(workspace.id) + await this.flushWorkspaceSessionOrThrowAsync() } catch (error) { - this.setWorkspaceSessionForWorktree(workspace.id, session) + const current = this.getWorkspaceSessionForWorktree(workspace.id) + if (staged && current) { + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + this.setWorkspaceSessionForWorktree(workspace.id, rolledBack) + } + } throw error } for (const { claim, pty, paneKey } of validated) { @@ -16816,7 +16772,8 @@ export class OrcaRuntimeService { const liveTitleClearsBlockedText = terminal.titleStatusIsLive && terminal.titleStatus !== null && - terminal.titleStatus !== 'permission' + terminal.titleStatus !== 'permission' && + !isOpenCodeNativeTitle(terminal.title) if (terminal.titleStatus === 'permission' && terminal.titleStatusIsLive) { return { handle, isRunningAgent: true, status: 'permission' } } @@ -16843,6 +16800,20 @@ export class OrcaRuntimeService { } } if (terminal.titleStatus) { + // Why: an OpenCode marker and a lone quarter-circle spinner (STA-4028) are activity, + // not identity, so resolve both through the identity/foreground evidence path. + if ( + isOpenCodeNativeTitle(terminal.title) || + isQuarterCircleSpinnerOnlyAgentTitle(terminal.title) + ) { + const isRunningAgent = await this.isTerminalRunningAgent(handle) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return { + handle, + isRunningAgent, + status: isRunningAgent ? terminal.titleStatus : null + } + } return { handle, isRunningAgent: true, status: terminal.titleStatus } } @@ -17126,6 +17097,9 @@ export class OrcaRuntimeService { return } this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + if (this.mobileSessionTabListeners.size > 0) { + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) + } if (!foregroundAgentChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -17737,6 +17711,7 @@ export class OrcaRuntimeService { ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), lastActivityAt: worktree.lastActivityAt, ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), linkedIssue: worktree.linkedIssue, linkedPR, linkedLinearIssue: meta?.linkedLinearIssue ?? null, @@ -17785,6 +17760,7 @@ export class OrcaRuntimeService { ...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}), lastActivityAt: worktree.lastActivityAt, ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), linkedIssue: worktree.linkedIssue ?? null, linkedPR: null, linkedLinearIssue: worktree.linkedLinearIssue ?? null, @@ -18459,6 +18435,7 @@ export class OrcaRuntimeService { name?: string folderPath?: string | null connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] createdWithAgent?: FolderWorkspace['createdWithAgent'] @@ -18487,7 +18464,10 @@ export class OrcaRuntimeService { { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) - const workspace = this.store.createFolderWorkspace(input) + const workspace = this.store.createFolderWorkspace({ + ...input, + creatorProvenance: input.creatorProvenance ?? { kind: 'host' } + }) this.notifyReposChanged() return workspace } @@ -18521,6 +18501,7 @@ export class OrcaRuntimeService { | 'pendingFirstAgentMessageRename' | 'firstAgentMessageRenameError' | 'lastActivityAt' + | 'diffComments' > > ): Promise { @@ -18572,6 +18553,7 @@ export class OrcaRuntimeService { if (!isAbsolute(path)) { throw new Error('Project path must be an absolute path') } + await awaitWindowsHostGitEnvironmentReady({ cwd: path }) return scanNestedRepos({ path, options: { timeoutMs: 15_000 } }) } @@ -18620,6 +18602,7 @@ export class OrcaRuntimeService { projectPaths: string[] mode: ProjectGroupImportMode }): Promise { + await awaitWindowsHostGitEnvironmentReady({ cwd: args.parentPath }) if (!this.store?.createProjectGroup || !this.store?.moveProjectToGroup) { throw new Error('runtime_unavailable') } @@ -18647,6 +18630,7 @@ export class OrcaRuntimeService { const importTargetResolver = createNestedRepoImportTargetResolver() for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { try { + await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) if (!isGitRepo(repoPath)) { results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) continue @@ -18774,6 +18758,9 @@ export class OrcaRuntimeService { // server-side repo paths to be explicit so `orca serve` cwd is irrelevant. throw new Error('Project path must be an absolute path') } + if (kind === 'git') { + await awaitWindowsHostGitEnvironmentReady({ cwd: path }) + } if (kind === 'git' && !isGitRepo(path)) { throw new Error(`Not a valid git repository: ${path}`) } @@ -19020,10 +19007,11 @@ export class OrcaRuntimeService { await mkdir(trimmedDestination, { recursive: true }) const claimedTarget = await claimCloneTarget(clonePath) - await new Promise((resolve, reject) => { - let proc: ReturnType - try { - proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], { + let proc: Awaited> + try { + proc = await gitSpawnAfterWindowsEnvironmentReady( + ['clone', '--progress', '--', trimmedUrl, clonePath], + { cwd: trimmedDestination, // Why: without the non-interactive guard, a clone that needs GitHub // auth makes Git Credential Manager pop its "Connect to GitHub" OAuth @@ -19032,14 +19020,14 @@ export class OrcaRuntimeService { // (issue #7652). Fail fast with a clear error instead. env: nonInteractiveGitEnv(), stdio: ['ignore', 'ignore', 'pipe'] - }) - } catch (err) { - void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => { - const message = err instanceof Error ? err.message : String(err) - reject(new Error(`Clone failed: ${message}`)) - }) - return - } + } + ) + } catch (err) { + await cleanupClaimedCloneTarget(clonePath, claimedTarget) + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Clone failed: ${message}`) + } + await new Promise((resolve, reject) => { let stderrTail = '' let settled = false proc.stderr?.on('data', (chunk: Buffer) => { @@ -19161,6 +19149,7 @@ export class OrcaRuntimeService { | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' | 'projectGroupId' | 'projectGroupOrder' > @@ -19481,10 +19470,25 @@ export class OrcaRuntimeService { } catch { continue } + const repoIcon = + upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github' + ? await detectGitHubAvatarIcon(repo.path, null, upstream) + : null + // Why: settings can change the repo while the probes above are pending, so + // re-read it — a stale snapshot must not clobber a user-chosen icon or an + // upstream another path already resolved. + const current = store.getRepos().find((candidate) => candidate.id === repo.id) + if (!current || current.upstream !== undefined) { + continue + } const updates: Partial = { upstream: upstream ?? null } // Only migrate the auto-detected origin avatar; never touch a chosen icon. - if (upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github') { - updates.repoIcon = githubAvatarIcon(upstream) + if ( + repoIcon && + current.repoIcon?.type === 'image' && + current.repoIcon.source === 'github' + ) { + updates.repoIcon = repoIcon } store.updateRepo(repo.id, updates) changed = true @@ -19754,6 +19758,36 @@ export class OrcaRuntimeService { return result } + async createStackedHostedReview( + args: CreateStackedHostedReviewInput & { repoSelector: string; worktreeSelector?: string } + ): Promise { + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) + const executionOptions = this.getHostedReviewExecutionOptions(repo) + const result = await createStackedHostedReviewFromRepo( + repoPath, + { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft, + ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) + }, + repo.connectionId ?? null, + executionOptions ?? {} + ) + if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { + this.stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: result.number, prUrl: result.url } + }) + } + return result + } + async listGitLabRepoWorkItems( repoSelector: string, state?: MRListState, @@ -20193,14 +20227,17 @@ export class OrcaRuntimeService { checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null - } + }, + signal?: AbortSignal ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) + const localGitOptions = this.getLocalGitExecutionOptionArgs(repo)[0] ?? {} return getPRCheckDetails( repo.path, { ...args, prRepo: args.prRepo ?? null }, repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) + localGitOptions, + signal ) } @@ -20220,6 +20257,25 @@ export class OrcaRuntimeService { ) } + async setRepoPRCommentReaction( + repoSelector: string, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return setPRCommentReaction( + repo.path, + reactionSubjectId, + content, + reacted, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + async getRepoPRFileContents( repoSelector: string, args: { @@ -21773,6 +21829,7 @@ export class OrcaRuntimeService { pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance + creatorProvenance?: Worktree['creatorProvenance'] startup?: WorktreeStartupLaunch startupDraft?: string startupDraftPaste?: WorktreeStartupDraftPaste @@ -21839,6 +21896,7 @@ export class OrcaRuntimeService { }, ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined @@ -22467,6 +22525,7 @@ export class OrcaRuntimeService { : {}), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) @@ -23634,17 +23693,21 @@ export class OrcaRuntimeService { // Why: fetch failures are non-fatal; we proceed with whatever the // last-known remote ref points at. `fetchRemoteWithCache` never throws. await this.fetchRemoteWithCache(repo.path, remote, localWorktreeGitOptions) - const drift = getRemoteDrift(wt.path, 'HEAD', base, localGitExecOptions) + const drift = await getRemoteDrift(wt.path, 'HEAD', base, localGitExecOptions) if (!drift) { return null } - const recentSubjects = getRecentDriftSubjects( - wt.path, - 'HEAD', - base, - DRIFT_PROBE_SUBJECT_LIMIT, - localGitExecOptions - ) + // Why: behind=0 proves HEAD..base is empty, so git log cannot add subjects. + const recentSubjects = + drift.behind > 0 + ? await getRecentDriftSubjects( + wt.path, + 'HEAD', + base, + DRIFT_PROBE_SUBJECT_LIMIT, + localGitExecOptions + ) + : [] return { base, behind: drift.behind, recentSubjects } } @@ -23668,8 +23731,7 @@ export class OrcaRuntimeService { this.invalidateWorktreeScanCacheForRepo(worktree.repoId) } const shouldClearPushTarget = - Object.prototype.hasOwnProperty.call(metaUpdates, 'pushTarget') && - metaUpdates.pushTarget === null + Object.hasOwn(metaUpdates, 'pushTarget') && metaUpdates.pushTarget === null const normalizedMetaUpdates: Partial = shouldClearPushTarget ? { ...metaUpdates, pushTarget: undefined } : (metaUpdates as Partial) @@ -23738,21 +23800,29 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } - const now = Date.now() - let updated = 0 - for (let i = 0; i < orderedIds.length; i++) { - // Why: a sort-order snapshot must only reorder existing worktrees, never - // mint new meta — a stale id would otherwise resurrect an orphan workspace - // (setWorktreeMeta has no repo-existence check). - if (!this.store.getWorktreeMeta(orderedIds[i])) { - continue - } - this.store.setWorktreeMeta(orderedIds[i], { sortOrder: now - i * 1000 }) - updated++ + const store = this.store + const updates = planWorktreeSortOrderUpdates( + orderedIds, + (worktreeId) => store.getWorktreeMeta(worktreeId), + Date.now() + ) + for (const update of updates) { + store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) + } + if (updates.length === 0) { + return { updated: 0 } } this.invalidateResolvedWorktreeCache() - this.notifyReposChanged() - return { updated } + const changedRepoIds = new Set( + updates.flatMap((update) => { + const parsed = splitWorktreeId(update.worktreeId) + return parsed ? [parsed.repoId] : [] + }) + ) + for (const repoId of changedRepoIds) { + this.notifyWorktreesChanged(repoId) + } + return { updated: updates.length } } async resolveManagedPrBase(args: { @@ -24147,6 +24217,7 @@ export class OrcaRuntimeService { store.removeWorktreeMeta(worktreeId) } this.mobileSessionTabsByWorktree.delete(worktreeId) + this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) @@ -24167,6 +24238,7 @@ export class OrcaRuntimeService { private rememberPreservedBranchCleanupTarget( worktreeId: string, + hostId: ExecutionHostId | undefined, result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined, pushTarget: GitPushTarget | undefined @@ -24178,14 +24250,21 @@ export class OrcaRuntimeService { `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` ) } - this.preservedBranchCleanupByWorktreeId.set(worktreeId, { - branchName: result.preservedBranch.branchName, - head, - ...(pushTarget ? { pushTarget } : {}) - }) + this.preservedBranchCleanupByScope.set( + preservedBranchCleanupScopeKey({ worktreeId, hostId }), + { + worktreeId, + ...(hostId ? { hostId } : {}), + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + } + ) return } - this.preservedBranchCleanupByWorktreeId.delete(worktreeId) + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId, hostId }) + ) } private preserveBranchHeadFallback( @@ -24207,15 +24286,29 @@ export class OrcaRuntimeService { async forceDeletePreservedBranch( worktreeSelector: string, branchName: string, - expectedHead: string + expectedHead: string, + hostId?: string ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) - const cleanupTarget = removalTarget - ? this.preservedBranchCleanupByWorktreeId.get(removalTarget.id) + const normalizedHostId = parseExecutionHostId(hostId)?.id + const exactTarget = removalTarget + ? this.preservedBranchCleanupByScope.get( + preservedBranchCleanupScopeKey({ worktreeId: removalTarget.id, hostId: normalizedHostId }) + ) : undefined + const legacyMatches = + removalTarget && !hostId + ? [...this.preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === removalTarget.id && + target.branchName === branchName && + target.head === expectedHead + ) + : [] + const cleanupTarget = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) if ( !removalTarget || !cleanupTarget || @@ -24269,7 +24362,12 @@ export class OrcaRuntimeService { ) } - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupTarget.hostId + }) + ) return { deleted: true } } @@ -24279,13 +24377,19 @@ export class OrcaRuntimeService { runHooks = false, // Why (#11960): only an explicit Force Delete waives PTY-stop proof; `force` // alone is already set by the ordinary delete confirmation. - allowUnverifiedPtyStop = false + allowUnverifiedPtyStop = false, + hostId?: string ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const store = this.store + const cleanupHostId = parseExecutionHostId(hostId)?.id const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector) + const cleanupScopeKey = preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupHostId + }) const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop) const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalTarget.id) if (inFlightRemoval) { @@ -24349,7 +24453,7 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24392,7 +24496,7 @@ export class OrcaRuntimeService { }) } this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(repo.id) return {} @@ -24497,7 +24601,7 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24546,7 +24650,7 @@ export class OrcaRuntimeService { ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24582,7 +24686,7 @@ export class OrcaRuntimeService { )) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24629,6 +24733,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -24674,6 +24779,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -24836,7 +24942,7 @@ export class OrcaRuntimeService { ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24863,6 +24969,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, refreshedRegisteredWorktree.head, removedPushTarget @@ -25680,6 +25787,7 @@ export class OrcaRuntimeService { ? copySleepingAgentLaunchConfig(effectiveLaunchConfig) : null pty.launchToken = launchToken ?? null + pty.launchIncarnationId = launchToken ? pty.incarnationId : null pty.launchAgent = launchOpts.launchAgent ?? null } pty.tabId = tabId @@ -27245,6 +27353,15 @@ export class OrcaRuntimeService { } const direction = opts.direction ?? 'horizontal' const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`) + const sourceAuthority = this.resolveTerminalSplitSourceAuthority( + workspace.id, + parentTabId, + parsedPaneKey.leafId, + pty.ptyId + ) + if (!sourceAuthority) { + throw new Error('terminal_split_source_not_found') + } const leafId = randomUUID() const preAllocatedHandle = this.createPreAllocatedTerminalHandle() const paneKey = makePaneKey(parentTabId, leafId) @@ -27261,7 +27378,22 @@ export class OrcaRuntimeService { preAllocatedHandle, tabId: parentTabId, leafId, - persistHostSessionBinding: true + persistHostSessionBinding: true, + ...(sourceAuthority.persisted + ? { + expectedSourceBinding: { + ...(sourceAuthority.persistedWorktreeId + ? { worktreeId: sourceAuthority.persistedWorktreeId } + : {}), + tabId: parentTabId, + leafId: parsedPaneKey.leafId, + ptyId: pty.ptyId, + ...(sourceAuthority.persistedIncarnationId + ? { incarnationId: sourceAuthority.persistedIncarnationId } + : {}) + } + } + : {}) }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { @@ -27279,7 +27411,7 @@ export class OrcaRuntimeService { ) } - try { + const revealSplit = async (): Promise => { await this.notifier?.revealTerminalSession?.(workspace.id, { ptyId: result.id, title: null, @@ -27291,33 +27423,136 @@ export class OrcaRuntimeService { splitDirection: direction, splitTelemetrySource: opts.telemetrySource }) + } + + try { + const revalidatedAuthority = this.resolveTerminalSplitSourceAuthority( + workspace.id, + parentTabId, + parsedPaneKey.leafId, + pty.ptyId + ) + if (!revalidatedAuthority || (sourceAuthority.persisted && !revalidatedAuthority.persisted)) { + throw new Error('terminal_split_source_not_found') + } + if (!sourceAuthority.persisted) { + await revealSplit() + } + if (createdPty) { + const persisted = this.persistHeadlessTerminalSplit({ + worktreeId: workspace.id, + tabId: parentTabId, + leafId, + ptyId: createdPty.ptyId, + splitFromLeafId: parsedPaneKey.leafId, + direction + }) + if (sourceAuthority.persisted && !persisted) { + throw new Error('workspace_session_unavailable') + } + this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { + tabId: parentTabId, + leafId, + title: null, + activate: opts.activate !== false, + split: { splitFromLeafId: parsedPaneKey.leafId, direction } + }) + } } catch (error) { this.setPairedRendererSessionOwnership(result.id, false) this.ptyController.kill?.(result.id) throw error } - if (createdPty) { - this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { - tabId: parentTabId, - leafId, - title: null, - activate: opts.activate !== false, - split: { splitFromLeafId: parsedPaneKey.leafId, direction } - }) - // Why: persist the split so a later snapshot rebuild keeps it instead of collapsing to a single pane. - this.persistHeadlessTerminalSplit({ - worktreeId: workspace.id, - tabId: parentTabId, - leafId, - ptyId: createdPty.ptyId, - splitFromLeafId: parsedPaneKey.leafId, - direction - }) + const committedSourceAuthority = sourceAuthority.persisted + ? this.resolveTerminalSplitSourceAuthority( + workspace.id, + parentTabId, + parsedPaneKey.leafId, + pty.ptyId + ) + : null + if (sourceAuthority.persisted && committedSourceAuthority?.rendererMounted) { + // Why: renderer adoption is a projection after the durable main commit; rejection cannot undo it. + void revealSplit().catch(() => undefined) } return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 } } + private resolveTerminalSplitSourceAuthority( + worktreeId: string, + tabId: string, + leafId: string, + ptyId: string + ): { + persisted: boolean + rendererMounted: boolean + persistedWorktreeId: string | null + persistedIncarnationId: string | null + } | null { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null + const persistedTab = sessionWorktreeId + ? session?.tabsByWorktree[sessionWorktreeId]?.find( + (tab) => tab.id === tabId && runtimeWorktreeIdsEqual(tab.worktreeId, worktreeId) + ) + : undefined + const persistedLayout = session?.terminalLayoutsByTabId?.[tabId] + const persistedIncarnationId = + session?.terminalPtyIncarnationsByPaneKey?.[makePaneKey(tabId, leafId)] ?? null + const liveIncarnationId = this.ptysById.get(ptyId)?.incarnationId ?? null + if ( + persistedIncarnationId && + liveIncarnationId && + persistedIncarnationId !== liveIncarnationId + ) { + return null + } + const persisted = Boolean( + persistedTab && + persistedLayout?.ptyIdsByLeafId?.[leafId] === ptyId && + terminalLayoutContainsLeaf(persistedLayout.root, leafId) + ) + const rendererTab = this.tabs.get(tabId) + const rendererLeaf = this.leaves.get(this.getLeafKey(tabId, leafId)) + const rendererMounted = Boolean( + rendererTab && + rendererLeaf && + runtimeWorktreeIdsEqual(rendererTab.worktreeId, worktreeId) && + runtimeWorktreeIdsEqual(rendererLeaf.worktreeId, worktreeId) && + rendererLeaf.ptyId === ptyId + ) + if (persisted && persistedLayout) { + return { + persisted: true, + rendererMounted, + persistedWorktreeId: sessionWorktreeId, + persistedIncarnationId + } + } + // Why: renderer adoption can precede graph sync; this path still requires reveal success before commit. + const projected = [...this.mobileSessionTabsByWorktree.entries()].some( + ([candidateWorktreeId, snapshot]) => + runtimeWorktreeIdsEqual(candidateWorktreeId, worktreeId) && + snapshot.tabs.some( + (tab) => + tab.type === 'terminal' && + tab.parentTabId === tabId && + tab.leafId === leafId && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[leafId] === ptyId) + ) + ) + if (!rendererMounted && !projected) { + return null + } + return { + persisted: false, + rendererMounted, + persistedWorktreeId: null, + persistedIncarnationId: null + } + } + async handleAgentTeamsTmuxCompat( request: AgentTeamsTmuxCompatRequest ): Promise { @@ -27747,7 +27982,7 @@ export class OrcaRuntimeService { committedPtyIds, terminalHandlesByPtyId }) - if (failedStopIndex >= 0) { + if (failedStopIndex !== -1) { const failedStop = stopResults[failedStopIndex] throw Object.assign(new Error('terminal_worktree_sleep_failed'), { ptyId: orderedLivePtyIds[failedStopIndex], @@ -27774,7 +28009,7 @@ export class OrcaRuntimeService { committedPtyIds, terminalHandlesByPtyId }) - if (failedStopIndex >= 0 && remainingLivePtyIds.size > 0) { + if (failedStopIndex !== -1 && remainingLivePtyIds.size > 0) { const failedStop = stopResults[failedStopIndex] console.error('[runtime] worktree terminal sleep physical stop failed', { worktreeId: worktree.id, @@ -28133,16 +28368,9 @@ export class OrcaRuntimeService { private async resolveFolderWorkspaceLaunchScope( selector: string ): Promise { - const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector - const parsed = parseWorkspaceKey(workspaceSelector) - if (parsed?.type !== 'folder') { - return null - } - const workspace = this.store - ?.getFolderWorkspaces?.() - .find((entry) => entry.id === parsed.folderWorkspaceId) + const workspace = this.resolveFolderWorkspaceSelector(selector) if (!workspace) { - throw new Error('selector_not_found') + return null } if (!this.store) { throw new Error('runtime_unavailable') @@ -28162,6 +28390,36 @@ export class OrcaRuntimeService { } } + private resolveFolderWorkspaceSelector(selector: string): FolderWorkspace | null { + const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = parseWorkspaceKey(workspaceSelector) + if (parsed?.type !== 'folder') { + return null + } + const workspace = this.store + ?.getFolderWorkspaces?.() + .find((entry) => entry.id === parsed.folderWorkspaceId) + if (!workspace) { + throw new Error('selector_not_found') + } + return workspace + } + + private async resolveEmulatorWorkspaceId(selector: string): Promise { + const folderWorkspace = this.resolveFolderWorkspaceSelector(selector) + return folderWorkspace + ? folderWorkspaceKey(folderWorkspace.id) + : (await this.resolveWorktreeSelector(selector)).id + } + + private async resolveEmulatorCleanupWorkspaceId(selector: string): Promise { + const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = parseWorkspaceKey(workspaceSelector) + return parsed?.type === 'folder' + ? folderWorkspaceKey(parsed.folderWorkspaceId) + : this.resolveEmulatorWorkspaceId(selector) + } + private folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree { const worktree = folderWorkspaceToWorktree(folderWorkspace) return { @@ -29177,6 +29435,7 @@ export class OrcaRuntimeService { paneKey: state.paneKey ?? null, launchConfig: null, launchToken: null, + launchIncarnationId: null, launchAgent: null, foregroundAgent: null, connected: state.connected ?? true, @@ -29184,6 +29443,8 @@ export class OrcaRuntimeService { lastExitCode: null, lastAgentStatus: null, lastAgentStatusObservedLive: false, + lastAgentStatusStartedAtEpochMs: null, + lastAgentStatusRichInvalidatedAtEpochMs: null, lastOscTitle: null, lastOscTitleAt: null, lastOscTitleEpochMs: null, @@ -29387,6 +29648,7 @@ export class OrcaRuntimeService { for (const ptyId of ambiguousControllerPtyIds) { controllerIdentityByPtyId.delete(ptyId) } + const findResolvedWorktree = createIncrementalResolvedWorktreeLookup(resolvedWorktrees) const persistedIndexesByHostId = new Map< ExecutionHostId, { @@ -29418,14 +29680,12 @@ export class OrcaRuntimeService { ) const controllerIdentity = controllerIdentityByPtyId.get(session.id) const persistedWorktreeId = persistedIndexes.worktreeIdByPtyId.get(session.id) - const providerWorktree = resolvedWorktrees.find( - (worktree) => session.worktreeId && runtimeWorktreeIdsEqual(worktree.id, session.worktreeId) - ) + const providerWorktree = session.worktreeId + ? findResolvedWorktree(session.worktreeId) + : undefined const inferredWorktreeId = inferWorktreeIdFromPtyId(session.id) const persistedWorktree = persistedWorktreeId - ? resolvedWorktrees.find((worktree) => - runtimeWorktreeIdsEqual(worktree.id, persistedWorktreeId) - ) + ? findResolvedWorktree(persistedWorktreeId) : undefined const hasMigrationEvidence = Boolean(session.worktreeId) && @@ -29792,7 +30052,7 @@ export class OrcaRuntimeService { private syncMobileSessionTabs( snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, unchangedWorktreeIds?: string[], - resyncWorktreeIds: Set = new Set() + resyncWorktreeIds = new Set() ): Set { const changedWorktreeIds = new Set() if (snapshots === undefined) { @@ -29935,6 +30195,7 @@ export class OrcaRuntimeService { nextWorktrees.add(worktreeId) } else { this.mobileSessionTabsByWorktree.delete(worktreeId) + this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) // Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame. this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) @@ -30471,16 +30732,21 @@ export class OrcaRuntimeService { (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) ? hookAgentStatus.providerSession : tab.agentStatus?.providerSession - const normalizedTabAgentStatus = tab.agentStatus - ? normalizeCompatibleAgentStatusEntryForOwner( - { - ...tab.agentStatus, - ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) - }, - ownerAgent - ) - : null - // Why: keep rich hook status on a live prompt/tool (authoritative even under a non-agent title), else interactivePrompt is lost. + const statusPty = liveLeafPty ?? mobileStatusPty + const normalizedTabAgentStatus = this.renewMobileAgentStatusFromPtyTitle( + tab.agentStatus + ? normalizeCompatibleAgentStatusEntryForOwner( + { + ...tab.agentStatus, + ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) + }, + ownerAgent + ) + : null, + statusPty, + { preserveQuestionUnderShellTitle: true } + ) + // Why: keep rich status on a live prompt/tool, or interactivePrompt is lost under a non-agent title. const hasLiveAgentSignal = normalizedTabAgentStatus?.interactivePrompt != null || normalizedTabAgentStatus?.toolName != null @@ -30500,8 +30766,10 @@ export class OrcaRuntimeService { agentStatus: { state: 'done' as const, prompt: '', - updatedAt: normalizedTabAgentStatus.updatedAt, - stateStartedAt: normalizedTabAgentStatus.stateStartedAt, + updatedAt: statusPty?.lastOscTitleEpochMs ?? normalizedTabAgentStatus.updatedAt, + stateStartedAt: + statusPty?.lastAgentStatusStartedAtEpochMs ?? + normalizedTabAgentStatus.stateStartedAt, paneKey: normalizedTabAgentStatus.paneKey, stateHistory: [], agentType: normalizedTabAgentStatus.agentType, @@ -30594,6 +30862,91 @@ export class OrcaRuntimeService { } } + private renewMobileAgentStatusFromPtyTitle( + status: AgentStatusEntry | null, + pty: RuntimePtyWorktreeRecord | null, + options: { preserveQuestionUnderShellTitle?: boolean } = {} + ): AgentStatusEntry | null { + if (!status || !pty) { + return status + } + if ( + options.preserveQuestionUnderShellTitle && + status.interactivePrompt != null && + terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) + ) { + return status + } + const richStatusCanOwnTitleInterval = + pty.lastAgentStatusRichInvalidatedAtEpochMs === null || + status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs + const titleEvidenceAt = pty.lastOscTitleEpochMs + if (titleEvidenceAt === null) { + return richStatusCanOwnTitleInterval ? status : null + } + const buildTitleOnlyStatus = ( + state: AgentStatusEntry['state'], + updatedAt: number, + stateStartedAt: number + ): AgentStatusEntry => ({ + state, + prompt: '', + updatedAt, + stateStartedAt, + paneKey: status.paneKey, + stateHistory: [], + ...(status.agentType ? { agentType: status.agentType } : {}), + ...(status.terminalHandle ? { terminalHandle: status.terminalHandle } : {}), + ...(status.worktreeId ? { worktreeId: status.worktreeId } : {}), + ...(status.tabId ? { tabId: status.tabId } : {}), + ...(status.terminalTitle ? { terminalTitle: status.terminalTitle } : {}), + ...(status.providerSession ? { providerSession: status.providerSession } : {}) + }) + const titleConfirmsState = + (pty.lastAgentStatus === 'working' && status.state === 'working') || + (pty.lastAgentStatus === 'permission' && + (status.state === 'blocked' || status.state === 'waiting')) + if (!titleConfirmsState) { + if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) { + return status + } + if ( + pty.lastAgentStatus === null && + !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) + ) { + return status + } + const titleState = + pty.lastAgentStatus === 'working' + ? 'working' + : pty.lastAgentStatus === 'permission' + ? 'blocked' + : 'done' + return buildTitleOnlyStatus( + titleState, + titleEvidenceAt, + pty.lastAgentStatusStartedAtEpochMs ?? titleEvidenceAt + ) + } + const richStatusIsFresh = Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + const richStatusOwnsCurrentState = richStatusIsFresh && richStatusCanOwnTitleInterval + // Fresh explicit evidence from this title interval owns acknowledgement identity. + const stateStartedAt = richStatusOwnsCurrentState + ? status.stateStartedAt + : (pty.lastAgentStatusStartedAtEpochMs ?? status.stateStartedAt) + if (richStatusOwnsCurrentState) { + pty.lastAgentStatusStartedAtEpochMs = stateStartedAt + } + const updatedAt = Math.max(status.updatedAt, titleEvidenceAt) + if (!richStatusOwnsCurrentState) { + return buildTitleOnlyStatus(status.state, updatedAt, stateStartedAt) + } + if (updatedAt === status.updatedAt && stateStartedAt === status.stateStartedAt) { + return status + } + return { ...status, updatedAt, stateStartedAt } + } + /** Mobile-friendly status entry for a PTY, aligning agentType and titles with the active owner. */ private buildPtyMobileAgentStatus( pty: RuntimePtyWorktreeRecord | null, @@ -30668,24 +31021,29 @@ export class OrcaRuntimeService { // Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970). const liveRow = retained ?? this.resolveHookLiveAgentRow(hookRow.live, pty, nonAgentTitle) if (liveRow) { - return { - agentStatus: normalizeCompatibleAgentStatusEntryForOwner( - { - ...liveRow.payload, - paneKey, - updatedAt: liveRow.updatedAt, - stateStartedAt: liveRow.stateStartedAt, - stateHistory: [], - ...(terminalHandle ? { terminalHandle } : {}), - ...((pty?.worktreeId ?? liveRow.worktreeId) - ? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId } - : {}), - tabId: tab.parentTabId, - terminalTitle, - ...providerSession - }, - ownerAgent - ) + const liveStatus = normalizeCompatibleAgentStatusEntryForOwner( + { + ...liveRow.payload, + paneKey, + updatedAt: liveRow.updatedAt, + stateStartedAt: liveRow.stateStartedAt, + stateHistory: [], + ...(terminalHandle ? { terminalHandle } : {}), + ...((pty?.worktreeId ?? liveRow.worktreeId) + ? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId } + : {}), + tabId: tab.parentTabId, + terminalTitle, + ...providerSession + }, + ownerAgent + ) + // A live question outranks only the shell title that currently obscures it. + const renewedStatus = this.renewMobileAgentStatusFromPtyTitle(liveStatus, pty, { + preserveQuestionUnderShellTitle: true + }) + if (renewedStatus) { + return { agentStatus: renewedStatus } } } // Last resort: the pane's hook evidence is identity only (resume rows, stale @@ -30706,7 +31064,7 @@ export class OrcaRuntimeService { : 'done', prompt: '', updatedAt: evidenceAt, - stateStartedAt: evidenceAt, + stateStartedAt: pty?.lastAgentStatusStartedAtEpochMs ?? evidenceAt, paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), @@ -31219,19 +31577,29 @@ export class OrcaRuntimeService { return await this.isPtyRunningAgent(pty.pty, leaf) } const { leaf } = this.getLiveLeafForHandle(handle) + const trackedPty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : null // Why: check the leaf pane title and the tab title, which already carries OSC-enriched agent indicators (e.g. ✳ prefix). const paneTitle = getLatestLeafTitle(leaf, null) const paneTitleClassification = classifyAgentTitle(paneTitle) - if (paneTitleClassification === 'agent') { + if ( + trackedPty + ? ptyTitleProvesAgentPresence(trackedPty, paneTitle, paneTitleClassification) + : agentTitleProvesAgentPresence(paneTitle, paneTitleClassification) + ) { return true } const tabTitle = this.tabs.get(leaf.tabId)?.title?.trim() || null const tabTitleClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral' - if (tabTitleClassification === 'agent') { + if ( + trackedPty + ? ptyTitleProvesAgentPresence(trackedPty, tabTitle, tabTitleClassification) + : agentTitleProvesAgentPresence(tabTitle, tabTitleClassification) + ) { return true } + const openCodeMarkerTitle = paneTitle ?? tabTitle const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - if (isKnownReadyPromptPreview(waitText)) { + if (!isOpenCodeNativeTitle(openCodeMarkerTitle) && isKnownReadyPromptPreview(waitText)) { return true } const hasCurrentTitleEvidence = paneTitle !== null || tabTitle !== null @@ -31275,7 +31643,7 @@ export class OrcaRuntimeService { ) : null const leafTitleClassification = classifyAgentTitle(leafTitle) - if (leafTitleClassification === 'agent') { + if (ptyTitleProvesAgentPresence(pty, leafTitle, leafTitleClassification)) { return true } const ptyTitle = getLatestAgentCandidateTitle( @@ -31283,15 +31651,19 @@ export class OrcaRuntimeService { { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } ) const ptyTitleClassification = classifyAgentTitle(ptyTitle) - if (leafTitle === null && ptyTitleClassification === 'agent') { + if (leafTitle === null && ptyTitleProvesAgentPresence(pty, ptyTitle, ptyTitleClassification)) { return true } const managementTitleClassification = classifyLatestAgentTitle({ title: pty.managementTitle, updatedAt: pty.managementTitleAt }) + const openCodeMarkerTitle = leafTitle ?? ptyTitle + if (isOpenCodeNativeTitle(openCodeMarkerTitle) && pty.launchAgent === 'opencode') { + return true + } const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) - if (isKnownReadyPromptPreview(waitText)) { + if (!isOpenCodeNativeTitle(openCodeMarkerTitle) && isKnownReadyPromptPreview(waitText)) { return true } // Why: stale status is a fallback only when no current title evidence exists; neutral titles (shells) clear it. @@ -32354,7 +32726,7 @@ export class OrcaRuntimeService { linearSearchForAgents(args: { query: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): ReturnType { return searchLinearIssuesForAgents(args) } @@ -32364,7 +32736,7 @@ export class OrcaRuntimeService { } async linearTeamListForAgents(params: { - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): Promise { try { const result = await listLinearTeamsForAgent(params.workspaceId) @@ -32443,7 +32815,7 @@ export class OrcaRuntimeService { async linearProjectListForAgents(params: { query?: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): Promise { const limit = clampLinearSearchLimit(params.limit) try { @@ -32482,7 +32854,7 @@ export class OrcaRuntimeService { filter?: LinearIssueListFilter teamInput?: string limit?: number - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' }): Promise { const filter = params.filter ?? 'assigned' const limit = clampLinearIssueListLimit(params.limit) @@ -34165,7 +34537,7 @@ export class OrcaRuntimeService { private async resolveLinearTeamInput( teamInput: string, - workspaceId?: string | 'all' + workspaceId?: (string & {}) | 'all' ): Promise<{ id: string key: string @@ -34728,7 +35100,9 @@ export class OrcaRuntimeService { private readonly emulatorCommands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => this.emulatorBridge, - resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + resolveEmulatorWorkspaceId: (selector) => this.resolveEmulatorWorkspaceId(selector), + resolveEmulatorCleanupWorkspaceId: (selector) => + this.resolveEmulatorCleanupWorkspaceId(selector), getAuthoritativeWindow: () => this.getAuthoritativeWindow(), getSettings: () => this.requireStore().getSettings() }) @@ -35346,269 +35720,6 @@ function withTimeoutResult( ) } -export function appendRecentPtyPathCandidates( - previous: string[] | undefined, - data: string -): string[] { - const extractedCandidates = extractTerminalOutputPathCandidates(data) - if (extractedCandidates.length === 0) { - // Why: pathless output is the hot path; reuse immutable history so each chunk doesn't clone and byte-scan up to 1,024 old candidates. - return previous ?? [] - } - const next = previous ? previous.slice() : [] - for (const candidate of extractedCandidates) { - if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { - continue - } - next.push(candidate) - } - return pruneRecentPtyPathCandidates(next) -} - -export function recentTerminalPathCandidatesIncludePath( - recentCandidates: readonly string[], - pathText: string, - absolutePath: string -): boolean { - const candidates = new Set( - [ - pathText, - absolutePath, - ...wslTerminalOutputAliases(pathText), - ...wslTerminalOutputAliases(absolutePath) - ] - .map((candidate) => candidate.trim()) - .filter((candidate) => candidate.length > 0) - ) - for (const recent of recentCandidates) { - if (candidates.has(recent)) { - return true - } - } - return false -} - -function pruneRecentPtyPathCandidates(candidates: string[]): string[] { - const countBounded = - candidates.length > RECENT_PTY_PATH_CANDIDATE_LIMIT - ? candidates.slice(-RECENT_PTY_PATH_CANDIDATE_LIMIT) - : candidates - let totalBytes = 0 - let startIndex = countBounded.length - for (let index = countBounded.length - 1; index >= 0; index -= 1) { - const nextTotal = totalBytes + Buffer.byteLength(countBounded[index]!, 'utf8') - if (nextTotal > RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES) { - break - } - totalBytes = nextTotal - startIndex = index - } - return startIndex === 0 ? countBounded : countBounded.slice(startIndex) -} - -export function recentTerminalOutputIncludesPath( - recentOutput: string, - pathText: string, - absolutePath: string -): boolean { - const candidates = new Set( - [pathText, absolutePath] - .map((candidate) => candidate.trim()) - .filter((candidate) => candidate.length > 0) - ) - if (candidates.size === 0) { - return false - } - for (const candidate of candidates) { - if (outputContainsPathCandidate(recentOutput, candidate)) { - return true - } - } - const decodedOutput = decodeTerminalOutputPercentEscapes(recentOutput) - if (decodedOutput !== recentOutput) { - for (const candidate of candidates) { - if (outputContainsPathCandidate(decodedOutput, candidate)) { - return true - } - } - } - return false -} - -function outputContainsPathCandidate(output: string, candidate: string): boolean { - let start = output.indexOf(candidate) - while (start !== -1) { - const end = start + candidate.length - if (isPathCandidateStartBoundary(output, start) && isPathCandidateEndBoundary(output, end)) { - return true - } - start = output.indexOf(candidate, start + 1) - } - return false -} - -function isPathCandidateStartBoundary(output: string, start: number): boolean { - if (start === 0) { - return true - } - if (output.slice(0, start).endsWith('file://')) { - return true - } - if ( - /^[A-Za-z]:[\\/]/.test(output.slice(start)) && - /file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)?\/$/i.test(output.slice(0, start)) - ) { - return true - } - if (/file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)$/i.test(output.slice(0, start))) { - return true - } - return !isPathCandidateContinuationChar(output[start - 1]!) -} - -function isPathCandidateEndBoundary(output: string, end: number): boolean { - const next = output[end] - if (!next) { - return true - } - if (next === ':' && /^\d+(?::\d+)?(?:\D|$)/.test(output.slice(end + 1))) { - return true - } - return !isPathCandidateContinuationChar(next) -} - -function isPathCandidateContinuationChar(char: string): boolean { - return /[A-Za-z0-9._~/%+@\\()[\]-]/.test(char) -} - -function decodeTerminalOutputPercentEscapes(value: string): string { - return value.replace(/(?:%[0-9a-f]{2})+/gi, (match) => { - try { - return decodeURIComponent(match) - } catch { - return match - } - }) -} - -// Why: the extension regex backtracks quadratically on the PTY hot path; candidates can't cross a newline, so scan per line and skip over-long lines (dropped anyway). -function extractTerminalOutputPathCandidates(data: string): string[] { - const candidates: string[] = [] - const add = (value: string): void => { - const candidate = trimTerminalOutputPathCandidate(value) - if (candidate.length > 0) { - candidates.push(candidate) - const drivePath = normalizeTerminalOutputFileUriDrivePath(candidate) - if (drivePath) { - candidates.push(drivePath) - } - } - } - for (const line of data.split(/[\r\n]+/)) { - if (line.length === 0 || line.length > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { - continue - } - collectTerminalOutputLinePathCandidates(line, add) - } - return candidates -} - -function collectTerminalOutputLinePathCandidates(line: string, add: (value: string) => void): void { - for (const match of line.matchAll(/file:\/\/([^/\s]*)(\/[^\s\x1b"'<>)]*)/gi)) { - const authority = match[1] ?? '' - const uriPath = match[2] - if (uriPath) { - const decoded = decodeTerminalOutputPercentEscapes(uriPath) - add(isTerminalOutputLoopbackAuthority(authority) ? decoded : `//${authority}${decoded}`) - } - } - for (const match of line.matchAll( - /(?:\/(?:tmp|private\/tmp)\/|[A-Za-z]:[\\/])[^\r\n\x1b"'<>]+/g - )) { - if (isInsideNonLocalFileUri(line, match.index)) { - continue - } - add(match[0]) - } - for (const match of line.matchAll( - /\/[^\r\n\x1b"'<>]*\.[A-Za-z0-9_+-]+(?:[#:\s][^\r\n\x1b"'<>]*)?/g - )) { - if (isInsideNonLocalFileUri(line, match.index)) { - continue - } - add(match[0]) - } -} - -function normalizeTerminalOutputFileUriDrivePath(candidate: string): string | null { - return /^\/[A-Za-z]:[\\/]/.test(candidate) ? candidate.slice(1) : null -} - -function trimTerminalOutputPathCandidate(value: string): string { - let candidate = value.trim().replace(/[),;.]+$/g, '') - if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { - return '' - } - let selected: string | null = null - for (const match of candidate.matchAll( - /.+?\.[A-Za-z0-9_+-]+(?:#L\d+(?:C\d+)?|(?::\d+)?(?::\d+)?)?(?=\s+|$)/gi - )) { - const end = match.index + match[0].length - const text = candidate.slice(0, end) - if (countTerminalOutputPathStarts(text) > 1) { - continue - } - // Same as the tap parsers: a line-end token extends the candidate only when the added segment is path-like, so trailing prose isn't swallowed. - if ( - end < candidate.length || - selected === null || - /[\\/]/.test(candidate.slice(selected.length, end)) - ) { - selected = text - } - } - return trimTerminalOutputPathLocator(selected ?? candidate) -} - -function isTerminalOutputLoopbackAuthority(authority: string): boolean { - const normalized = authority.toLowerCase() - return ( - normalized === '' || - normalized === 'localhost' || - normalized === '127.0.0.1' || - normalized === '::1' || - normalized === '[::1]' - ) -} - -function isInsideNonLocalFileUri(output: string, pathStart: number): boolean { - const prefix = output.slice(0, pathStart) - const match = /file:\/\/([^/\s]*)$/i.exec(prefix) - return !!match && !isTerminalOutputLoopbackAuthority(match[1] ?? '') -} - -function countTerminalOutputPathStarts(value: string): number { - let count = 0 - for (const match of value.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { - void match - count += 1 - } - return count -} - -function trimTerminalOutputPathLocator(value: string): string { - return value.replace(/#L\d+(?:C\d+)?$/i, '').replace(/:\d+(?::\d+)?$/, '') -} - -function wslTerminalOutputAliases(value: string): string[] { - const match = /^\\\\wsl(?:\.localhost|\$)\\[^\\]+(\\.*)$/i.exec(value) - if (!match) { - return [] - } - const linuxPath = match[1]!.replace(/\\/g, '/') - return linuxPath.startsWith('/') ? [linuxPath] : [`/${linuxPath}`] -} - export function buildPreview(lines: string[], partialLine: string): string { const previewLines: string[] = [] const collectVisibleLine = (line: string): void => { @@ -36336,12 +36447,12 @@ function applyTerminalLineControls(line: string): { hadControl: boolean } { const carriageIndex = line.lastIndexOf('\r') - const latestRedraw = carriageIndex >= 0 ? line.slice(carriageIndex + 1) : line + const latestRedraw = carriageIndex !== -1 ? line.slice(carriageIndex + 1) : line if (!latestRedraw.includes('\u0008') && !latestRedraw.includes('\u001b')) { return { text: latestRedraw, cursorColumn: latestRedraw.length, - hadControl: carriageIndex >= 0 + hadControl: carriageIndex !== -1 } } @@ -36798,6 +36909,8 @@ function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { // Why: launch titles like "Codex YOLO" contain an agent name but aren't readiness signals; terminal.wait needs explicit idle evidence. if ( EXPLICIT_IDLE_TITLE_RE.test(title) || + // Why: unblock hookless remote waits; guarded writes corroborate this marker. + isOpenCodeNativeTitle(title) || title.startsWith(CLAUDE_IDLE_PREFIX) || title.startsWith('* ') || title.includes(GEMINI_IDLE_PREFIX) || @@ -36935,7 +37048,7 @@ function isTerminalWaitWhitespace(value: string, index: number): boolean { } const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i + /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always/i function findTerminalWaitBlockedSignal( normalized: string @@ -37005,6 +37118,20 @@ function findTerminalWaitBlockedSignal( candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) } } + const permissionPromptIndex = Math.max( + normalized.lastIndexOf('permission required'), + normalized.lastIndexOf('requires permission') + ) + if (permissionPromptIndex !== -1) { + const permissionSegment = normalized.slice(permissionPromptIndex, permissionPromptIndex + 1_500) + const decisionCount = ['allow once', 'allow always', 'reject', 'deny'].filter((choice) => + permissionSegment.includes(choice) + ).length + if (decisionCount >= 2) { + // Why: preserve the existing remote receipt value for mixed-version clients. + candidates.push({ reason: 'codex-interactive-prompt', index: permissionPromptIndex }) + } + } return candidates.length > 0 ? candidates.reduce((latest, candidate) => candidate.index > latest.index ? candidate : latest @@ -37112,6 +37239,42 @@ function runtimeWorktreeIdentityKey(worktreeId: string): string { : worktreeId } +function runtimeWorktreeLookupKey(worktreeId: string): string { + const parsed = splitWorktreeId(worktreeId) + return JSON.stringify( + parsed + ? ['parsed', parsed.repoId, normalizeRuntimePathForComparison(parsed.worktreePath)] + : ['raw', worktreeId] + ) +} + +function createIncrementalResolvedWorktreeLookup( + resolvedWorktrees: ResolvedWorktree[] +): (worktreeId: string) => ResolvedWorktree | undefined { + const worktreeByIdentity = new Map() + let indexedCount = 0 + return (worktreeId) => { + const lookupKey = runtimeWorktreeLookupKey(worktreeId) + const indexed = worktreeByIdentity.get(lookupKey) + if (indexed) { + return indexed + } + while (indexedCount < resolvedWorktrees.length) { + const worktree = resolvedWorktrees[indexedCount] + indexedCount += 1 + const key = runtimeWorktreeLookupKey(worktree.id) + // Why: preserve Array.find's first match when normalized identities collide. + if (!worktreeByIdentity.has(key)) { + worktreeByIdentity.set(key, worktree) + } + if (key === lookupKey) { + return worktreeByIdentity.get(key) + } + } + return undefined + } +} + function resolveTerminalSessionWorktreeId( session: WorkspaceSessionState, targetWorktreeId: string @@ -37450,6 +37613,33 @@ function getLatestLeafTitle(leaf: RuntimeLeafRecord, tabTitle: string | null): s ) } +// Why: an 'agent' title only proves an agent owns the pane when something other than a +// quarter-circle spinner carries it — those glyphs are generic progress frames (STA-4028). +function agentTitleProvesAgentPresence( + title: string | null, + classification: 'agent' | 'management' | 'neutral' +): boolean { + return ( + classification === 'agent' && + !isOpenCodeNativeTitle(title) && + !isQuarterCircleSpinnerOnlyAgentTitle(title) + ) +} + +function ptyTitleProvesAgentPresence( + pty: RuntimePtyWorktreeRecord, + title: string | null, + classification: 'agent' | 'management' | 'neutral' +): boolean { + return ( + agentTitleProvesAgentPresence(title, classification) || + (isQuarterCircleSpinnerOnlyAgentTitle(title) && + pty.launchAgent === 'claude' && + pty.launchToken !== null && + pty.launchIncarnationId === pty.incarnationId) + ) +} + function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neutral' { if (!title) { return 'neutral' diff --git a/src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts b/src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts new file mode 100644 index 00000000000..9a08ed95662 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Coordinator, DISPATCH_STALE_THRESHOLD, type CoordinatorRuntime } from './coordinator' +import { OrchestrationDb } from './db' + +describe('Coordinator drift probe coalescing', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('shares one drift snapshot across a tick and probes again on the next tick', async () => { + db = new OrchestrationDb(':memory:') + const sentMessages: { handle: string; text: string }[] = [] + const probeDriftCalls: string[] = [] + const terminals = [ + { handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }, + { handle: 'term_b', worktreeId: 'wt1', connected: true, writable: true } + ] + const runtime: CoordinatorRuntime = { + async sendTerminalAgentPrompt(handle, prompt) { + sentMessages.push({ handle, text: prompt }) + return { accepted: true } + }, + async listTerminals() { + return { terminals } + }, + async createTerminal() { + throw new Error('unexpected terminal create') + }, + async waitForTerminal(handle) { + return { handle, condition: 'exit' } + }, + async probeWorktreeDrift(worktreeSelector) { + probeDriftCalls.push(worktreeSelector) + return probeDriftCalls.length === 1 + ? { + base: 'origin/main', + behind: DISPATCH_STALE_THRESHOLD + 1, + recentSubjects: ['stale'] + } + : { base: 'origin/main', behind: 0, recentSubjects: [] } + } + } + const first = db.createTask({ spec: 'first task' }) + const second = db.createTask({ spec: 'second task' }) + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 30, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await vi.waitFor(() => expect(sentMessages).toHaveLength(2)) + + expect(probeDriftCalls).toEqual(['wt1', 'wt1']) + expect(sentMessages.map((message) => message.handle).sort()).toEqual(['term_a', 'term_b']) + + for (const task of [first, second]) { + const dispatch = db.getDispatchContext(task.id) + if (!dispatch?.assignee_handle) { + throw new Error(`missing dispatch for ${task.id}`) + } + db.insertMessage({ + from: dispatch.assignee_handle, + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }) + }) + } + + await expect(runPromise).resolves.toMatchObject({ status: 'completed' }) + }) + + it('reuses capacity after refusing a stale-base task', async () => { + db = new OrchestrationDb(':memory:') + const sentMessages: { handle: string; text: string }[] = [] + const probeDriftCalls: string[] = [] + const runtime: CoordinatorRuntime = { + async sendTerminalAgentPrompt(handle, prompt) { + sentMessages.push({ handle, text: prompt }) + return { accepted: true } + }, + async listTerminals() { + return { + terminals: [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + } + }, + async createTerminal() { + throw new Error('unexpected terminal create') + }, + async waitForTerminal(handle) { + return { handle, condition: 'exit' } + }, + async probeWorktreeDrift(worktreeSelector) { + probeDriftCalls.push(worktreeSelector) + return { + base: 'origin/main', + behind: DISPATCH_STALE_THRESHOLD + 1, + recentSubjects: ['stale'] + } + } + } + const refused = db.createTask({ spec: 'requires a current base' }) + const allowed = db.createTask({ spec: 'can use stale base\nallow-stale-base: true' }) + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + maxConcurrent: 1, + pollIntervalMs: 30, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await vi.waitFor(() => expect(sentMessages).toHaveLength(1)) + + expect(probeDriftCalls).toEqual(['wt1']) + expect(db.getTask(refused.id)?.status).toBe('ready') + expect(db.getTask(allowed.id)?.status).toBe('dispatched') + expect(sentMessages[0]).toMatchObject({ handle: 'term_a' }) + expect(sentMessages[0].text).toContain('can use stale base') + expect(sentMessages[0].text).not.toContain('allow-stale-base: true') + + coordinator.stop() + await expect(runPromise).resolves.toMatchObject({ status: 'failed' }) + }) +}) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index cd4fbd29f43..61e97024fc1 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -4,6 +4,14 @@ import type { MessageRow, TaskRow, CoordinatorStatus } from './types' import { buildDispatchPreamble } from './preamble' import { reconcileLifecycleMessage } from './lifecycle-reconciliation' +type WorktreeDrift = { + base: string + behind: number + recentSubjects: string[] +} | null + +type TaskDispatchResult = 'dispatched' | 'stale-base-refused' + export type CoordinatorRuntime = { sendTerminalAgentPrompt(handle: string, prompt: string): Promise listTerminals( @@ -22,11 +30,7 @@ export type CoordinatorRuntime = { options?: { condition?: string; timeoutMs?: number } ): Promise<{ handle: string; condition: string }> // Why (§3.1): lives on the runtime because it must resolve a worktree, load the repo, and fetch — the coordinator only knows handles + specs. - probeWorktreeDrift(worktreeSelector: string): Promise<{ - base: string - behind: number - recentSubjects: string[] - } | null> + probeWorktreeDrift(worktreeSelector: string): Promise // Why: pane-only fallback preserves reservation identity for lightweight runtime fakes. getTerminalPaneKey?(handle: string): string | null // Why: automatic dispatch persists the same authenticated pane/process tuple as manual dispatch. @@ -379,6 +383,14 @@ export class Coordinator { } } + // Why: every task in one tick dispatches from the same fetched base snapshot. + const baseDrift = this.opts.worktree + ? await this.runtime.probeWorktreeDrift(this.opts.worktree).catch((err) => { + this.opts.onLog(`probeWorktreeDrift failed for ${this.opts.worktree}: ${err}`) + return null + }) + : null + for (const task of readyTasks) { if (slotsAvailable <= 0 || terminals.length === 0) { break @@ -388,42 +400,38 @@ export class Coordinator { slotsAvailable-- try { - await this.dispatchTask(task, targetHandle) + const result = await this.dispatchTask(task, targetHandle, baseDrift) + if (result === 'stale-base-refused') { + terminals.unshift(targetHandle) + slotsAvailable++ + } } catch (err) { this.opts.onLog(`Failed to dispatch task ${task.id}: ${String(err)}`) } } } - private async dispatchTask(task: TaskRow, targetHandle: string): Promise { + private async dispatchTask( + task: TaskRow, + targetHandle: string, + baseDrift: WorktreeDrift + ): Promise { // Why (§3.1): drift check runs before createDispatchContext so a refusal doesn't bump failure_count (carried forward as MAX in db.ts:301-306) and burn the circuit-breaker budget; the task stays `ready` and retries next tick. const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(task.spec) - let baseDrift: { - base: string - behind: number - recentSubjects: string[] - } | null = null if (!this.opts.worktree) { // Why (§7.4): worktree is optional; with none we can't probe drift, so log that the guard is inert and proceed. this.opts.onLog(`stale-base guard inert for ${task.id}: coordinator has no worktree selector`) - } else { - baseDrift = await this.runtime.probeWorktreeDrift(this.opts.worktree).catch((err) => { - this.opts.onLog(`probeWorktreeDrift failed for ${this.opts.worktree}: ${err}`) - return null - }) - - if (baseDrift && baseDrift.behind > DISPATCH_STALE_THRESHOLD && !allowStale) { - // Why (§3.1): silent-return, not failDispatch — failing a recoverable stale-base here would burn the circuit-breaker budget. - this.opts.onLog( - `Skipping dispatch of ${task.id}: worktree is ${baseDrift.behind} commits ` + - `behind ${baseDrift.base}. Pull/rebase the worktree, recreate it with ` + - `--base-branch ${baseDrift.base}, or include 'allow-stale-base: true' ` + - `in the task spec to override. Task remains in 'ready'; coordinator ` + - `will retry on the next tick.` - ) - return - } + } else if (baseDrift && baseDrift.behind > DISPATCH_STALE_THRESHOLD && !allowStale) { + // Why (§3.1): silent-return, not failDispatch — failing a recoverable stale-base here would burn the circuit-breaker budget. + this.opts.onLog( + `Skipping dispatch of ${task.id}: worktree is ${baseDrift.behind} commits ` + + `behind ${baseDrift.base}. Pull/rebase the worktree, recreate it with ` + + `--base-branch ${baseDrift.base}, or include 'allow-stale-base: true' ` + + `in the task spec to override. Task remains in 'ready'; coordinator ` + + `will retry on the next tick.` + ) + return 'stale-base-refused' } const dispatchAuthority = this.runtime.getOrchestrationDispatchAuthority?.(targetHandle) @@ -480,6 +488,7 @@ export class Coordinator { this.opts.onLog(`Dispatched task ${task.id} to ${targetHandle}`) this.state.phase = 'monitoring' + return 'dispatched' } private async getAvailableTerminals(): Promise { diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 7049d59469d..ec279aef763 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -44,6 +44,7 @@ import { OrchestrationError } from './orchestration-error' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' import { deriveWorkerTerminalListState, + WORKER_SETTLED_STATES, type WorkerTerminalResourceRow, type WorkerTerminalArchiveRow, type WorkerTerminalArchiveStatus, @@ -57,6 +58,10 @@ import { releaseContextOnlyDispatch, type ContextOnlyDispatchReleaseResult } from './context-only-dispatch-release' +import { + ensureMutationReceiptCapacity, + migrateMutationReceiptCapacity +} from './mutation-receipt-capacity' // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. function isEquivalentPaneKey(a: string, b: string): boolean { @@ -68,6 +73,17 @@ function isEquivalentPaneKey(a: string, b: string): boolean { return Boolean(aLeaf && bLeaf && aLeaf === bLeaf) } +function parseWorkerTerminalPriorOwnerIds(value: string): string[] | null { + try { + const parsed: unknown = JSON.parse(value) + return Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string') + ? parsed + : null + } catch { + return null + } +} + // Why: indexable pre-filter for isEquivalentPaneKey — equal strings and equal leaves both share the // text after the first ':', so this narrows candidates without deciding equivalence itself. const RUN_PANE_KEY_MATCH_SUFFIX_SQL = @@ -77,7 +93,7 @@ const DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL = function paneKeyMatchSuffix(paneKey: string): string { const colon = paneKey.indexOf(':') - return colon < 0 ? paneKey : paneKey.slice(colon + 1) + return colon === -1 ? paneKey : paneKey.slice(colon + 1) } export type { @@ -261,9 +277,6 @@ export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION -const MUTATION_RECEIPT_MAX_ROWS = 10_000 -const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 - export type RunListPage = { runs: RunRow[] nextCursor: string | null @@ -281,10 +294,10 @@ type RunListCursor = { id: string } -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup. -const SCHEMA_VERSION = 25 +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity. +const SCHEMA_VERSION = 26 -function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { +function hardenOrchestrationDatabaseFiles(dbPath: (string & {}) | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { // Why: Windows protects these files through Orca's current-user-only userData DACL; POSIX mode bits are inert there. return @@ -307,7 +320,7 @@ export class OrchestrationDb { // per-terminal fan-out. Only createDispatchContext flips this false→true. private hasAnyDispatchContextsCache: boolean | undefined - constructor(dbPath: string | ':memory:') { + constructor(dbPath: (string & {}) | ':memory:') { this.db = new Database(dbPath) this.db.pragma('journal_mode = WAL') this.db.pragma('synchronous = NORMAL') @@ -983,6 +996,9 @@ export class OrchestrationDb { WHERE assignee_handle IS NOT NULL AND status IN ('pending', 'dispatched'); `) } + if (current < 26) { + migrateMutationReceiptCapacity(this.db) + } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL}) @@ -1398,44 +1414,6 @@ export class OrchestrationDb { // ── Durable mutation receipts ── - private ensureMutationReceiptCapacity(): void { - this.db - .prepare( - `DELETE FROM mutation_receipts - WHERE state = 'completed' - AND updated_at < datetime('now', ?)` - ) - .run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) - - const row = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { - count: number - } - const completedToRemove = row.count - MUTATION_RECEIPT_MAX_ROWS + 1 - if (completedToRemove > 0) { - this.db - .prepare( - `DELETE FROM mutation_receipts - WHERE rowid IN ( - SELECT rowid FROM mutation_receipts - WHERE state = 'completed' - ORDER BY updated_at ASC, rowid ASC - LIMIT ? - )` - ) - .run(completedToRemove) - } - - const retained = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { - count: number - } - if (retained.count >= MUTATION_RECEIPT_MAX_ROWS) { - throw new OrchestrationError( - 'mutation_ledger_full', - 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' - ) - } - } - beginMutationReceipt(params: { callerFingerprint: string requestId: string @@ -1458,7 +1436,7 @@ export class OrchestrationDb { this.db.exec('COMMIT') return { disposition: existing.state, row: existing } } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( @@ -3885,7 +3863,7 @@ export class OrchestrationDb { return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[] } - // Why: LEFT JOIN keeps non-dispatched tasks (NULL assignee); the MAX(rowid) subquery matches getDispatchContext's most-recent-active-dispatch semantics. + // Why: the correlated indexed lookup avoids materializing every retained Dispatch before filtering Tasks. listTasksWithDispatch(filter?: { status?: TaskStatus ready?: boolean @@ -3913,16 +3891,14 @@ export class OrchestrationDb { d.assignee_handle AS assignee_handle, d.id AS dispatch_id FROM tasks t - LEFT JOIN ( - SELECT dc.* - FROM dispatch_contexts dc - INNER JOIN ( - SELECT task_id, MAX(rowid) AS max_rowid - FROM dispatch_contexts - WHERE status IN ('pending', 'dispatched') - GROUP BY task_id - ) latest ON latest.task_id = dc.task_id AND latest.max_rowid = dc.rowid - ) d ON d.task_id = t.id + LEFT JOIN dispatch_contexts d ON d.rowid = ( + SELECT candidate.rowid + FROM dispatch_contexts candidate + WHERE candidate.task_id = t.id + AND candidate.status IN ('pending', 'dispatched') + ORDER BY candidate.rowid DESC + LIMIT 1 + ) ${where} ORDER BY t.created_at ` @@ -4009,7 +3985,7 @@ export class OrchestrationDb { `Mutation ${receipt.requestId} already has a durable acceptance record.` ) } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( @@ -4642,7 +4618,7 @@ export class OrchestrationDb { `Remote attachment request ${params.mutationReceipt.requestId} already exists.` ) } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( @@ -5130,13 +5106,44 @@ export class OrchestrationDb { dispatchId: string direction: FederationRelayDirection throughSequence: number + settleRemoteReport?: { sequence: number; outcome: WorkerReportOutcome } }): void { - this.db - .prepare( - `UPDATE federation_relay_items SET acked_at = COALESCE(acked_at, datetime('now')) - WHERE dispatch_id = ? AND direction = ? AND sequence <= ?` - ) - .run(params.dispatchId, params.direction, params.throughSequence) + this.db.exec('BEGIN IMMEDIATE') + try { + if (params.settleRemoteReport) { + const report = this.getFederationRelayItem( + params.dispatchId, + params.direction, + params.settleRemoteReport.sequence + ) + if ( + params.direction !== 'to_home' || + params.settleRemoteReport.sequence > params.throughSequence || + report?.kind !== 'worker_done' || + parseFederatedWorkerReportOutcome(report.payload) !== params.settleRemoteReport.outcome + ) { + throw new OrchestrationError( + 'request_mismatch', + `Federation acknowledgment for ${params.dispatchId} does not match its queued worker_done.` + ) + } + this.settleRemoteAttachmentInRelayTransaction( + params.dispatchId, + params.settleRemoteReport.outcome, + 'worker_report_settled' + ) + } + this.db + .prepare( + `UPDATE federation_relay_items SET acked_at = COALESCE(acked_at, datetime('now')) + WHERE dispatch_id = ? AND direction = ? AND sequence <= ?` + ) + .run(params.dispatchId, params.direction, params.throughSequence) + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } } setFederatedHomeImportSequence(dispatchId: string, sequence: number): void { @@ -5174,7 +5181,11 @@ export class OrchestrationDb { result: string } | { kind: 'rejected'; code: string; reason: string } - }): { message: MessageRow; duplicate: boolean } { + }): { + message: MessageRow + duplicate: boolean + lifecycle?: WorkerReportSettlement | { action: 'rejected'; code: string; reason: string } + } { this.db.exec('BEGIN IMMEDIATE') try { const federated = this.getFederatedDispatch(params.dispatchId) @@ -5184,18 +5195,14 @@ export class OrchestrationDb { `Federated Dispatch ${params.dispatchId} was not found.` ) } - if (params.sequence <= federated.to_home_imported_sequence) { - const existing = this.getMessageById(params.message.id) - if (!existing) { - throw new OrchestrationError( - 'operation_unknown', - `Federated relay sequence ${params.sequence} was committed without its message.` - ) - } - this.db.exec('COMMIT') - return { message: existing, duplicate: true } + const duplicate = params.sequence <= federated.to_home_imported_sequence + if (duplicate && !this.getMessageById(params.message.id)) { + throw new OrchestrationError( + 'operation_unknown', + `Federated relay sequence ${params.sequence} was committed without its message.` + ) } - if (params.sequence !== federated.to_home_imported_sequence + 1) { + if (!duplicate && params.sequence !== federated.to_home_imported_sequence + 1) { throw new OrchestrationError( 'operation_unknown', `Federated relay for ${params.dispatchId} is not contiguous after sequence ${federated.to_home_imported_sequence}.` @@ -5222,32 +5229,45 @@ export class OrchestrationDb { dispatchId: params.dispatchId }) } - if (params.lifecycle.kind === 'heartbeat') { + let lifecycle: + | WorkerReportSettlement + | { action: 'rejected'; code: string; reason: string } + | undefined + if (params.lifecycle.kind === 'heartbeat' && !duplicate) { this.recordHeartbeat(params.dispatchId, params.lifecycle.at) } else if (params.lifecycle.kind === 'worker_report') { - const settlement = this.settleWorkerReportInTransaction({ + lifecycle = this.settleWorkerReportInTransaction({ taskId: params.lifecycle.taskId, dispatchId: params.dispatchId, outcome: params.lifecycle.outcome, result: params.lifecycle.result }) - if (settlement.action === 'rejected') { + if (lifecycle.action === 'rejected' && !duplicate) { message = this.convertLifecycleMessageToRejection( message.id, - settlement.code, - settlement.reason + lifecycle.code, + lifecycle.reason ) as MessageRow } } else if (params.lifecycle.kind === 'rejected') { - message = this.convertLifecycleMessageToRejection( - message.id, - params.lifecycle.code, - params.lifecycle.reason - ) as MessageRow + lifecycle = { + action: 'rejected', + code: params.lifecycle.code, + reason: params.lifecycle.reason + } + if (!duplicate) { + message = this.convertLifecycleMessageToRejection( + message.id, + params.lifecycle.code, + params.lifecycle.reason + ) as MessageRow + } + } + if (!duplicate) { + this.setFederatedHomeImportSequence(params.dispatchId, params.sequence) } - this.setFederatedHomeImportSequence(params.dispatchId, params.sequence) this.db.exec('COMMIT') - return { message, duplicate: false } + return { message, duplicate, ...(lifecycle ? { lifecycle } : {}) } } catch (error) { this.db.exec('ROLLBACK') throw error @@ -5348,19 +5368,37 @@ export class OrchestrationDb { private settleRemoteAttachmentInRelayTransaction( dispatchId: string, - outcome: WorkerReportOutcome | undefined + outcome: WorkerReportOutcome | undefined, + stage = 'worker_report_queued' ): void { if (!outcome) { return } + const attachment = this.getRemoteDispatchAttachment(dispatchId) + const state = outcome === 'succeeded' ? 'succeeded' : 'failed' + if (!attachment) { + throw new OrchestrationError( + 'dispatch_not_found', + `Remote Dispatch ${dispatchId} was not found.` + ) + } + if (attachment.state === state) { + return + } + if (attachment.state !== 'ready') { + throw new OrchestrationError( + 'request_mismatch', + `Remote Dispatch ${dispatchId} cannot settle as ${state} from ${attachment.state}.` + ) + } this.db .prepare( `UPDATE remote_dispatch_attachments - SET state = ?, stage = 'worker_report_queued', capability_hash = NULL, + SET state = ?, stage = ?, capability_hash = NULL, updated_at = datetime('now') WHERE dispatch_id = ? AND state = 'ready'` ) - .run(outcome === 'succeeded' ? 'succeeded' : 'failed', dispatchId) + .run(state, stage, dispatchId) } isDispatchProcessCurrent(params: { @@ -5852,12 +5890,12 @@ export class OrchestrationDb { if (!worker) { throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`) } - if (!['succeeded', 'failed'].includes(worker.state)) { + if (!WORKER_SETTLED_STATES.includes(worker.state)) { // Why: release is post-completion cleanup only; recording intent for an unsettled or // uncertain worker would let recovery close a terminal the coordinator never reviewed. throw new OrchestrationError( 'dispatch_inactive', - `Dispatch ${dispatchId} is ${worker.state}; only a succeeded or failed worker can release. Use worker-stop to cancel an active worker.` + `Dispatch ${dispatchId} is ${worker.state}; only a settled worker can release. Use worker-stop to cancel an active worker.` ) } const resource = this.getWorkerTerminalResourceByOwner(dispatchId) @@ -5872,6 +5910,10 @@ export class OrchestrationDb { this.db.exec('COMMIT') return { disposition: 'already_released', resource } } + if (worker.state === 'stopped' || worker.state === 'abandoned') { + this.db.exec('COMMIT') + return { disposition: 'retained', resource, reason: 'identity_unproven' } + } if (resource.ownership_state === 'external') { this.db.exec('COMMIT') return { @@ -5920,6 +5962,66 @@ export class OrchestrationDb { } } + settleDeadWorkerTerminalRelease(params: { + requestingDispatchId: string + resourceId: string + processIncarnation: string + }): + | { disposition: 'released'; resource: WorkerTerminalResourceRow } + | { disposition: 'retained'; resource: WorkerTerminalResourceRow } { + this.db.exec('BEGIN IMMEDIATE') + try { + const resource = this.getWorkerTerminalResource(params.resourceId) + if (!resource) { + throw new OrchestrationError( + 'dispatch_not_found', + `Worker terminal resource ${params.resourceId} was not found.` + ) + } + const priorOwners = parseWorkerTerminalPriorOwnerIds(resource.prior_owner_dispatch_ids) + const requesterRelated = + resource.owner_dispatch_id === params.requestingDispatchId || + priorOwners?.includes(params.requestingDispatchId) === true + const requester = this.getWorkerDispatch(params.requestingDispatchId) + const owner = this.getWorkerDispatch(resource.owner_dispatch_id) + const requesterSettled = Boolean(requester && WORKER_SETTLED_STATES.includes(requester.state)) + const ownerSettled = Boolean(owner && WORKER_SETTLED_STATES.includes(owner.state)) + if ( + !priorOwners || + !requesterRelated || + !requesterSettled || + !ownerSettled || + resource.process_incarnation !== params.processIncarnation || + resource.ownership_state === 'released' || + !['not_requested', 'retained', 'unknown'].includes(resource.release_state) + ) { + this.db.exec('COMMIT') + return { disposition: 'retained', resource } + } + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'released', ownership_state = 'released', retained_reason = NULL, + release_requested_at = COALESCE(release_requested_at, datetime('now')), + release_completed_at = datetime('now'), release_error = NULL, + updated_at = datetime('now') + WHERE id = ? AND process_incarnation = ? AND ownership_state != 'released' + AND release_state IN ('not_requested', 'retained', 'unknown')` + ) + .run(params.resourceId, params.processIncarnation) + const released = this.getWorkerTerminalResource( + params.resourceId + ) as WorkerTerminalResourceRow + this.db.exec('COMMIT') + return released.release_state === 'released' + ? { disposition: 'released', resource: released } + : { disposition: 'retained', resource: released } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + storeWorkerTerminalArchive(params: { dispatchId: string resourceId: string @@ -6838,3 +6940,18 @@ export class OrchestrationDb { this.db.close() } } + +function parseFederatedWorkerReportOutcome(payload: string): WorkerReportOutcome | undefined { + try { + const message = JSON.parse(payload) as { payload?: unknown } + if (typeof message.payload !== 'string') { + return undefined + } + const report = JSON.parse(message.payload) as { outcome?: unknown } + return report.outcome === 'succeeded' || report.outcome === 'failed' + ? report.outcome + : undefined + } catch { + return undefined + } +} diff --git a/src/main/runtime/orchestration/federation-ack-checkpoints.ts b/src/main/runtime/orchestration/federation-ack-checkpoints.ts new file mode 100644 index 00000000000..2b488c1b7a5 --- /dev/null +++ b/src/main/runtime/orchestration/federation-ack-checkpoints.ts @@ -0,0 +1,71 @@ +import type { OrcaRuntimeService } from '../orca-runtime' + +export type FederationAckIdentity = { + environmentId: string + peerFingerprint: string + remoteRuntimeEpoch: string +} + +type FederationAckCheckpoint = FederationAckIdentity & { throughSequence: number } +type FederationAckDispatchState = { checkpoint: FederationAckCheckpoint | null } +type FederationAckRuntimeState = { byDispatch: Map } + +export type FederationAckLease = { + runtimeState: FederationAckRuntimeState + dispatchState: FederationAckDispatchState +} + +const federationAckStates = new WeakMap() + +export function clearFederationAckCheckpoints(runtime: OrcaRuntimeService): void { + federationAckStates.delete(runtime) +} + +export function acquireFederationAckLease( + runtime: OrcaRuntimeService, + dispatchId: string +): FederationAckLease { + let runtimeState = federationAckStates.get(runtime) + if (!runtimeState) { + runtimeState = { byDispatch: new Map() } + federationAckStates.set(runtime, runtimeState) + } + let dispatchState = runtimeState.byDispatch.get(dispatchId) + if (!dispatchState) { + dispatchState = { checkpoint: null } + runtimeState.byDispatch.set(dispatchId, dispatchState) + } + return { runtimeState, dispatchState } +} + +export function getFederationAckedThrough( + lease: FederationAckLease, + identity: FederationAckIdentity +): number { + const checkpoint = lease.dispatchState.checkpoint + return checkpoint?.environmentId === identity.environmentId && + checkpoint.peerFingerprint === identity.peerFingerprint && + checkpoint.remoteRuntimeEpoch === identity.remoteRuntimeEpoch + ? checkpoint.throughSequence + : 0 +} + +export function recordFederationAckCheckpoint( + runtime: OrcaRuntimeService, + lease: FederationAckLease, + checkpoint: FederationAckCheckpoint +): void { + if (federationAckStates.get(runtime) !== lease.runtimeState) { + return + } + const current = lease.dispatchState.checkpoint + if ( + current?.environmentId === checkpoint.environmentId && + current.peerFingerprint === checkpoint.peerFingerprint && + current.remoteRuntimeEpoch === checkpoint.remoteRuntimeEpoch && + current.throughSequence >= checkpoint.throughSequence + ) { + return + } + lease.dispatchState.checkpoint = checkpoint +} diff --git a/src/main/runtime/orchestration/federation-lifecycle-settlement.ts b/src/main/runtime/orchestration/federation-lifecycle-settlement.ts new file mode 100644 index 00000000000..921db91b728 --- /dev/null +++ b/src/main/runtime/orchestration/federation-lifecycle-settlement.ts @@ -0,0 +1,70 @@ +import type { OrcaRuntimeService } from '../orca-runtime' + +export type FederatedLifecycleSettlement = + | { action: 'completed' | 'failed'; authority: 'run_home' } + | { action: 'rejected'; code: string; reason: string; authority: 'run_home' } + +type Waiter = (settlement: FederatedLifecycleSettlement) => void + +const waitersByRuntime = new WeakMap>>() + +export function publishFederatedLifecycleSettlement( + runtime: OrcaRuntimeService, + dispatchId: string, + sequence: number, + settlement: FederatedLifecycleSettlement +): void { + const waiters = waitersByRuntime.get(runtime)?.get(settlementKey(dispatchId, sequence)) + for (const waiter of waiters ?? []) { + waiter(settlement) + } +} + +export function waitForFederatedLifecycleSettlement( + runtime: OrcaRuntimeService, + dispatchId: string, + sequence: number, + options: { timeoutMs: number; signal?: AbortSignal } +): Promise { + return new Promise((resolve) => { + if (options.signal?.aborted) { + resolve(undefined) + return + } + const runtimeWaiters = getRuntimeWaiters(runtime) + const key = settlementKey(dispatchId, sequence) + const waiters = runtimeWaiters.get(key) ?? new Set() + runtimeWaiters.set(key, waiters) + let timer: ReturnType | undefined + const finish = (settlement?: FederatedLifecycleSettlement) => { + if (timer) { + clearTimeout(timer) + } + options.signal?.removeEventListener('abort', onAbort) + waiters.delete(onSettlement) + if (waiters.size === 0) { + runtimeWaiters.delete(key) + } + resolve(settlement) + } + const onSettlement: Waiter = (settlement) => finish(settlement) + const onAbort = () => finish() + waiters.add(onSettlement) + options.signal?.addEventListener('abort', onAbort, { once: true }) + timer = setTimeout(() => finish(), options.timeoutMs) + }) +} + +function getRuntimeWaiters(runtime: OrcaRuntimeService): Map> { + const existing = waitersByRuntime.get(runtime) + if (existing) { + return existing + } + const created = new Map>() + waitersByRuntime.set(runtime, created) + return created +} + +function settlementKey(dispatchId: string, sequence: number): string { + return `${dispatchId}:${sequence}` +} diff --git a/src/main/runtime/orchestration/federation-sync.test.ts b/src/main/runtime/orchestration/federation-sync.test.ts index 339b1a55f27..5470fe6416d 100644 --- a/src/main/runtime/orchestration/federation-sync.test.ts +++ b/src/main/runtime/orchestration/federation-sync.test.ts @@ -1,6 +1,92 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import { + acquireFederationAckLease, + clearFederationAckCheckpoints, + getFederationAckedThrough, + recordFederationAckCheckpoint, + type FederationAckIdentity +} from './federation-ack-checkpoints' import { parseRelayedMessage } from './federation-sync' +function createIdleSyncHarness() { + let remoteRuntimeEpoch = 'remote_epoch_1' + let blockedAck: { reached: () => void; released: Promise } | null = null + let blockedPull: { reached: () => void; released: Promise } | null = null + const federated = { + environment_id: 'environment_windows', + environment_name: 'windows', + peer_fingerprint: 'windows_peer_fingerprint', + to_home_imported_sequence: 2 + } + const createDb = () => + ({ + getFederatedDispatch: () => federated, + getDispatchContextById: () => ({ run_id: 'run_home', task_id: 'task_home' }), + getWorkerDispatch: () => ({ state: 'ready' }), + listPendingFederationRelay: () => [] + }) as never + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(createDb()) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + peerFingerprint: federated.peer_fingerprint + } as never) + const remoteCall = vi + .spyOn(runtime, 'callOrchestrationWorkerServer') + .mockImplementation(async (_environmentId, method) => { + if (method === 'orchestration.federationPull') { + const gate = blockedPull + if (gate) { + gate.reached() + await gate.released + if (blockedPull === gate) { + blockedPull = null + } + } + return { runtimeEpoch: remoteRuntimeEpoch, items: [] } + } + if (method === 'orchestration.federationAck') { + const gate = blockedAck + if (gate) { + gate.reached() + await gate.released + if (blockedAck === gate) { + blockedAck = null + } + } + return { acknowledgedThrough: federated.to_home_imported_sequence } + } + throw new Error(`Unexpected method ${method}`) + }) + return { + runtime, + remoteCall, + advanceCursor: () => { + federated.to_home_imported_sequence += 1 + }, + restartRemote: () => { + remoteRuntimeEpoch = 'remote_epoch_2' + }, + replaceDb: () => runtime.setOrchestrationDb(createDb()), + blockAck: () => { + let noteReached!: () => void + let release!: () => void + const reached = new Promise((resolve) => (noteReached = resolve)) + const released = new Promise((resolve) => (release = resolve)) + blockedAck = { reached: noteReached, released } + return { reached, release } + }, + blockPull: () => { + let noteReached!: () => void + let release!: () => void + const reached = new Promise((resolve) => (noteReached = resolve)) + const released = new Promise((resolve) => (release = resolve)) + blockedPull = { reached: noteReached, released } + return { reached, release } + } + } +} + describe('federation relay parsing', () => { it('accepts a supported message type', () => { expect( @@ -16,3 +102,160 @@ describe('federation relay parsing', () => { ).toThrowError('Federated relay message type invented is not supported.') }) }) + +describe('federation relay acknowledgments', () => { + it('acknowledges only new progress until remote runtime identity changes', async () => { + const { runtime, remoteCall, advanceCursor, restartRemote } = createIdleSyncHarness() + const ackCalls = () => + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(ackCalls()).toHaveLength(1) + + advanceCursor() + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(ackCalls()).toHaveLength(2) + + restartRemote() + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(ackCalls().map(([, , params]) => params)).toEqual([ + { dispatchId: 'dispatch_remote', throughSequence: 2 }, + { dispatchId: 'dispatch_remote', throughSequence: 3 }, + { dispatchId: 'dispatch_remote', throughSequence: 3 } + ]) + }) + + it('coalesces overlapping syncs while an acknowledgment is in flight', async () => { + const { runtime, remoteCall, blockAck } = createIdleSyncHarness() + const gate = blockAck() + + const first = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await gate.reached + const second = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(second).toBe(first) + gate.release() + await Promise.all([first, second]) + + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(1) + }) + + it('pulls again after a sync that predates a terminal observation', async () => { + const { runtime, remoteCall, blockPull } = createIdleSyncHarness() + const gate = blockPull() + + const oldSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await gate.reached + const freshSync = runtime.syncOrchestrationFederatedDispatchAfterCurrent('dispatch_remote') + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationPull') + ).toHaveLength(1) + gate.release() + await Promise.all([oldSync, freshSync]) + + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationPull') + ).toHaveLength(2) + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(1) + }) + + it('starts a new sync when the orchestration database changes in flight', async () => { + const { runtime, remoteCall, blockAck, replaceDb } = createIdleSyncHarness() + const gate = blockAck() + + const oldSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await gate.reached + replaceDb() + const newSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(newSync).not.toBe(oldSync) + await vi.waitFor(() => + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(2) + ) + gate.release() + await Promise.all([oldSync, newSync]) + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(2) + }) + + it('starts a new sync when relay state resets in flight', async () => { + const { runtime, remoteCall, blockAck } = createIdleSyncHarness() + const gate = blockAck() + + const oldSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await gate.reached + runtime.stopOrchestrationFederationRelay() + const newSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(newSync).not.toBe(oldSync) + await vi.waitFor(() => + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(2) + ) + gate.release() + await Promise.all([oldSync, newSync]) + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(2) + }) + + it('matches checkpoints only to their exact remote identity and never moves backward', () => { + const runtime = {} as OrcaRuntimeService + const identity: FederationAckIdentity = { + environmentId: 'environment_windows', + peerFingerprint: 'windows_peer_fingerprint', + remoteRuntimeEpoch: 'remote_epoch_1' + } + const lease = acquireFederationAckLease(runtime, 'dispatch_remote') + recordFederationAckCheckpoint(runtime, lease, { + ...identity, + throughSequence: 2 + }) + + recordFederationAckCheckpoint(runtime, lease, { + ...identity, + throughSequence: 3 + }) + recordFederationAckCheckpoint(runtime, lease, { + ...identity, + throughSequence: 2 + }) + + expect(getFederationAckedThrough(lease, identity)).toBe(3) + expect( + getFederationAckedThrough(lease, { ...identity, remoteRuntimeEpoch: 'remote_epoch_2' }) + ).toBe(0) + expect( + getFederationAckedThrough(lease, { ...identity, peerFingerprint: 'replacement_peer' }) + ).toBe(0) + expect(getFederationAckedThrough(lease, { ...identity, environmentId: 'replacement' })).toBe(0) + }) + + it('fences delayed writes after runtime reset', () => { + const runtime = {} as OrcaRuntimeService + const identity: FederationAckIdentity = { + environmentId: 'environment_windows', + peerFingerprint: 'windows_peer_fingerprint', + remoteRuntimeEpoch: 'remote_epoch_1' + } + const staleRuntimeLease = acquireFederationAckLease(runtime, 'dispatch_remote') + clearFederationAckCheckpoints(runtime) + recordFederationAckCheckpoint(runtime, staleRuntimeLease, { + ...identity, + throughSequence: 2 + }) + expect( + getFederationAckedThrough(acquireFederationAckLease(runtime, 'dispatch_remote'), identity) + ).toBe(0) + }) +}) diff --git a/src/main/runtime/orchestration/federation-sync.ts b/src/main/runtime/orchestration/federation-sync.ts index 85856883aa9..13ec9dce561 100644 --- a/src/main/runtime/orchestration/federation-sync.ts +++ b/src/main/runtime/orchestration/federation-sync.ts @@ -5,7 +5,14 @@ import { type WorkerReportOutcome } from './types' import type { OrcaRuntimeService } from '../orca-runtime' +import type { FederatedLifecycleSettlement } from './federation-lifecycle-settlement' +import { ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION } from '../../../shared/protocol-version' import { OrchestrationError } from './orchestration-error' +import { + acquireFederationAckLease, + getFederationAckedThrough, + recordFederationAckCheckpoint +} from './federation-ack-checkpoints' const MESSAGE_TYPE_SET = new Set(MESSAGE_TYPES) @@ -52,6 +59,9 @@ export async function syncFederatedDispatch( `Saved environment ${federated.environment_name} now identifies a different Orca server.` ) } + const ackLease = acquireFederationAckLease(runtime, dispatchId) + const supportsLifecycleSettlement = + federated.protocol_version >= ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION const pulled = (await runtime.callOrchestrationWorkerServer( federated.environment_id, @@ -59,12 +69,17 @@ export async function syncFederatedDispatch( { dispatchId, afterSequence: federated.to_home_imported_sequence, + ...(supportsLifecycleSettlement ? { replayUnacknowledged: true } : {}), limit: 50 }, 15_000 )) as { runtimeEpoch: string; items: PulledRelayItem[] } - let cursor = federated.to_home_imported_sequence + let cursor = + supportsLifecycleSettlement && pulled.items.length > 0 + ? pulled.items[0].sequence - 1 + : federated.to_home_imported_sequence let imported = 0 + const settlements: { sequence: number; lifecycle: FederatedLifecycleSettlement }[] = [] for (const item of pulled.items) { if (item.dispatch_id !== dispatchId || item.sequence !== cursor + 1) { throw new OrchestrationError( @@ -90,19 +105,44 @@ export async function syncFederatedDispatch( }, lifecycle: parseFederatedLifecycle(message, item.message_id, dispatchId, dispatch.task_id) }) + if (stored.lifecycle && supportsLifecycleSettlement) { + settlements.push({ + sequence: item.sequence, + lifecycle: + stored.lifecycle.action === 'settled' + ? { + action: stored.lifecycle.outcome === 'succeeded' ? 'completed' : 'failed', + authority: 'run_home' + } + : { ...stored.lifecycle, authority: 'run_home' } + }) + } cursor = item.sequence runtime.notifyMessageArrived(stored.message.to_handle, stored.message.type) imported += stored.duplicate ? 0 : 1 } - if (cursor > 0) { + const ackIdentity = { + environmentId: federated.environment_id, + peerFingerprint: federated.peer_fingerprint, + remoteRuntimeEpoch: pulled.runtimeEpoch + } + if (cursor > getFederationAckedThrough(ackLease, ackIdentity)) { await runtime.callOrchestrationWorkerServer( federated.environment_id, 'orchestration.federationAck', - { dispatchId, throughSequence: cursor }, + { + dispatchId, + throughSequence: cursor, + ...(settlements.length > 0 ? { settlements } : {}) + }, 15_000, { orchestrationRequestId: `relay_ack_${dispatchId}_${cursor}` } ) + recordFederationAckCheckpoint(runtime, ackLease, { + ...ackIdentity, + throughSequence: cursor + }) } const toWorker = db.getWorkerDispatch(dispatchId)?.state === 'ready' diff --git a/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts new file mode 100644 index 00000000000..6907d73ba81 --- /dev/null +++ b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' +import { MUTATION_RECEIPT_MAX_ROWS } from './mutation-receipt-capacity' + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +function insertReceipts( + sqlite: Database.Database, + count: number, + state: 'pending' | 'completed' +): void { + sqlite + .prepare( + `WITH RECURSIVE receipt_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM receipt_numbers WHERE value < ? + ) + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) + SELECT 'caller', printf('request_%05d', value), 'orchestration.send', + printf('hash_%05d', value), ? + FROM receipt_numbers` + ) + .run(count, state) +} + +function beginReceipt(db: OrchestrationDb, requestId: string): void { + db.beginMutationReceipt({ + callerFingerprint: 'new-caller', + requestId, + method: 'orchestration.send', + payloadHash: `hash-${requestId}` + }) +} + +describe('mutation receipt capacity schema', () => { + let db: OrchestrationDb | undefined + let secondDb: OrchestrationDb | undefined + let tempDir: string | undefined + + afterEach(() => { + secondDb?.close() + db?.close() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('uses the completed receipt index for age and capacity pruning', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + insertReceipts(sqlite, 10, 'completed') + + const agePlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ) + .all('-30 days') as { detail: string }[] + const capacityPlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ?` + ) + .all(64) as { detail: string }[] + const details = [...agePlan, ...capacityPlan].map((row) => row.detail).join('\n') + + expect(details).toContain('idx_mutation_receipts_completed_updated') + expect(details).not.toContain('USE TEMP B-TREE') + expect(details).not.toMatch(/SCAN mutation_receipts(?:\n|$)/) + }) + + it('migrates a populated v25 database and tracks writes from older connections', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-migration-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + insertReceipts(sqliteFor(db), 20, 'completed') + db.close() + db = undefined + + const oldDb = new Database(dbPath) + oldDb.exec(` + DROP TRIGGER mutation_receipts_count_insert; + DROP TRIGGER mutation_receipts_count_delete; + DROP TABLE mutation_receipt_ledger; + DROP INDEX idx_mutation_receipts_completed_updated; + `) + oldDb.pragma('user_version = 25') + oldDb.close() + + db = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: 20 + }) + + const olderConnection = new Database(dbPath) + olderConnection.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('old-client', 'inserted', 'orchestration.send', 'hash', 'pending'); + DELETE FROM mutation_receipts + WHERE caller_fingerprint = 'old-client' AND request_id = 'inserted'; + `) + olderConnection.close() + + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: 20 + }) + }) + + it('amortizes capacity pruning while retaining the newest replay records', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + insertReceipts(sqlite, MUTATION_RECEIPT_MAX_ROWS, 'completed') + + beginReceipt(db, 'first') + const afterFirst = sqlite + .prepare('SELECT receipt_count FROM mutation_receipt_ledger') + .get() as { receipt_count: number } + beginReceipt(db, 'second') + const afterSecond = sqlite + .prepare('SELECT receipt_count FROM mutation_receipt_ledger') + .get() as { receipt_count: number } + + expect(afterFirst.receipt_count).toBe(MUTATION_RECEIPT_MAX_ROWS - 63) + expect(afterSecond.receipt_count).toBe(afterFirst.receipt_count + 1) + expect(db.getMutationReceipt('caller', 'request_00064')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'request_00065')).toMatchObject({ state: 'completed' }) + expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) + }) + + it('keeps the row limit exact across independent database connections', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-concurrency-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + secondDb = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + insertReceipts(sqlite, MUTATION_RECEIPT_MAX_ROWS - 1, 'pending') + sqlite.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('caller', 'completed-slot', 'orchestration.send', 'hash', 'completed') + `) + + beginReceipt(db, 'first-connection') + expect(() => beginReceipt(secondDb!, 'second-connection')).toThrowError( + expect.objectContaining({ code: 'mutation_ledger_full' }) + ) + db.discardPendingMutationReceipt('new-caller', 'first-connection') + beginReceipt(secondDb, 'second-connection') + + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: MUTATION_RECEIPT_MAX_ROWS + }) + }) +}) diff --git a/src/main/runtime/orchestration/mutation-receipt-capacity.ts b/src/main/runtime/orchestration/mutation-receipt-capacity.ts new file mode 100644 index 00000000000..2ba735adad0 --- /dev/null +++ b/src/main/runtime/orchestration/mutation-receipt-capacity.ts @@ -0,0 +1,79 @@ +import type Database from '../../sqlite/sync-database' +import { OrchestrationError } from './orchestration-error' + +export const MUTATION_RECEIPT_MAX_ROWS = 10_000 +const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 +const MUTATION_RECEIPT_PRUNE_BATCH_SIZE = 64 + +export function migrateMutationReceiptCapacity(db: Database.Database): void { + // Why: database triggers keep the count exact for concurrent connections and older binaries. + db.exec(` + CREATE INDEX IF NOT EXISTS idx_mutation_receipts_completed_updated + ON mutation_receipts(updated_at) WHERE state = 'completed'; + + CREATE TABLE IF NOT EXISTS mutation_receipt_ledger ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + receipt_count INTEGER NOT NULL CHECK(receipt_count >= 0) + ); + + INSERT INTO mutation_receipt_ledger (singleton, receipt_count) + SELECT 1, COUNT(*) FROM mutation_receipts + -- SQLite needs a WHERE to disambiguate SELECT UPSERT syntax. + WHERE true + ON CONFLICT(singleton) DO UPDATE SET receipt_count = excluded.receipt_count; + + CREATE TRIGGER IF NOT EXISTS mutation_receipts_count_insert + AFTER INSERT ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger + SET receipt_count = receipt_count + 1 + WHERE singleton = 1; + END; + + CREATE TRIGGER IF NOT EXISTS mutation_receipts_count_delete + AFTER DELETE ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger + SET receipt_count = receipt_count - 1 + WHERE singleton = 1; + END; + `) +} + +function receiptCount(db: Database.Database): number { + const row = db + .prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1') + .get() as { receipt_count: number } | undefined + if (!row) { + throw new Error('Mutation receipt ledger metadata is missing.') + } + return row.receipt_count +} + +export function ensureMutationReceiptCapacity(db: Database.Database): void { + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ).run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) + + const count = receiptCount(db) + if (count >= MUTATION_RECEIPT_MAX_ROWS) { + db.prepare( + `DELETE FROM mutation_receipts + WHERE rowid IN ( + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ? + )` + ).run(count - MUTATION_RECEIPT_MAX_ROWS + MUTATION_RECEIPT_PRUNE_BATCH_SIZE) + } + + if (receiptCount(db) >= MUTATION_RECEIPT_MAX_ROWS) { + throw new OrchestrationError( + 'mutation_ledger_full', + 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' + ) + } +} diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts index 8cb0547ee27..0b9c5ad7097 100644 --- a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -74,7 +74,7 @@ describe('OrchestrationDb bounded mutation receipts', () => { const count = sqliteFor(db) .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') .get() as { count: number } - expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(count.count).toBeLessThanOrEqual(MUTATION_RECEIPT_MAX_ROWS) expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) expect(db.getMutationReceipt('caller', 'new')).toMatchObject({ state: 'pending' }) @@ -117,7 +117,7 @@ describe('OrchestrationDb bounded mutation receipts', () => { const count = sqliteFor(db) .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') .get() as { count: number } - expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(count.count).toBeLessThanOrEqual(MUTATION_RECEIPT_MAX_ROWS) expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() expect(db.getMutationReceipt('caller', 'remote_pruned')).toMatchObject({ state: 'pending' }) expect(db.getRemoteDispatchAttachment('ctx_remote_pruned')).toBeDefined() @@ -240,7 +240,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: null, @@ -277,7 +277,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(26) expect(db.getDispatchContextById(dispatch.id)).toBeDefined() }) @@ -309,7 +309,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: 'tab_creator:leaf_creator', created_by_process_incarnation: 'pty_creator:incarnation-a', @@ -328,7 +328,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(26) expect(db.getTask(task.id)?.created_by_process_incarnation).toBe('pty_creator:incarnation-a') }) }) diff --git a/src/main/runtime/orchestration/setup-completion-signal.ts b/src/main/runtime/orchestration/setup-completion-signal.ts index 23e22008387..15c74d2294e 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.ts @@ -61,7 +61,7 @@ export function createSetupCompletionScanner( } const combined = `${carry}${data}` const markerIndex = combined.lastIndexOf(expectedPrefix) - if (markerIndex >= 0) { + if (markerIndex !== -1) { const suffix = combined.slice(markerIndex + expectedPrefix.length) const match = suffix.match(/^(-?\d+)\r?\n/) if (match) { diff --git a/src/main/runtime/orchestration/worker-terminal-ownership.ts b/src/main/runtime/orchestration/worker-terminal-ownership.ts index d6f160cda4a..022146f0e75 100644 --- a/src/main/runtime/orchestration/worker-terminal-ownership.ts +++ b/src/main/runtime/orchestration/worker-terminal-ownership.ts @@ -75,36 +75,6 @@ export const WORKER_SETTLED_STATES: readonly WorkerDispatchState[] = [ export const WORKER_RELEASABLE_STATES: readonly WorkerDispatchState[] = ['succeeded', 'failed'] -// Same-operation creation evidence: worker-start labels its own agent terminal 'created' -// (existing worktree) or 'reused_agent_terminal' (agent-first worktree creation). An explicit -// --terminal reuse is labeled 'reused' and never claims ownership. -export function residualResourcesClaimAgentTerminal( - residualResourcesJson: string, - terminalHandle: string -): boolean { - let parsed: unknown - try { - parsed = JSON.parse(residualResourcesJson) - } catch { - return false - } - if (!Array.isArray(parsed)) { - return false - } - return parsed.some((effect) => { - if (!effect || typeof effect !== 'object') { - return false - } - const candidate = effect as { kind?: unknown; role?: unknown; action?: unknown; id?: unknown } - return ( - candidate.kind === 'terminal' && - candidate.role === 'agent' && - candidate.id === terminalHandle && - (candidate.action === 'created' || candidate.action === 'reused_agent_terminal') - ) - }) -} - // Process accounting for worker-list; deliberately independent of Task/Dispatch outcome. export function deriveWorkerTerminalListState(params: { workerState: WorkerDispatchState diff --git a/src/main/runtime/orchestration/worker-terminal-process-liveness.ts b/src/main/runtime/orchestration/worker-terminal-process-liveness.ts new file mode 100644 index 00000000000..5a6e92c7878 --- /dev/null +++ b/src/main/runtime/orchestration/worker-terminal-process-liveness.ts @@ -0,0 +1,62 @@ +import type { PtyProcessInfo } from '../../providers/pty-process-info' + +export type WorkerTerminalHostScope = + | { kind: 'local'; hostId: 'local' } + | { kind: 'wsl'; hostId: 'local'; distro: string } + | { kind: 'ssh'; targetId: string } + +export function parseWorkerTerminalHostScope(value: string | null): WorkerTerminalHostScope | null { + if (!value) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const scope = parsed as Record + if (scope.kind === 'local' && scope.hostId === 'local') { + return { kind: 'local', hostId: 'local' } + } + if ( + scope.kind === 'wsl' && + scope.hostId === 'local' && + typeof scope.distro === 'string' && + scope.distro.length > 0 + ) { + return { kind: 'wsl', hostId: 'local', distro: scope.distro } + } + if (scope.kind === 'ssh' && typeof scope.targetId === 'string' && scope.targetId.length > 0) { + return { kind: 'ssh', targetId: scope.targetId } + } + return null +} + +export function classifyWorkerTerminalProcessIncarnation( + processIncarnation: string, + sessions: readonly PtyProcessInfo[] +): 'live' | 'dead' | 'unknown' { + const possibleMatches = sessions.filter((session) => + processIncarnation.startsWith(`${session.id}:`) + ) + if ( + possibleMatches.some((session) => { + const incarnationId = session.incarnationId + if (!incarnationId || incarnationId !== incarnationId.trim()) { + return false + } + return `${session.id}:${incarnationId}` === processIncarnation + }) + ) { + return 'live' + } + return possibleMatches.some( + (session) => !session.incarnationId || session.incarnationId !== session.incarnationId.trim() + ) + ? 'unknown' + : 'dead' +} diff --git a/src/main/runtime/quarter-circle-title-send-authorization.test.ts b/src/main/runtime/quarter-circle-title-send-authorization.test.ts new file mode 100644 index 00000000000..d8e58073cb0 --- /dev/null +++ b/src/main/runtime/quarter-circle-title-send-authorization.test.ts @@ -0,0 +1,212 @@ +// STA-4028 (regression from #13925): quarter circles are ordinary progress glyphs — +// ora, installers, any TUI animates them — so a title carrying nothing else must not +// authorize a guarded send, which auto-submits with Enter into whatever owns the pane. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' +import { detectAgentStatusFromTitle } from '../../shared/agent-detection' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'wt-1' +const PTY_ID = 'pty-1' + +// Captured from Claude Code 2.1.228 while it read this repository's package.json. +const SPINNER_ONLY_TITLE = '◑ Check package version in package.json' +const SPINNER_WITH_IDENTITY_TITLE = '◐ Claude Code' +const BRAILLE_SPINNER_ONLY_TITLE = '⠂ Deploying release 4.2' + +async function createRuntimeWithTitle( + paneTitle: string, + foregroundProcess: string | null, + launchAgent?: 'claude', + verifiedLaunch = true +): Promise<{ + runtime: OrcaRuntimeService + handle: string + getForegroundProcess: ReturnType +}> { + const runtime = new OrcaRuntimeService(null) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: WORKTREE_ID, + path: '/repo/app', + connectionId: null, + repo: null, + folderWorkspace: null + }) + const getForegroundProcess = vi.fn(async () => foregroundProcess) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'initial-incarnation' }), + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + leafId: LEAF_ID, + title: 'Terminal', + ...(launchAgent + ? { + launchAgent, + ...(verifiedLaunch + ? { launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} } } + : {}) + } + : {}) + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle + } + ] + }) + return { runtime, handle: terminal.handle, getForegroundProcess } +} + +const AUTHORIZED = 'authorized' + +async function guardedSendResult(runtime: OrcaRuntimeService, handle: string): Promise { + try { + await assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + return AUTHORIZED + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} + +describe('quarter-circle title send authorization (STA-4028)', () => { + it('refuses a guarded send when a quarter-circle spinner is the only agent evidence', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'node') + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('refuses a guarded send when the foreground process cannot be read at all', async () => { + // Why: SSH and folder-workspace panes can fail the foreground read; no evidence + // stays a refusal rather than falling back to the glyph. + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null) + + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('authorizes a guarded send when the foreground process is a recognized agent', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'claude') + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + }) + + it('authorizes a managed Claude launch when the foreground process is unavailable', async () => { + const { runtime, handle, getForegroundProcess } = await createRuntimeWithTitle( + SPINNER_ONLY_TITLE, + null, + 'claude' + ) + expect( + ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get(PTY_ID) + ).toMatchObject({ launchAgent: 'claude', launchToken: expect.any(String) }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + + it('does not trust an unverified Claude launch hint', async () => { + const { runtime, handle } = await createRuntimeWithTitle( + SPINNER_ONLY_TITLE, + null, + 'claude', + false + ) + + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('does not carry managed Claude identity into a replacement PTY incarnation', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null, 'claude') + const pty = ( + runtime as unknown as { + ptysById: Map< + string, + { incarnationId: string | null; launchIncarnationId: string | null; launchToken: string } + > + } + ).ptysById.get(PTY_ID) + expect(pty).toMatchObject({ + incarnationId: 'initial-incarnation', + launchIncarnationId: 'initial-incarnation' + }) + + runtime.onPtySpawned(PTY_ID, 'replacement-incarnation', { awaitsRegistration: false }) + + expect(pty).toMatchObject({ + incarnationId: 'replacement-incarnation', + launchIncarnationId: 'initial-incarnation', + launchToken: expect.any(String) + }) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('authorizes a guarded send when the busy title itself names the agent', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_WITH_IDENTITY_TITLE, null) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + }) + + it('leaves braille-spinner authorization unchanged', async () => { + const { runtime, handle } = await createRuntimeWithTitle(BRAILLE_SPINNER_ONLY_TITLE, null) + + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + }) + + it('keeps the quarter-circle glyph a working activity signal (#13889)', async () => { + expect(detectAgentStatusFromTitle(SPINNER_ONLY_TITLE)).toBe('working') + expect(detectAgentStatusFromTitle(SPINNER_WITH_IDENTITY_TITLE)).toBe('working') + }) +}) diff --git a/src/main/runtime/relay/relay-control-protocol.ts b/src/main/runtime/relay/relay-control-protocol.ts index 06cf61a95fd..c94797f4815 100644 --- a/src/main/runtime/relay/relay-control-protocol.ts +++ b/src/main/runtime/relay/relay-control-protocol.ts @@ -119,7 +119,6 @@ export const RelayControlErrorMessageSchema = z }) .strict() -export type RelayHostChallengeMessage = z.infer export type RelayHostHelloAckMessage = z.infer export type RelayConnectionOpenMessage = z.infer export type RelayDrainMessage = z.infer diff --git a/src/main/runtime/repo-icon-fork-backfill.test.ts b/src/main/runtime/repo-icon-fork-backfill.test.ts new file mode 100644 index 00000000000..dc7ffb85463 --- /dev/null +++ b/src/main/runtime/repo-icon-fork-backfill.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/types' +import * as client from '../github/client' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../github/client', async (importOriginal) => ({ + ...(await importOriginal>()), + getRepoUpstream: vi.fn(), + getRepoSlug: vi.fn() +})) + +const getRepoUpstream = vi.mocked(client.getRepoUpstream) +const getRepoSlug = vi.mocked(client.getRepoSlug) + +type BackfillInternals = { backfillForkUpstreams(): Promise } + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/workspace/rocket-pro', + displayName: 'rocket-pro', + badgeColor: '#2563eb', + addedAt: 1, + kind: 'git', + repoIcon: { + type: 'image', + src: 'https://github.com/acme.png?size=64', + source: 'github', + label: 'acme/rocket-pro' + }, + ...overrides + } +} + +function attachStore(runtime: OrcaRuntimeService, repos: Repo[]) { + const updateRepo = vi.fn((repoId: string, updates: Partial) => { + const index = repos.findIndex((repo) => repo.id === repoId) + repos[index] = { ...repos[index], ...updates } + }) + Object.assign(runtime, { store: { getRepos: () => repos, updateRepo } }) + return updateRepo +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('startup fork-upstream backfill', () => { + it('keeps a renamed fork own owner avatar while recording the upstream', async () => { + const runtime = new OrcaRuntimeService() + const updateRepo = attachStore(runtime, [makeRepo()]) + getRepoUpstream.mockResolvedValue({ owner: 'upstream-org', repo: 'rocket' }) + getRepoSlug.mockResolvedValue({ owner: 'acme', repo: 'rocket-pro' }) + + await (runtime as unknown as BackfillInternals).backfillForkUpstreams() + + expect(updateRepo).toHaveBeenCalledExactlyOnceWith('repo-1', { + upstream: { owner: 'upstream-org', repo: 'rocket' }, + repoIcon: { + type: 'image', + src: 'https://github.com/acme.png?size=64', + source: 'github', + label: 'acme/rocket-pro' + } + }) + }) + + it('migrates a same-name fork to the upstream owner avatar', async () => { + const runtime = new OrcaRuntimeService() + const updateRepo = attachStore(runtime, [ + makeRepo({ + path: '/workspace/rocket', + repoIcon: { + type: 'image', + src: 'https://github.com/acme.png?size=64', + source: 'github', + label: 'acme/rocket' + } + }) + ]) + getRepoUpstream.mockResolvedValue({ owner: 'upstream-org', repo: 'rocket' }) + getRepoSlug.mockResolvedValue({ owner: 'acme', repo: 'rocket' }) + + await (runtime as unknown as BackfillInternals).backfillForkUpstreams() + + expect(updateRepo).toHaveBeenCalledExactlyOnceWith('repo-1', { + upstream: { owner: 'upstream-org', repo: 'rocket' }, + repoIcon: { + type: 'image', + src: 'https://github.com/upstream-org.png?size=64', + source: 'github', + label: 'upstream-org/rocket' + } + }) + }) + + it('keeps an icon chosen while avatar detection is pending', async () => { + const runtime = new OrcaRuntimeService() + const repo = makeRepo() + const repos = [repo] + const updateRepo = attachStore(runtime, repos) + getRepoUpstream.mockResolvedValue({ owner: 'upstream-org', repo: 'rocket' }) + let resolveSlug!: (value: { owner: string; repo: string }) => void + let markSlugStarted!: () => void + const slugStarted = new Promise((resolve) => { + markSlugStarted = resolve + }) + getRepoSlug.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSlug = resolve + markSlugStarted() + }) + ) + + const backfill = (runtime as unknown as BackfillInternals).backfillForkUpstreams() + await slugStarted + repos[0] = { ...repo, repoIcon: { type: 'emoji', emoji: '🚀' } } + resolveSlug({ owner: 'acme', repo: 'rocket-pro' }) + await backfill + + expect(updateRepo).toHaveBeenCalledExactlyOnceWith('repo-1', { + upstream: { owner: 'upstream-org', repo: 'rocket' } + }) + }) +}) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 8e09e942a08..b415ddd0e71 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -107,13 +107,13 @@ export type RpcContext = { ) => () => void } -export type RpcHandler = (params: TParams, ctx: RpcContext) => Promise | unknown +export type RpcHandler = (params: TParams, ctx: RpcContext) => unknown // Why: RpcMethod erases the param type; centralizing the cast in defineMethod sidesteps RpcHandler's contravariance. export type RpcMethod = { readonly name: string readonly params: ZodType | null - readonly handler: (params: unknown, ctx: RpcContext) => Promise | unknown + readonly handler: (params: unknown, ctx: RpcContext) => unknown } type DefineMethodSpec = { diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index d1d692dab41..b52a52f24a7 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -19,6 +19,7 @@ import { } from '../../../../shared/worktree-card-properties' import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' import type { TaskProvider } from '../../../../shared/types' +import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields' import { TaskResumeState } from './task-resume-state-schema' import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' @@ -214,15 +215,10 @@ const UiUpdateFields = z manualRepoOrder: z .array(z.object({ hostId: z.string(), repoId: z.string() }).strict()) .optional(), - hideDefaultBranchWorkspace: z.boolean().optional(), - hideAutomationGeneratedWorkspaces: z.boolean().optional(), + ...ClientUiWorkspaceFilterFields, // Why: rides App.tsx's debounced writer, so omitting it rejected that entire // payload (sidebar widths, filters, agent acks) for every paired client. showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), - hideCliCreatedWorkspaces: z.boolean().optional(), - hideDetachedHeadWorkspaces: z.boolean().optional(), - alwaysShowDefaultBranchWorkspace: z.boolean().optional(), - filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), uiZoomLevel: z.number().finite().optional(), editorFontZoomLevel: z.number().finite().optional(), diff --git a/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts new file mode 100644 index 00000000000..d0239b03ec3 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const ClientUiWorkspaceFilterFields = { + hideDefaultBranchWorkspace: z.boolean().optional(), + hideAutomationGeneratedWorkspaces: z.boolean().optional(), + hideCliCreatedWorkspaces: z.boolean().optional(), + hideDetachedHeadWorkspaces: z.boolean().optional(), + hideWorkspacesFromOtherDevices: z.boolean().optional(), + alwaysShowDefaultBranchWorkspace: z.boolean().optional(), + filterRepoIds: z.array(z.string()).optional() +} diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 2a4b17b455e..18c747c242c 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -422,6 +422,24 @@ describe('client UI RPC methods', () => { }) }) + it('does not let a paired client replace the host workspace origin filter', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.set', { + hideWorkspacesFromOtherDevices: true, + sidebarWidth: 280 + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith({ sidebarWidth: 280 }) + }) + it('accepts persisted literal UI arrays and nested UI state', async () => { const updated: PersistedUIState = { ...getDefaultUIState(), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index a10f1b66708..73326756cd9 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -8,7 +8,7 @@ import { } from './client-ui-schemas' // Type-only side effect: keeps the schema/PersistedUIState parity assertions in // the typecheck graph so drift fails the build instead of a paired client. -import type {} from './ui-state-schema-parity-checks' + import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema' export const CLIENT_UI_METHODS: RpcMethod[] = [ @@ -55,9 +55,11 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ name: 'ui.set', params: UiUpdate, - handler: (params, { runtime }) => ({ - ui: runtime.updateUIState(params as Partial) - }) + handler: (params, { runtime }) => { + const { hideWorkspacesFromOtherDevices: _clientLocalFilter, ...hostUpdates } = params + void _clientLocalFilter + return { ui: runtime.updateUIState(hostUpdates as Partial) } + } }), defineMethod({ name: 'ui.recordFeatureInteraction', diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts index baf03ada5d0..aa39654d9f8 100644 --- a/src/main/runtime/rpc/methods/folder-workspace.ts +++ b/src/main/runtime/rpc/methods/folder-workspace.ts @@ -5,6 +5,8 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' +import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' +import { DiffCommentSchema } from '../../../../shared/diff-comment-schema' const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable() @@ -58,7 +60,8 @@ const FolderWorkspaceUpdate = z.object({ createdWithAgent: z.string().refine(isTuiAgent).optional(), pendingFirstAgentMessageRename: z.boolean().optional(), firstAgentMessageRenameError: z.string().nullable().optional(), - lastActivityAt: OptionalFiniteNumber + lastActivityAt: OptionalFiniteNumber, + diffComments: z.array(DiffCommentSchema).optional() }) .superRefine(assertLinkedTaskSourceContextMatch) }) @@ -94,8 +97,11 @@ export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [ defineMethod({ name: 'folderWorkspace.create', params: FolderWorkspaceCreate, - handler: async (params, { runtime }) => ({ - folderWorkspace: await runtime.createFolderWorkspace(params) + handler: async (params, context) => ({ + folderWorkspace: await context.runtime.createFolderWorkspace({ + ...params, + creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context) + }) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 06cc04d917e..72a4e150077 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -192,6 +192,32 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: [] }) }) + it('forwards request cancellation to PR check-details work', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoPRCheckDetails: vi.fn().mockResolvedValue(null) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + const controller = new AbortController() + + await dispatcher.dispatch( + makeRequest('github.prCheckDetails', { repo: 'repo-1', checkRunId: 9 }), + { signal: controller.signal } + ) + + expect(runtime.getRepoPRCheckDetails).toHaveBeenCalledWith( + 'repo-1', + { + checkRunId: 9, + workflowRunId: undefined, + checkName: undefined, + url: undefined, + prRepo: null + }, + controller.signal + ) + }) + it('fetches PR comments on the runtime server with explicit PR repo', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -219,6 +245,31 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: [] }) }) + it('sets a PR comment reaction on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + setRepoPRCommentReaction: vi.fn().mockResolvedValue(true) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.setPRCommentReaction', { + repo: 'repo-1', + reactionSubjectId: 'IC_1', + content: 'heart', + reacted: true, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } + }) + ) + + expect(runtime.setRepoPRCommentReaction).toHaveBeenCalledWith('repo-1', 'IC_1', 'heart', true, { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) + expect(response).toMatchObject({ ok: true, result: true }) + }) + it('fetches PR file contents on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 858d22b72bb..c9f8935b128 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -73,6 +73,13 @@ const PullRequest = RepoSelector.extend({ prRepo: SlugRepo.nullable().optional() }) +const PRCommentReaction = RepoSelector.extend({ + reactionSubjectId: requiredString('Missing reaction subject ID'), + content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']), + reacted: z.boolean(), + prRepo: SlugRepo.nullable().optional() +}) + const PullRequestChecks = PullRequest.extend({ headSha: OptionalString }) @@ -432,14 +439,18 @@ export const GITHUB_METHODS: RpcMethod[] = [ defineMethod({ name: 'github.prCheckDetails', params: PullRequestCheckDetails, - handler: async (params, { runtime }) => - runtime.getRepoPRCheckDetails(params.repo, { - checkRunId: params.checkRunId, - workflowRunId: params.workflowRunId, - checkName: params.checkName, - url: params.url, - prRepo: params.prRepo ?? null - }) + handler: async (params, { runtime, signal }) => + runtime.getRepoPRCheckDetails( + params.repo, + { + checkRunId: params.checkRunId, + workflowRunId: params.workflowRunId, + checkName: params.checkName, + url: params.url, + prRepo: params.prRepo ?? null + }, + signal + ) }), defineMethod({ name: 'github.rerunPRChecks', @@ -459,6 +470,18 @@ export const GITHUB_METHODS: RpcMethod[] = [ noCache: params.noCache }) }), + defineMethod({ + name: 'github.setPRCommentReaction', + params: PRCommentReaction, + handler: async (params, { runtime }) => + runtime.setRepoPRCommentReaction( + params.repo, + params.reactionSubjectId, + params.content, + params.reacted, + params.prRepo ?? null + ) + }), defineMethod({ name: 'github.prFileContents', params: PullRequestFileContents, diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 3d132e82e41..ba4b05790b7 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -162,4 +162,39 @@ describe('hosted review RPC methods', () => { result: { ok: true, number: 51 } }) }) + + it('dispatches stacked creation through a distinct runtime method', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createStackedHostedReview: vi.fn().mockResolvedValue({ + ok: true, + number: 52, + url: 'https://github.com/acme/orca/pull/52', + stackNumber: 60, + parentReview: { number: 51, url: 'https://github.com/acme/orca/pull/51' } + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: HOSTED_REVIEW_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('hostedReview.createStacked', { + repo: 'repo-1', + worktree: 'path:/worktrees/child', + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + ) + + expect(runtime.createStackedHostedReview).toHaveBeenCalledWith({ + repoSelector: 'repo-1', + worktreeSelector: 'path:/worktrees/child', + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, stackNumber: 60 } }) + }) }) diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index b658c4c74cb..0d48b02fb19 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -105,5 +105,21 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ draft: params.draft, useTemplate: params.useTemplate }) + }), + defineMethod({ + name: 'hostedReview.createStacked', + params: HostedReviewCreate, + handler: async (params, { runtime }) => + runtime.createStackedHostedReview({ + repoSelector: params.repo, + worktreeSelector: params.worktree, + provider: params.provider, + base: params.base, + head: params.head, + title: params.title, + body: params.body, + draft: params.draft, + useTemplate: params.useTemplate + }) }) ] diff --git a/src/main/runtime/rpc/methods/linear-agent-access.test.ts b/src/main/runtime/rpc/methods/linear-agent-access.test.ts index 945eadb20b8..56c32168460 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.test.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.test.ts @@ -355,7 +355,7 @@ type LinearUnconfirmedBuilder = { target: unknown, extra?: unknown ): Error & { data?: { cause?: string; nextSteps?: string[] } } - resolveLinearAgentState(input: string, states: unknown[]): unknown | null + resolveLinearAgentState(input: string, states: unknown[]): unknown linearCreatedIssueMatchesIntent(issue: unknown, intent: unknown): boolean linearSavedIssueMatchesIntent(issue: unknown, intent: unknown): boolean notifyLinearLinkedIssueUpdated( @@ -372,13 +372,13 @@ type LinearRetryLookupTester = { parentId: string | null, workspaceId: string, required: boolean - ): Promise + ): Promise getMatchingLinearAttachmentWrite( writeId: string, issueId: string, workspaceId: string, required: boolean - ): Promise + ): Promise getMatchingLinearCreatedIssue( writeId: string, teamId: string, @@ -386,7 +386,7 @@ type LinearRetryLookupTester = { workspaceId: string, required: boolean, intent?: unknown - ): Promise + ): Promise refetchLinearCommentAfterDuplicate( writeId: string, issueId: string, diff --git a/src/main/runtime/rpc/methods/linear-agent-project-access.test.ts b/src/main/runtime/rpc/methods/linear-agent-project-access.test.ts index cd51e8a0798..9e0f1568050 100644 --- a/src/main/runtime/rpc/methods/linear-agent-project-access.test.ts +++ b/src/main/runtime/rpc/methods/linear-agent-project-access.test.ts @@ -10,7 +10,7 @@ type LinearProjectResolverTester = { id: string name: string }> - readLinearProjectByIdForCreate(id: string, workspaceId: string): Promise + readLinearProjectByIdForCreate(id: string, workspaceId: string): Promise readLinearProjectsForCreate(query: string, workspaceId: string): Promise readLinearProjectsByExactNameForCreate(name: string, workspaceId: string): Promise } diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-image-block.ts b/src/main/runtime/rpc/methods/native-chat-rpc-image-block.ts new file mode 100644 index 00000000000..faa94d93c98 --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-image-block.ts @@ -0,0 +1,31 @@ +import type { NativeChatImageRefBlock } from '../../../../shared/native-chat-types' + +// Image refs are labels over RPC; inline bytes stay local and metadata cannot consume the frame. +const NATIVE_CHAT_RPC_IMAGE_METADATA_CAP = 512 + +export function sanitizeNativeChatRpcImageBlock( + block: NativeChatImageRefBlock +): NativeChatImageRefBlock { + const path = boundedImageMetadata(block.path) + const boundedUrl = boundedImageMetadata(block.url) + const url = boundedUrl && !isInlineDataUrl(boundedUrl) ? boundedUrl : undefined + const alt = boundedImageMetadata(block.alt) + return { + type: 'image-ref', + ...(path ? { path } : {}), + ...(url ? { url } : {}), + ...(alt ? { alt } : {}) + } +} + +function boundedImageMetadata(value: string | undefined): string | undefined { + return value && value.length <= NATIVE_CHAT_RPC_IMAGE_METADATA_CAP ? value : undefined +} + +function isInlineDataUrl(value: string): boolean { + let schemeStart = 0 + while (schemeStart < value.length && value.charCodeAt(schemeStart) <= 0x20) { + schemeStart += 1 + } + return /^data:/i.test(value.slice(schemeStart)) +} diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 47708d36077..af885e35de3 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -21,6 +21,7 @@ const cachedResult = vi.hoisted(() => ({ } } })) +const tailRead = vi.hoisted(() => ({ signal: undefined as AbortSignal | undefined })) const watcher = vi.hoisted(() => ({ args: null as null | { onInitialSnapshot?: ( @@ -53,10 +54,17 @@ const watcher = vi.hoisted(() => ({ } ) => void }, - watching: true + watching: true, + setupSignal: undefined as AbortSignal | undefined, + setupPromise: null as Promise<{ unsubscribe: () => void; watching: boolean }> | null, + setupFactory: null as + | ((signal?: AbortSignal) => Promise<{ unsubscribe: () => void; watching: boolean }>) + | null, + unsubscribe: vi.fn() })) vi.mock('../../../native-chat/transcript-watch', () => ({ - readNativeChatTranscriptTail: ({ limit }: { limit: number }) => { + readNativeChatTranscriptTail: ({ limit }: { limit: number }, signal?: AbortSignal) => { + tailRead.signal = signal const messages = cachedResult.value.messages return Promise.resolve({ messages: messages.slice(-limit), @@ -65,9 +73,17 @@ vi.mock('../../../native-chat/transcript-watch', () => ({ ...(cachedResult.value.lifecycle ? { lifecycle: cachedResult.value.lifecycle } : {}) }) }, - subscribeNativeChatTranscript: (args: NonNullable) => { + subscribeNativeChatTranscript: ( + args: NonNullable, + setupSignal?: AbortSignal + ) => { watcher.args = args - return Promise.resolve({ unsubscribe: vi.fn(), watching: watcher.watching }) + watcher.setupSignal = setupSignal + return ( + watcher.setupFactory?.(setupSignal) ?? + watcher.setupPromise ?? + Promise.resolve({ unsubscribe: watcher.unsubscribe, watching: watcher.watching }) + ) } })) @@ -141,6 +157,14 @@ function activeWatcherArgs(): NonNullable { } describe('nativeChat.readSession clientKind truncation gating', () => { + it('passes request cancellation to transcript resolution', async () => { + const controller = new AbortController() + const context = { ...ctxWith('runtime'), signal: controller.signal } + await readSessionHandler()({ agent: 'claude', sessionId: 's' }, context) + + expect(tailRead.signal).toBe(controller.signal) + }) + it('clips oversized tool output for mobile clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } const result = await readSessionHandler()( @@ -179,6 +203,43 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(block.text).toBe(`${text.slice(0, 64_000)}\n… (truncated)`) }) + it.each(['mobile', 'runtime', undefined] as const)( + 'omits inline image bodies and oversized metadata for %s clients', + async (clientKind) => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'image-ref', + url: `data:image/png;base64,${'a'.repeat(10_000)}`, + alt: 'Inline image' + }, + { type: 'image-ref', url: ' \tdata:image/png;base64,abc' }, + { type: 'image-ref', url: 'https://example.com/reference.png' }, + { type: 'image-ref', path: '/'.repeat(600) } + ] + } + ] + } + + const result = await readSessionHandler()( + { agent: 'codex', sessionId: 's' }, + ctxWith(clientKind) + ) + const messages = (result as { messages: NativeChatMessage[] }).messages + + expect(messages[0].blocks).toEqual([ + { type: 'image-ref', alt: 'Inline image' }, + { type: 'image-ref' }, + { type: 'image-ref', url: 'https://example.com/reference.png' }, + { type: 'image-ref' } + ]) + expect(JSON.stringify(messages).length).toBeLessThan(1_000) + } + ) + it('bounds raw tool-call inputs before sending them to mobile', async () => { cachedResult.value = { messages: [ @@ -523,6 +584,95 @@ describe('nativeChat.subscribe initial snapshot', () => { } ]) }) + + it('cancels pending setup when the stream is cleaned up', async () => { + const cleanups = new Map void>() + const context = streamingContext('mobile') + vi.mocked(context.runtime.registerSubscriptionCleanup).mockImplementation((id, cleanup) => { + cleanups.set(id, cleanup) + }) + const setupControl: { + finish?: (subscription: { unsubscribe: () => void; watching: boolean }) => void + } = {} + const lateUnsubscribe = vi.fn() + watcher.setupSignal = undefined + watcher.setupPromise = new Promise((resolve) => { + setupControl.finish = resolve + }) + const emitted: unknown[] = [] + + try { + const handling = subscribeHandler()( + { agent: 'claude', sessionId: 'pending' }, + context, + (value) => emitted.push(value) + ) + await vi.waitFor(() => expect(watcher.setupSignal).toBeDefined()) + const setupSignal = watcher.setupSignal as unknown as AbortSignal + const cleanup = [...cleanups.values()][0] + expect(cleanup).toBeDefined() + + cleanup?.() + expect(setupSignal.aborted).toBe(true) + setupControl.finish?.({ unsubscribe: lateUnsubscribe, watching: true }) + await handling + + expect(lateUnsubscribe).toHaveBeenCalledOnce() + expect(emitted).toEqual([{ type: 'end' }]) + } finally { + watcher.setupPromise = null + } + }) + + it('handles request cancellation while setup rejects', async () => { + const cleanups = new Map void>() + const controller = new AbortController() + const context = { ...streamingContext('mobile'), signal: controller.signal } + vi.mocked(context.runtime.registerSubscriptionCleanup).mockImplementation((id, cleanup) => { + cleanups.set(id, cleanup) + }) + vi.mocked(context.runtime.cleanupSubscription).mockImplementation((id) => { + cleanups.get(id)?.() + }) + watcher.setupSignal = undefined + watcher.setupFactory = (signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + const emitted: unknown[] = [] + + try { + const handling = subscribeHandler()( + { agent: 'claude', sessionId: 'pending-abort' }, + context, + (value) => emitted.push(value) + ) + await vi.waitFor(() => expect(watcher.setupSignal).toBeDefined()) + controller.abort(new Error('request closed')) + await handling + + expect(context.runtime.cleanupSubscription).toHaveBeenCalledOnce() + expect(emitted).toEqual([{ type: 'end' }]) + } finally { + watcher.setupFactory = null + } + }) + + it('skips setup for an already-cancelled request', async () => { + const controller = new AbortController() + controller.abort() + const context = { ...streamingContext('mobile'), signal: controller.signal } + watcher.args = null + const emitted: unknown[] = [] + + await subscribeHandler()({ agent: 'claude', sessionId: 'already-closed' }, context, (value) => + emitted.push(value) + ) + + expect(context.runtime.registerSubscriptionCleanup).not.toHaveBeenCalled() + expect(watcher.args).toBeNull() + expect(emitted).toEqual([]) + }) }) describe('nativeChat.readSession lifecycle payload', () => { diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index ee9343524e7..3b8d42287de 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -6,9 +6,12 @@ import type { } from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, - subscribeNativeChatTranscript + subscribeNativeChatTranscript, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The // desktop reaches the readers via Electron IPC; mobile/web clients reach the @@ -79,7 +82,16 @@ function clip(text: string, cap: number): string { return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text } -function clipBlock(block: NativeChatBlock): NativeChatBlock { +function sanitizeBlock( + block: NativeChatBlock, + clientKind: RpcContext['clientKind'] +): NativeChatBlock { + if (block.type === 'image-ref') { + return sanitizeNativeChatRpcImageBlock(block) + } + if (clientKind !== 'mobile') { + return block + } if (block.type === 'text') { return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP ? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) } @@ -126,7 +138,7 @@ function sanitizeToolInput( const result: Record = {} let count = 0 for (const key in value) { - if (!Object.prototype.hasOwnProperty.call(value, key)) { + if (!Object.hasOwn(value, key)) { continue } if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { @@ -136,7 +148,7 @@ function sanitizeToolInput( let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse // to the same bounded key; suffix collisions so neither field is silently lost. - if (Object.prototype.hasOwnProperty.call(result, boundedKey)) { + if (Object.hasOwn(result, boundedKey)) { boundedKey = `${boundedKey}~${count}` } budget.remaining -= boundedKey.length @@ -150,15 +162,18 @@ function sanitizeToolInput( return result } -function sanitizeMessage(message: NativeChatMessage): NativeChatMessage { - return { ...message, blocks: message.blocks.map(clipBlock) } +function sanitizeMessage( + message: NativeChatMessage, + clientKind: RpcContext['clientKind'] +): NativeChatMessage { + return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) } } function sanitizeAppendForClient( messages: readonly NativeChatMessage[], clientKind: RpcContext['clientKind'] ): NativeChatMessage[] { - return clientKind === 'mobile' ? messages.map(sanitizeMessage) : messages.slice() + return messages.map((message) => sanitizeMessage(message, clientKind)) } /** Window a transcript to its most recent `limit` messages so a long session @@ -173,32 +188,34 @@ function windowTranscript( return messages.length > window ? messages.slice(-window) : messages.slice() } -/** Apply the windowed slice plus, for `mobile` clients only, oversized-block - * char truncation. Web/desktop (`runtime`, or undefined for in-process callers) - * are full-class surfaces and pass block bodies through untruncated — matching - * the desktop IPC path, which never clips. */ +/** Apply the windowed slice and keep inline image bytes off every RPC transport. + * Mobile clients additionally receive bounded text and tool bodies; runtime + * clients keep those bodies intact. */ function windowForClient( messages: readonly NativeChatMessage[], clientKind: RpcContext['clientKind'], limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW ): NativeChatMessage[] { const windowed = windowTranscript(messages, limit) - return clientKind === 'mobile' ? windowed.map(sanitizeMessage) : windowed + return windowed.map((message) => sanitizeMessage(message, clientKind)) } export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ defineMethod({ name: 'nativeChat.readSession', params: NativeChatSession, - handler: async (params, { clientKind }) => { + handler: async (params, { clientKind, signal }) => { const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW - const result = await readNativeChatTranscriptTail({ - agent: params.agent, - sessionId: params.sessionId, - transcriptPath: params.transcriptPath, - limit, - beforeOffset: params.beforeOffset - }) + const result = await readNativeChatTranscriptTail( + { + agent: params.agent, + sessionId: params.sessionId, + transcriptPath: params.transcriptPath, + limit, + beforeOffset: params.beforeOffset + }, + signal + ) return 'messages' in result ? { messages: windowForClient(result.messages, clientKind, limit), @@ -212,9 +229,13 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ defineStreamingMethod({ name: 'nativeChat.subscribe', params: NativeChatSession, - handler: async (params, { runtime, connectionId, clientKind }, emit) => { + handler: async (params, { runtime, connectionId, clientKind, signal }, emit) => { + if (signal?.aborted) { + return + } let closed = false let unsubscribe = (): void => {} + const setupController = new AbortController() // Why: the first drain is a bounded tail snapshot; later drains emit only // appended turns. This avoids parsing or shipping full long transcripts. // Clients merge by message id, so the initial windowed batch doubles as the @@ -225,19 +246,29 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}` const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}` const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW - runtime.registerSubscriptionCleanup( - subscriptionId, - () => { - closed = true - unsubscribe() - emit({ type: 'end' }) - }, - connectionId - ) + const cleanup = (): void => { + if (closed) { + return + } + closed = true + signal?.removeEventListener('abort', handleAbort) + setupController.abort() + unsubscribe() + emit({ type: 'end' }) + } + function handleAbort(): void { + runtime.cleanupSubscription(subscriptionId) + } + signal?.addEventListener('abort', handleAbort, { once: true }) + runtime.registerSubscriptionCleanup(subscriptionId, cleanup, connectionId) + if (signal?.aborted) { + runtime.cleanupSubscription(subscriptionId) + return + } if (closed) { return } - const subscription = await subscribeNativeChatTranscript({ + const subscribeArgs: SubscribeNativeChatTranscriptArgs = { agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, @@ -279,7 +310,16 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ ...(lifecycle ? { lifecycle } : {}) }) } - }) + } + let subscription: NativeChatTranscriptSubscription + try { + subscription = await subscribeNativeChatTranscript(subscribeArgs, setupController.signal) + } catch (error) { + if (closed || setupController.signal.aborted) { + return + } + throw error + } // The connection may have closed while the file was being resolved. if (closed) { subscription.unsubscribe() diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts index 23a6dc0928f..561c35603f2 100644 --- a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -4,6 +4,8 @@ import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION, ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION, + ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_RUNTIME_CAPABILITY, ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { orchestrationMigrationData } from '../../../../shared/orchestration-rpc-contract' @@ -82,11 +84,16 @@ export async function startFederatedWorker(args: { capabilities: status.capabilities, serverName: server.name }) - const federationProtocolVersion = status.capabilities?.includes( + const supportsControlMail = status.capabilities?.includes( ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY ) - ? ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION - : 1 + const federationProtocolVersion = + supportsControlMail && + status.capabilities?.includes(ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_RUNTIME_CAPABILITY) + ? ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION + : supportsControlMail + ? ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION + : 1 const setupDecision = createsWorktree ? (params.setup ?? 'run') : 'not_applicable' const started = db.createStartingWorkerDispatch({ diff --git a/src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts new file mode 100644 index 00000000000..894462e5397 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts @@ -0,0 +1,448 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ORCHESTRATION_CONTRACT_VERSION, + ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport' +import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' +import { RpcDispatcher } from '../dispatcher' +import { ORCHESTRATION_METHODS } from './orchestration' +import { createFederationWorkerStartRequest as startRequest } from './orchestration-federation-test-request' + +describe('orchestration federation lifecycle settlement', () => { + let homeDb: OrchestrationDb + let workerDb: OrchestrationDb + let homeRuntime: OrcaRuntimeService + let workerRuntime: OrcaRuntimeService + let homeDispatcher: RpcDispatcher + let workerDispatcher: RpcDispatcher + let workerCapabilities: string[] + let failNextAckBeforeDelivery: boolean + + beforeEach(() => { + homeDb = new OrchestrationDb(':memory:') + workerDb = new OrchestrationDb(':memory:') + workerRuntime = new OrcaRuntimeService() + workerRuntime.setOrchestrationDb(workerDb) + workerDispatcher = new RpcDispatcher({ runtime: workerRuntime, methods: ORCHESTRATION_METHODS }) + workerCapabilities = [...(workerRuntime.getStatus().capabilities ?? [])] + failNextAckBeforeDelivery = false + const transport: OrchestrationEnvironmentTransport = { + resolve: () => ({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer_fingerprint' + }), + call: async (_selector, method, params, _timeoutMs, envelope) => { + if (method === 'status.get') { + return { + id: 'status', + ok: true, + result: { ...workerRuntime.getStatus(), capabilities: workerCapabilities }, + _meta: { runtimeId: workerRuntime.getRuntimeId() } + } + } + if (method === 'orchestration.federationAck' && failNextAckBeforeDelivery) { + failNextAckBeforeDelivery = false + throw new Error('connection lost before acknowledgment') + } + return (await workerDispatcher.dispatch({ + id: `remote_${method}`, + authToken: 'run-home-device-token', + method, + params, + orchestrationContractVersion: envelope?.orchestrationContractVersion, + orchestrationRequestId: envelope?.orchestrationRequestId, + orchestrationCapability: envelope?.orchestrationCapability + })) as RuntimeRpcResponse + } + } + homeRuntime = new OrcaRuntimeService(null, undefined, { + orchestrationEnvironmentTransport: transport + }) + homeRuntime.setOrchestrationDb(homeDb) + homeDispatcher = new RpcDispatcher({ runtime: homeRuntime, methods: ORCHESTRATION_METHODS }) + vi.spyOn(homeRuntime, 'getTerminalPaneKey').mockReturnValue( + 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + ) + configureWorkerRuntime() + }) + + afterEach(() => { + homeRuntime.stopOrchestrationFederationRelay() + homeDb.close() + workerDb.close() + }) + + function configureWorkerRuntime(): void { + vi.spyOn(workerRuntime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(workerRuntime, 'showRepo').mockResolvedValue({ + id: 'windows-repo', + kind: 'git' + } as never) + vi.spyOn(workerRuntime, 'createManagedWorktree').mockResolvedValue({ + worktree: { id: 'repo::windows-worktree', repoId: 'repo' }, + startupTerminal: { spawned: true, handle: 'term_windows_worker' }, + setupReceipt: { + requested: 'run', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'running' + } + } as never) + vi.spyOn(workerRuntime, 'listTerminals').mockResolvedValue({ + terminals: [{ handle: 'term_windows_worker', title: 'Codex' }], + totalCount: 1, + truncated: false + } as never) + vi.spyOn(workerRuntime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_windows_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(workerRuntime, 'getTerminalPaneKey').mockReturnValue( + 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + ) + vi.spyOn(workerRuntime, 'getTerminalProcessIncarnation').mockReturnValue( + 'windows_runtime:pty:1' + ) + vi.spyOn(workerRuntime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(workerRuntime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_windows_worker', + accepted: true, + bytesWritten: 1 + }) + } + + function createHomeTask() { + const run = homeDb.createRun({ + objective: 'Mac to Windows', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + return homeDb.createTask({ spec: 'Audit Windows behavior', runId: run.id }) + } + + async function sendRemoteCompletion(taskId: string, reportedTaskId: string, sync = true) { + await homeDispatcher.dispatch(startRequest(taskId)) + homeRuntime.stopOrchestrationFederationRelay() + const dispatch = homeDb.getDispatchContext(taskId)! + const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] + const sent = workerDispatcher.dispatch({ + id: 'rpc_waiting_worker_done', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'waiting_worker_done_request', + orchestrationCapability: capability, + method: 'orchestration.send', + params: { + from: 'term_windows_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: reportedTaskId, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + } + }) + await vi.waitFor(() => + expect(workerDb.listPendingFederationRelay(dispatch.id, 'to_home')).toHaveLength(1) + ) + expect(workerDb.getRemoteDispatchAttachment(dispatch.id)?.state).toBe('ready') + if (sync) { + await homeRuntime.syncOrchestrationFederation() + } + return { sent, dispatch } + } + + it('waits for Run-home settlement when an older CLI omits the wait hint', async () => { + const task = createHomeTask() + + const { sent, dispatch } = await sendRemoteCompletion(task.id, task.id) + + await expect(sent).resolves.toMatchObject({ + ok: true, + result: { lifecycle: { action: 'completed', authority: 'run_home' } } + }) + expect(homeDb.getTask(task.id)?.status).toBe('completed') + expect(workerDb.getRemoteDispatchAttachment(dispatch.id)).toMatchObject({ + state: 'succeeded', + stage: 'worker_report_settled', + capability_hash: null + }) + }) + + it('returns a Run-home rejection for a mismatched remote task', async () => { + const task = createHomeTask() + + const { sent } = await sendRemoteCompletion(task.id, 'task_wrong') + + await expect(sent).resolves.toMatchObject({ + ok: true, + result: { + lifecycle: { + action: 'rejected', + code: 'task_dispatch_mismatch', + authority: 'run_home' + } + } + }) + expect(homeDb.getTask(task.id)?.status).toBe('dispatched') + }) + + it('rejects before queueing when the negotiated protocol lacks settlement verdicts', async () => { + workerCapabilities = workerCapabilities.filter( + (capability) => + capability !== ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_RUNTIME_CAPABILITY + ) + const task = createHomeTask() + + await homeDispatcher.dispatch(startRequest(task.id)) + homeRuntime.stopOrchestrationFederationRelay() + const dispatch = homeDb.getDispatchContext(task.id)! + const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] + + await expect( + workerDispatcher.dispatch({ + id: 'rpc_unsupported_worker_done', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'unsupported_worker_done_request', + orchestrationCapability: capability, + method: 'orchestration.send', + params: { + from: 'term_windows_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + } + }) + ).resolves.toMatchObject({ + ok: false, + error: { code: 'capability_unsupported' } + }) + expect(workerDb.listPendingFederationRelay(dispatch.id, 'to_home')).toEqual([]) + expect(workerDb.getRemoteDispatchAttachment(dispatch.id)?.protocol_version).toBe(2) + }) + + it('does not settle an attachment from a verdict for non-lifecycle mail', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + homeRuntime.stopOrchestrationFederationRelay() + const dispatch = homeDb.getDispatchContext(task.id)! + const relay = workerDb.enqueueFederationRelay({ + dispatchId: dispatch.id, + direction: 'to_home', + kind: 'status', + payload: '{}' + }) + + await expect( + workerDispatcher.dispatch({ + id: 'rpc_unrelated_settlement', + authToken: 'run-home-device-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'unrelated_settlement_request', + method: 'orchestration.federationAck', + params: { + dispatchId: dispatch.id, + throughSequence: relay.sequence, + settlements: [ + { + sequence: relay.sequence, + lifecycle: { action: 'completed', authority: 'run_home' } + } + ] + } + }) + ).resolves.toMatchObject({ ok: false, error: { code: 'request_mismatch' } }) + expect(workerDb.getRemoteDispatchAttachment(dispatch.id)?.state).toBe('ready') + expect(workerDb.listPendingFederationRelay(dispatch.id, 'to_home')).toHaveLength(1) + }) + + it('does not settle an attachment from a verdict that contradicts the queued outcome', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + homeRuntime.stopOrchestrationFederationRelay() + const dispatch = homeDb.getDispatchContext(task.id)! + const relay = workerDb.enqueueFederationRelay({ + dispatchId: dispatch.id, + direction: 'to_home', + kind: 'worker_done', + payload: JSON.stringify({ + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'failed' + }) + }) + }) + + await expect( + workerDispatcher.dispatch({ + id: 'rpc_contradictory_settlement', + authToken: 'run-home-device-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'contradictory_settlement_request', + method: 'orchestration.federationAck', + params: { + dispatchId: dispatch.id, + throughSequence: relay.sequence, + settlements: [ + { + sequence: relay.sequence, + lifecycle: { action: 'completed', authority: 'run_home' } + } + ] + } + }) + ).resolves.toMatchObject({ ok: false, error: { code: 'request_mismatch' } }) + expect(workerDb.getRemoteDispatchAttachment(dispatch.id)?.state).toBe('ready') + expect(workerDb.listPendingFederationRelay(dispatch.id, 'to_home')).toHaveLength(1) + }) + + it('returns operation_unknown when Run-home settlement waiting is aborted', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + homeRuntime.stopOrchestrationFederationRelay() + const dispatch = homeDb.getDispatchContext(task.id)! + const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] + const controller = new AbortController() + const sent = workerDispatcher.dispatch( + { + id: 'rpc_aborted_worker_done', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'aborted_worker_done_request', + orchestrationCapability: capability, + method: 'orchestration.send', + params: { + from: 'term_windows_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + } + }, + { signal: controller.signal } + ) + await vi.waitFor(() => + expect(workerDb.listPendingFederationRelay(dispatch.id, 'to_home')).toHaveLength(1) + ) + + controller.abort() + + await expect(sent).resolves.toMatchObject({ + ok: false, + error: { code: 'operation_unknown' } + }) + }) + + it('does not register a waiter for an already-aborted signal', async () => { + const controller = new AbortController() + controller.abort() + const addEventListener = vi.spyOn(controller.signal, 'addEventListener') + + await expect( + waitForFederatedLifecycleSettlement(workerRuntime, 'ctx_aborted', 1, { + timeoutMs: 0, + signal: controller.signal + }) + ).resolves.toBeUndefined() + expect(addEventListener).not.toHaveBeenCalled() + }) + + it('replays a committed settlement after the first acknowledgment is lost', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + homeRuntime.stopOrchestrationFederationRelay() + const dispatch = homeDb.getDispatchContext(task.id)! + const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] + const sent = workerDispatcher.dispatch({ + id: 'rpc_replayed_worker_done', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'replayed_worker_done_request', + orchestrationCapability: capability, + method: 'orchestration.send', + params: { + from: 'term_windows_worker', + subject: 'Done', + type: 'worker_done', + waitForLifecycleSettlement: true, + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + } + }) + await vi.waitFor(() => + expect(workerDb.listPendingFederationRelay(dispatch.id, 'to_home')).toHaveLength(1) + ) + failNextAckBeforeDelivery = true + + await expect(homeRuntime.syncOrchestrationFederatedDispatch(dispatch.id)).rejects.toThrow( + 'connection lost before acknowledgment' + ) + expect(homeDb.getTask(task.id)?.status).toBe('completed') + await homeRuntime.syncOrchestrationFederatedDispatch(dispatch.id) + + await expect(sent).resolves.toMatchObject({ + ok: true, + result: { lifecycle: { action: 'completed', authority: 'run_home' } } + }) + expect(workerDb.getRemoteDispatchAttachment(dispatch.id)).toMatchObject({ + state: 'succeeded', + stage: 'worker_report_settled', + capability_hash: null + }) + }) + + it('replays a rejection without mutating its durable message twice', async () => { + const task = createHomeTask() + const { sent, dispatch } = await sendRemoteCompletion(task.id, 'task_wrong', false) + failNextAckBeforeDelivery = true + + await expect(homeRuntime.syncOrchestrationFederatedDispatch(dispatch.id)).rejects.toThrow( + 'connection lost before acknowledgment' + ) + const [relay] = workerDb.listPendingFederationRelay(dispatch.id, 'to_home') + const firstMessage = homeDb.getMessageById(relay.message_id) + await homeRuntime.syncOrchestrationFederatedDispatch(dispatch.id) + + await expect(sent).resolves.toMatchObject({ + ok: true, + result: { + lifecycle: { + action: 'rejected', + code: 'task_dispatch_mismatch', + authority: 'run_home' + } + } + }) + expect(homeDb.getMessageById(relay.message_id)).toMatchObject({ + subject: firstMessage?.subject, + body: firstMessage?.body, + payload: firstMessage?.payload + }) + expect(workerDb.getRemoteDispatchAttachment(dispatch.id)?.state).toBe('ready') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federation-relay.ts b/src/main/runtime/rpc/methods/orchestration-federation-relay.ts index fbc3e97fb3b..848b711b851 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-relay.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-relay.ts @@ -2,18 +2,42 @@ import { z } from 'zod' import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../shared/protocol-version' import { importFederatedControlMessage } from '../../orchestration/federation-control-message' import { OrchestrationError } from '../../orchestration/orchestration-error' +import { + publishFederatedLifecycleSettlement, + type FederatedLifecycleSettlement +} from '../../orchestration/federation-lifecycle-settlement' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, requiredString } from '../schemas' const FederationPullParams = z.object({ dispatchId: requiredString('Missing Dispatch ID'), afterSequence: OptionalFiniteNumber, + replayUnacknowledged: z.boolean().optional(), limit: OptionalFiniteNumber }) const FederationAckParams = z.object({ dispatchId: requiredString('Missing Dispatch ID'), - throughSequence: z.number().int().nonnegative() + throughSequence: z.number().int().nonnegative(), + settlements: z + .array( + z.object({ + sequence: z.number().int().positive(), + lifecycle: z.discriminatedUnion('action', [ + z.object({ + action: z.enum(['completed', 'failed']), + authority: z.literal('run_home') + }), + z.object({ + action: z.literal('rejected'), + code: z.string(), + reason: z.string(), + authority: z.literal('run_home') + }) + ]) + }) + ) + .optional() }) const FederationImportParams = z.object({ @@ -39,12 +63,16 @@ export const ORCHESTRATION_FEDERATION_RELAY_METHODS: RpcMethod[] = [ return { dispatchId: params.dispatchId, runtimeEpoch: runtime.getRuntimeId(), - items: runtime.getOrchestrationDb().listFederationRelay({ - dispatchId: params.dispatchId, - direction: 'to_home', - afterSequence: params.afterSequence ?? 0, - limit: params.limit - }) + items: params.replayUnacknowledged + ? runtime + .getOrchestrationDb() + .listPendingFederationRelay(params.dispatchId, 'to_home', params.limit) + : runtime.getOrchestrationDb().listFederationRelay({ + dispatchId: params.dispatchId, + direction: 'to_home', + afterSequence: params.afterSequence ?? 0, + limit: params.limit + }) } } }), @@ -53,11 +81,42 @@ export const ORCHESTRATION_FEDERATION_RELAY_METHODS: RpcMethod[] = [ params: FederationAckParams, handler: (params, { runtime, authenticatedCallerFingerprint }) => { requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) + const settlements = (params.settlements ?? []).filter( + (settlement) => settlement.sequence <= params.throughSequence + ) + const terminalSettlements = settlements.filter( + (settlement) => + settlement.lifecycle.action === 'completed' || settlement.lifecycle.action === 'failed' + ) + if (terminalSettlements.length > 1) { + throw new OrchestrationError( + 'request_mismatch', + `Federation acknowledgment for ${params.dispatchId} contains conflicting settlements.` + ) + } + const terminalSettlement = terminalSettlements[0] runtime.getOrchestrationDb().acknowledgeFederationRelay({ dispatchId: params.dispatchId, direction: 'to_home', - throughSequence: params.throughSequence + throughSequence: params.throughSequence, + ...(!terminalSettlement + ? {} + : { + settleRemoteReport: { + sequence: terminalSettlement.sequence, + outcome: + terminalSettlement.lifecycle.action === 'completed' ? 'succeeded' : 'failed' + } + }) }) + for (const settlement of settlements) { + publishFederatedLifecycleSettlement( + runtime, + params.dispatchId, + settlement.sequence, + settlement.lifecycle as FederatedLifecycleSettlement + ) + } return { dispatchId: params.dispatchId, acknowledgedThrough: params.throughSequence } } }), diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts index e6bea35c20a..9c03345ef88 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts @@ -6,7 +6,7 @@ export const FederationAttachStartParams = z.object({ dispatchId: requiredString('Missing Dispatch ID'), taskId: requiredString('Missing Task ID'), taskSpec: requiredString('Missing Task spec'), - protocolVersion: z.union([z.literal(1), z.literal(2)]), + protocolVersion: z.union([z.literal(1), z.literal(2), z.literal(3)]), worktree: requiredString('Missing remote worktree selector'), name: OptionalString, repo: OptionalString, diff --git a/src/main/runtime/rpc/methods/orchestration-federation.test.ts b/src/main/runtime/rpc/methods/orchestration-federation.test.ts index e797a09c256..ca850587ff0 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.test.ts @@ -194,7 +194,7 @@ describe('orchestration federation', () => { const attachment = workerDb.getRemoteDispatchAttachment(dispatch.id) expect(attachment).toMatchObject({ task_id: task.id, - protocol_version: 2, + protocol_version: 3, state: 'ready', worktree_id: 'repo::windows-worktree', terminal_handle: 'term_windows_worker' @@ -356,11 +356,8 @@ describe('orchestration federation', () => { }) } }) - expect(sent).toMatchObject({ - ok: true, - result: { relay: { dispatchId: dispatch.id, accepted: true } } - }) - expect(homeDb.getTask(task.id)?.status).toBe('dispatched') + expect(sent).toMatchObject({ ok: true, result: { lifecycle: { action: 'completed' } } }) + expect(homeDb.getTask(task.id)?.status).toBe('completed') await homeRuntime.syncOrchestrationFederation() @@ -508,7 +505,7 @@ describe('orchestration federation', () => { it('retries a lost relay acknowledgment without duplicating the home message', async () => { const task = createHomeTask() await homeDispatcher.dispatch(startRequest(task.id)) - const dispatch = homeDb.getDispatchContext(task.id)! + homeRuntime.stopOrchestrationFederationRelay() const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] await workerDispatcher.dispatch({ @@ -526,6 +523,7 @@ describe('orchestration federation', () => { } }) loseNextAckResponse = true + const remoteCall = vi.spyOn(homeRuntime, 'callOrchestrationWorkerServer') await expect(homeRuntime.syncOrchestrationFederation()).resolves.toBeUndefined() await homeRuntime.syncOrchestrationFederation() @@ -535,7 +533,10 @@ describe('orchestration federation', () => { .getRunMailboxHistory(task.run_id, 10) .filter((message) => message.subject === 'Checkpoint') ).toHaveLength(1) - expect(homeDb.getFederatedDispatch(dispatch.id)?.to_home_imported_sequence).toBe(1) + const acknowledgments = remoteCall.mock.calls.filter( + ([, method]) => method === 'orchestration.federationAck' + ) + expect(acknowledgments).toHaveLength(2) }) it('rejects a reordered relay gap, then converges without loss or duplication', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-control.ts b/src/main/runtime/rpc/methods/orchestration-worker-control.ts index 5798806d899..87133ede7b2 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-control.ts @@ -4,7 +4,6 @@ import { type OrchestrationWorkerReadResult } from '../../../../shared/orchestration-worker-output' import { OrchestrationError } from '../../orchestration/orchestration-error' -import { syncFederatedDispatch } from '../../orchestration/federation-sync' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, requiredString } from '../schemas' import { @@ -54,7 +53,9 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ attachment.state === 'succeeded' || (attachment.state === 'failed' && attachment.stage === 'worker_report_queued') ) { - await syncFederatedDispatch(runtime, params.dispatch).catch(() => undefined) + await runtime + .syncOrchestrationFederatedDispatchAfterCurrent(params.dispatch) + .catch(() => undefined) } else if ( attachment.state === 'stopped' && ['stopping', 'stop_unknown'].includes(worker.state) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts index 25670dda1e7..d2c17db9ba0 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts @@ -21,6 +21,7 @@ describe('orchestration worker release', () => { let runtime: OrcaRuntimeService let ctx: RpcContext let activeRunId: string + let inspectProcessLiveness: ReturnType const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -30,6 +31,12 @@ describe('orchestration worker release', () => { dbOpen = true runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) + inspectProcessLiveness = vi.fn().mockResolvedValue('live') + ;( + runtime as unknown as { + inspectTerminalProcessIncarnationLiveness: typeof inspectProcessLiveness + } + ).inspectTerminalProcessIncarnationLiveness = inspectProcessLiveness vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_coord' ? coordinatorPaneKey @@ -215,7 +222,7 @@ describe('orchestration worker release', () => { setup() const { dispatchId } = await startWorker() await expect(call('orchestration.workerRelease', { dispatch: dispatchId })).rejects.toThrow( - /only a succeeded or failed worker can release/ + /only a settled worker can release/ ) expect(runtime.closeTerminal).not.toHaveBeenCalled() expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('not_requested') @@ -232,6 +239,44 @@ describe('orchestration worker release', () => { expect(runtime.closeTerminal).not.toHaveBeenCalled() }) + it('reconciles a dead external terminal without closing a process', async () => { + setup() + const { dispatchId } = await startSettledWorker('succeeded', { terminal: 'term_worker' }) + inspectProcessLiveness.mockResolvedValue('dead') + + await expect( + call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'released', processAction: 'none' }) + expect(inspectProcessLiveness).toHaveBeenCalledWith( + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + }) + + it('retains dead inventory evidence when persisted ownership history is invalid', async () => { + setup() + const { dispatchId } = await startSettledWorker('succeeded', { terminal: 'term_worker' }) + const resource = db.getWorkerTerminalResourceByOwner(dispatchId) + const raw = ( + db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } } + ).db + raw + .prepare('UPDATE worker_terminal_resources SET prior_owner_dispatch_ids = ? WHERE id = ?') + .run('{invalid', resource?.id) + inspectProcessLiveness.mockResolvedValue('dead') + + await expect( + call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'retained', processAction: 'none' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).not.toBe('released') + }) + it('retains a user-taken-over terminal durably', async () => { setup() const { dispatchId } = await startSettledWorker() @@ -248,6 +293,50 @@ describe('orchestration worker release', () => { expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe('user_owned') }) + it('reconciles a dead user-taken-over terminal without closing a process', async () => { + setup() + const { dispatchId } = await startSettledWorker() + await call('orchestration.workerTerminalUserInput', { paneKey: workerPaneKey }) + inspectProcessLiveness.mockResolvedValue('dead') + + await expect( + call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'released', processAction: 'none' }) + expect(inspectProcessLiveness).toHaveBeenCalledWith( + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + }) + + it.each(['stopped', 'abandoned'] as const)( + 'reconciles a dead %s worker without closing a process', + async (state) => { + setup() + const { dispatchId } = await startWorker() + if (state === 'stopped') { + db.beginWorkerStop(dispatchId) + db.settleWorkerStop(dispatchId) + } else { + db.abandonWorkerDispatch(dispatchId) + } + inspectProcessLiveness.mockResolvedValue('dead') + + await expect( + call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'released', processAction: 'none' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + } + ) + it('lets user takeover cancel a release while output capture is pending', async () => { setup() const { dispatchId } = await startSettledWorker() @@ -617,6 +706,7 @@ describe('orchestration worker release', () => { expect(transferred?.terminal_handle).toBe('term_reminted') expect(db.getWorkerTerminalResourceByOwner(first.dispatchId)).toBeUndefined() + inspectProcessLiveness.mockResolvedValueOnce('dead') const oldRelease = (await call('orchestration.workerRelease', { dispatch: first.dispatchId })) as { state: string; reason?: string } @@ -632,6 +722,27 @@ describe('orchestration worker release', () => { expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') }) + it('reconciles dead transferred ownership after the current owner settles', async () => { + setup() + const first = await startSettledWorker('succeeded') + const second = await startWorker({ terminal: 'term_reminted' }) + settle(second.taskId, second.dispatchId, 'succeeded') + inspectProcessLiveness.mockResolvedValue('dead') + + await expect( + call('orchestration.workerRelease', { dispatch: first.dispatchId }) + ).resolves.toMatchObject({ state: 'released', processAction: 'none' }) + expect(inspectProcessLiveness).toHaveBeenCalledWith( + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(second.dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + }) + it('rejects exact reuse after release intent instead of closing the new worker', async () => { setup() const first = await startSettledWorker('succeeded') @@ -744,9 +855,14 @@ describe('orchestration worker release', () => { expect(listed.workers).toContainEqual( expect.objectContaining({ dispatchId, terminalState: 'retained' }) ) - await expect(call('orchestration.workerRelease', { dispatch: dispatchId })).rejects.toThrow( - /only a succeeded or failed worker can release/ - ) + await expect( + call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ + state: 'retained', + reason: 'identity_unproven', + processAction: 'none' + }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() }) it('worker-show exposes the terminal resource', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release.ts b/src/main/runtime/rpc/methods/orchestration-worker-release.ts index f01cd72de47..446887f581a 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-release.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-release.ts @@ -54,12 +54,36 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ } } if (requested.disposition === 'retained') { + const resource = requested.resource + const processIncarnation = resource?.process_incarnation + if ( + processIncarnation && + (await runtime.inspectTerminalProcessIncarnationLiveness( + processIncarnation, + resource.host_scope + )) === 'dead' + ) { + const reconciled = db.settleDeadWorkerTerminalRelease({ + requestingDispatchId: params.dispatch, + resourceId: resource.id, + processIncarnation + }) + if (reconciled.disposition === 'released') { + runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') + return { + dispatchId: params.dispatch, + state: 'released', + processAction: 'none', + archive: archiveSummary(reconciled.resource) + } + } + } return { dispatchId: params.dispatch, state: 'retained', reason: requested.reason, processAction: 'none', - archive: archiveSummary(requested.resource) + archive: archiveSummary(resource) } } return completeWorkerTerminalRelease({ diff --git a/src/main/runtime/rpc/methods/orchestration-worker-stop.ts b/src/main/runtime/rpc/methods/orchestration-worker-stop.ts index f393e65012a..0802fac3954 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-stop.ts @@ -1,6 +1,5 @@ import { z } from 'zod' import { OrchestrationError } from '../../orchestration/orchestration-error' -import { syncFederatedDispatch } from '../../orchestration/federation-sync' import { defineMethod, type RpcMethod } from '../core' import { requiredString } from '../schemas' import { @@ -49,7 +48,9 @@ export const ORCHESTRATION_WORKER_STOP_METHODS: RpcMethod[] = [ } if (remote.state === 'succeeded' || remote.state === 'failed') { db.resumeFederatedWorkerForTerminalRelay(params.dispatch) - await syncFederatedDispatch(runtime, params.dispatch).catch(() => undefined) + await runtime + .syncOrchestrationFederatedDispatchAfterCurrent(params.dispatch) + .catch(() => undefined) return { dispatchId: params.dispatch, state: db.getWorkerDispatch(params.dispatch)?.state ?? remote.state, diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index d9d91237d08..f95b20a8b05 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -415,7 +415,7 @@ describe('orchestration RPC methods', () => { ) vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) - await call('orchestration.send', { + const result = await call('orchestration.send', { from: 'term_worker', to: 'term_coord', subject: 'Done', @@ -429,6 +429,7 @@ describe('orchestration RPC methods', () => { expect(db.getTask(task.id)?.status).toBe('completed') expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + expect(result).toMatchObject({ lifecycle: { action: 'completed' } }) }) it('rejects an identity-less lifecycle send resolved through the coordinator handle', async () => { @@ -482,7 +483,7 @@ describe('orchestration RPC methods', () => { } expect(db.getTask(task.id)?.status).toBe('completed') - expect(result.lifecycle).toBeUndefined() + expect(result.lifecycle).toMatchObject({ action: 'completed' }) expect(result.message).toMatchObject({ type: 'worker_done', subject: 'Done' @@ -1788,11 +1789,14 @@ describe('orchestration RPC methods', () => { }) }) - it('rejects invalid deps JSON', async () => { + it('rejects non-JSON deps', async () => { setup() await expect( call('orchestration.taskCreate', { spec: 'bad', deps: 'not-json' }) ).rejects.toThrow('Invalid --deps') + await expect( + call('orchestration.taskCreate', { spec: 'bad', deps: '[task_example]' }) + ).rejects.toThrow('Invalid --deps') }) }) @@ -3075,9 +3079,11 @@ describe('orchestration RPC methods', () => { it('resets all state', async () => { setup() seedResetState() + const stopRelay = vi.spyOn(runtime, 'stopOrchestrationFederationRelay') const result = (await call('orchestration.reset', { all: true })) as { reset: string } expect(result.reset).toBe('all') + expect(stopRelay).toHaveBeenCalledOnce() expect(db.getInbox()).toHaveLength(0) expect(db.listTasks()).toHaveLength(0) }) @@ -3085,8 +3091,10 @@ describe('orchestration RPC methods', () => { it('resets tasks only', async () => { setup() seedResetState() + const stopRelay = vi.spyOn(runtime, 'stopOrchestrationFederationRelay') await call('orchestration.reset', { tasks: true }) + expect(stopRelay).toHaveBeenCalledOnce() expect(db.getInbox()).toHaveLength(1) expect(db.listTasks()).toHaveLength(0) }) @@ -3094,8 +3102,10 @@ describe('orchestration RPC methods', () => { it('resets messages only', async () => { setup() seedResetState() + const stopRelay = vi.spyOn(runtime, 'stopOrchestrationFederationRelay') await call('orchestration.reset', { messages: true }) + expect(stopRelay).not.toHaveBeenCalled() expect(db.getInbox()).toHaveLength(0) expect(db.listTasks()).toHaveLength(1) }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 22518b3717b..f3dee4d2171 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -13,6 +13,7 @@ import { buildDispatchPreamble } from '../../orchestration/preamble' import { formatMessageBanner } from '../../orchestration/formatter' import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' import { ORCHESTRATION_LEGACY_RUN_ID, @@ -28,7 +29,10 @@ import { OrchestrationError } from '../../orchestration/orchestration-error' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RunRow } from '../../orchestration/types' import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' -import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import { + ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION, + ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' const TASK_STATUSES: TaskStatus[] = [ 'pending', @@ -86,6 +90,7 @@ const SendParams = z // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. senderPaneKey: OptionalString, run: OptionalString, + waitForLifecycleSettlement: OptionalBoolean, devMode: OptionalBoolean }) .superRefine((params, ctx) => { @@ -392,7 +397,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ orchestrationCapability, legacyCoordinatorRunId, revalidateLegacyCoordinator, - orchestrationCompatibilityCallerAuthority + orchestrationCompatibilityCallerAuthority, + signal } ) => { const db = runtime.getOrchestrationDb() @@ -445,6 +451,16 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ 'Remote worker_done requires outcome=succeeded|failed.' ) } + if ( + outcome && + remoteAttachment.protocol_version < + ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION + ) { + throw new OrchestrationError( + 'capability_unsupported', + 'The Run-home runtime cannot confirm worker_done settlement. Update it before retrying; no report was queued.' + ) + } const relay = db.enqueueFederationRelay({ dispatchId: remoteAttachment.dispatch_id, direction: 'to_home', @@ -457,9 +473,20 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ priority: params.priority ?? 'normal', threadId: params.threadId ?? null, payload: params.payload ?? null - }), - settleRemoteOutcome: outcome + }) }) + const lifecycle = outcome + ? await waitForFederatedLifecycleSettlement(runtime, relay.dispatch_id, relay.sequence, { + timeoutMs: 30_000, + signal + }) + : undefined + if (outcome && !lifecycle) { + throw new OrchestrationError( + 'operation_unknown', + 'worker_done was queued, but the Run-home runtime did not confirm settlement. Verify the Task and Dispatch before retrying.' + ) + } return { relay: { messageId: relay.message_id, @@ -468,9 +495,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ destination: 'run_home', accepted: true }, - ...(outcome - ? { lifecycle: { action: outcome === 'succeeded' ? 'completed' : 'failed' } } - : {}) + ...(lifecycle ? { lifecycle } : {}) } } const routing = resolveMessageRun(runtime, { @@ -628,6 +653,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ runtime.notifyMessageArrived(to, rejection.type) return { message: rejection, lifecycle: reconciled } } + runtime.notifyMessageArrived(to, msg.type) + return msg.type === 'worker_done' + ? { message: msg, lifecycle: reconciled } + : { message: msg } } runtime.notifyMessageArrived(to, msg.type) return { message: msg } @@ -1527,10 +1556,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() if (params.all) { + runtime.stopOrchestrationFederationRelay() db.resetAll() return { reset: 'all' } } if (params.tasks) { + runtime.stopOrchestrationFederationRelay() db.resetTasks() return { reset: 'tasks' } } diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts index 6fdb59c3ae9..aaa0d886fb0 100644 --- a/src/main/runtime/rpc/methods/repo-update-schema.ts +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -54,6 +54,7 @@ export function createRepoUpdateSchema( externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(), importedExternalWorktreePaths: z.array(z.string()).optional(), + agentWorktreeVisibility: z.enum(['hide', 'show']).optional(), externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(), projectGroupId: OptionalString.nullable().optional(), projectGroupOrder: OptionalFiniteNumber, diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index df5bbff67b8..7c31660c1c4 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -303,6 +303,33 @@ describe('repo RPC methods', () => { }) }) + it('persists agent worktree visibility updates', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepo: vi.fn().mockResolvedValue({ + id: 'repo-1', + path: '/srv/repo', + agentWorktreeVisibility: 'show' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { agentWorktreeVisibility: 'show' } + }) + ) + + expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', { + agentWorktreeVisibility: 'show' + }) + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', agentWorktreeVisibility: 'show' } } + }) + }) + it('persists resolved GitHub upstream metadata updates', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -440,7 +467,8 @@ describe('repo RPC methods', () => { expect(runtime.listFolderWorkspaces).toHaveBeenCalled() expect(runtime.createFolderWorkspace).toHaveBeenCalledWith({ projectGroupId: group.id, - name: 'Refund fix' + name: 'Refund fix', + creatorProvenance: { kind: 'host' } }) expect(runtime.updateFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { comment: 'x' diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index fe9cb78ce45..03d66f84fb1 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -5,10 +5,11 @@ export const STATUS_METHODS: RpcMethod[] = [ defineMethod({ name: 'status.get', params: null, - handler: (_params, { runtime }) => { + handler: (_params, { runtime, pairedDeviceId }) => { const snapshot = getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) return { ...runtime.getStatus(), + ...(pairedDeviceId ? { pairedDeviceId } : {}), appVersion: snapshot.appVersion, remoteUpdateSupport: snapshot.support } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 1cdec021e55..69f4eb72190 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -99,6 +99,8 @@ type SnapshotFrameOptions = { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] pendingEscapeTailAnsi?: string + /** Effective kitty flags proven at this frame's own `seq`. */ + kittyKeyboardFlags?: number } type SerializedSnapshot = { @@ -113,6 +115,7 @@ type SerializedSnapshot = { scrollbackRows: number truncatedByByteBudget: boolean pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number } | null type TerminalViewportClient = { @@ -648,6 +651,15 @@ function sendSnapshotFrames( source: options.source, oscLinks: options.oscLinks, pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, + // Why conditional and additive: old clients ignore the unknown field, + // and a new client must read absence as unknown rather than zero, so + // no opcode or capability negotiation is involved (Rule 1 of + // docs/reference/remote-wire-compatibility.md). + // Why `seq` is required: the flags are only proven at this frame's own + // seq, so without a replay boundary the client cannot order them. + ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined + ? { kittyKeyboardFlags: options.kittyKeyboardFlags } + : {}), truncated: options.truncated === true, truncatedByByteBudget: options.truncatedByByteBudget === true }) @@ -1840,6 +1852,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ reason: 'ack-pending-overflow', seq: serialized.seq, source: serialized.source, + kittyKeyboardFlags: serialized.kittyKeyboardFlags, truncatedByByteBudget: serialized.truncatedByByteBudget, data: serialized.data } @@ -2327,6 +2340,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ seq: serialized?.seq, cwd: serialized?.cwd, source: serialized?.source, + kittyKeyboardFlags: serialized?.kittyKeyboardFlags, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, @@ -2669,6 +2683,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, source: serialized?.source, + kittyKeyboardFlags: serialized?.kittyKeyboardFlags, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, data: diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts index 29d205e6092..555e8f44cfb 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts @@ -13,16 +13,6 @@ export type AssertNoMissingKeys> ? true : { missingFromSchema: Exclude } -/** - * The mirror of `AssertNoMissingKeys`: a key the schema accepts but the shared - * type does not model. `.strict()` cannot catch this one — it lets the payload - * through, and the renderer then receives a field it has no type for. - */ -export type AssertNoExtraKeys> = - Exclude extends never - ? true - : { absentFromType: Exclude } - /** * Key parity alone is blind to VALUE drift: a schema can list `rightSidebarTab` * yet omit half its union members, which the strict schema then rejects. This diff --git a/src/main/runtime/rpc/methods/worktree-schemas.test.ts b/src/main/runtime/rpc/methods/worktree-schemas.test.ts index e1eb22f2c52..5737e2c4c8d 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.test.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.test.ts @@ -109,14 +109,14 @@ describe('worktree RPC schemas', () => { const parsed = WorktreeSet.parse({ worktree: 'id:r1::/repos/wt', displayName: '' }) expect(parsed.displayName).toBe('') - expect(Object.prototype.hasOwnProperty.call(parsed, 'displayName')).toBe(true) + expect(Object.hasOwn(parsed, 'displayName')).toBe(true) }) it('still omits a display name that was never sent', () => { const parsed = WorktreeSet.parse({ worktree: 'id:r1::/repos/wt', comment: 'note' }) expect(parsed.displayName).toBeUndefined() - expect(Object.prototype.hasOwnProperty.call(parsed, 'displayName')).toBe(false) + expect(Object.hasOwn(parsed, 'displayName')).toBe(false) }) it('ignores a non-string display name rather than persisting it', () => { diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 57b8a9202a5..a3e562e8516 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -275,6 +275,7 @@ export const WorktreeSet = WorktreeSelector.extend({ }) export const WorktreeRemove = WorktreeSelector.extend({ + hostId: OptionalString, force: OptionalBoolean, // Why (#11960): the CLI's --force is an unambiguous force affordance, but the // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop @@ -284,6 +285,7 @@ export const WorktreeRemove = WorktreeSelector.extend({ }) export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ + hostId: OptionalString, branchName: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 3fe4b5da15c..0b5be4da384 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -155,6 +155,7 @@ describe('worktree RPC methods', () => { setupDecision: 'skip', createdWithAgent: undefined, automationProvenance: undefined, + creatorProvenance: { kind: 'host' }, startup: undefined, startupDraft: undefined, lineage: { diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 758e6b83891..3d2a05b523f 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -7,6 +7,7 @@ import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-pr import { defineMethod, type RpcMethod } from '../core' import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' +import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { WorktreeCreate, WorktreeDetectedListParams, @@ -96,11 +97,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.create', params: WorktreeCreate, - handler: async (params, { runtime }) => + handler: async (params, context) => // Why: a mobile create interrupted by a connection migration is retried with // the same clientMutationId; dedupe so the host returns the in-flight/created // worktree instead of spawning a duplicate. No key (desktop/CLI) runs plainly. - runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => { + context.runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => { + const { runtime } = context const repo = await runtime.showRepo(params.repo) const automationProvenance = resolveAutomationWorkspaceProvenance({ authority: runtime, @@ -145,6 +147,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [ startupAgent: params.startupAgent ?? params.createdWithAgent, createdAt: Date.now() }), + creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context), startup: params.startupCommand ? { command: params.startupCommand, @@ -271,12 +274,15 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { - const result = await runtime.removeManagedWorktree( + const removalArgs = [ params.worktree, params.force === true, params.runHooks === true, params.allowUnverifiedPtyStop === true - ) + ] as const + const result = params.hostId + ? await runtime.removeManagedWorktree(...removalArgs, params.hostId) + : await runtime.removeManagedWorktree(...removalArgs) return { removed: true, ...result } } }), @@ -284,6 +290,17 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.forceDeleteBranch', params: WorktreeForceDeleteBranch, handler: async (params, { runtime }) => - runtime.forceDeletePreservedBranch(params.worktree, params.branchName, params.expectedHead) + params.hostId + ? runtime.forceDeletePreservedBranch( + params.worktree, + params.branchName, + params.expectedHead, + params.hostId + ) + : runtime.forceDeletePreservedBranch( + params.worktree, + params.branchName, + params.expectedHead + ) }) ] diff --git a/src/main/runtime/rpc/orchestration-legacy-compatibility.ts b/src/main/runtime/rpc/orchestration-legacy-compatibility.ts index 291d35b895f..31d3b5b1952 100644 --- a/src/main/runtime/rpc/orchestration-legacy-compatibility.ts +++ b/src/main/runtime/rpc/orchestration-legacy-compatibility.ts @@ -206,7 +206,7 @@ export class OrchestrationLegacyCompatibility { request: RpcRequest, params: unknown, signal?: AbortSignal - ): Promise { + ): Promise { if (request.method === 'orchestration.send') { return await handleLegacyLifecycleSend({ runtime: this.runtime, diff --git a/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts b/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts index 9ffe17cca6e..7e76128bd19 100644 --- a/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts +++ b/src/main/runtime/rpc/orchestration-legacy-lifecycle.ts @@ -17,7 +17,7 @@ export async function handleLegacyLifecycleSend(args: { authority: LegacyCompatibilityAuthority request: RpcRequest params: LegacySendParams -}): Promise { +}): Promise { const { runtime, authority, request, params } = args if (!['heartbeat', 'worker_done', 'escalation'].includes(params.type ?? '')) { return undefined diff --git a/src/main/runtime/rpc/orchestration-legacy-mail.ts b/src/main/runtime/rpc/orchestration-legacy-mail.ts index e41e0c11509..c83d7ffdf2f 100644 --- a/src/main/runtime/rpc/orchestration-legacy-mail.ts +++ b/src/main/runtime/rpc/orchestration-legacy-mail.ts @@ -20,7 +20,7 @@ export async function handleLegacyCheck(args: { request: RpcRequest params: LegacyCheckParams signal?: AbortSignal -}): Promise { +}): Promise { const { runtime, authority, request, params, signal } = args if (params.run) { return undefined @@ -138,7 +138,7 @@ export async function handleLegacyReply(args: { authority: LegacyCompatibilityAuthority request: RpcRequest params: LegacyReplyParams -}): Promise { +}): Promise { const { runtime, authority, request, params } = args const db = runtime.getOrchestrationDb() const original = db.getMessageById(params.id) diff --git a/src/main/runtime/rpc/orchestration-legacy-question.ts b/src/main/runtime/rpc/orchestration-legacy-question.ts index 69dec720a3a..cd463155619 100644 --- a/src/main/runtime/rpc/orchestration-legacy-question.ts +++ b/src/main/runtime/rpc/orchestration-legacy-question.ts @@ -17,7 +17,7 @@ export async function handleLegacyAsk(args: { request: RpcRequest params: LegacyAskParams signal?: AbortSignal -}): Promise { +}): Promise { const { runtime, authority, request, params, signal } = args const dispatch = authority.resolveAskDispatch(request, params) if (!dispatch || dispatch.contract_version !== LEGACY_CONTRACT_VERSION) { diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts index 2334cd709eb..701970eb58c 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -22,7 +22,7 @@ export class OrchestrationMutationExecutor { async run( request: RpcRequest, params: unknown, - invoke: (mutation?: DurableMutationInvocation) => Promise | unknown, + invoke: (mutation?: DurableMutationInvocation) => unknown, callerFingerprintOverride?: string ): Promise { const requestId = request.orchestrationRequestId diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts index 4062dacf9a1..a9cbfbc5ab7 100644 --- a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -38,7 +38,7 @@ describe('durable orchestration mutation ledger', () => { } }) - function createHarness(dbPath: string | ':memory:' = ':memory:') { + function createHarness(dbPath: (string & {}) | ':memory:' = ':memory:') { const db = new OrchestrationDb(dbPath) const runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) diff --git a/src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts b/src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts new file mode 100644 index 00000000000..2671449b606 --- /dev/null +++ b/src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import { TERMINAL_METHODS } from './methods/terminal' + +function request(method: string, params: unknown): RpcRequest { + return { id: 'request-1', authToken: 'test-token', method, params } +} + +describe('OpenCode guarded terminal send', () => { + afterEach(() => vi.useRealTimers()) + + it('refuses a marker title left on a shell without writing notes', async () => { + vi.useFakeTimers() + const write = vi.fn(() => true) + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ + write, + kill: () => true, + getForegroundProcess: async () => 'zsh' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree', + title: 'OC | zsh', + activeLeafId: 'pane-1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree', + leafId: 'pane-1', + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: 'OC | zsh' + } + ] + }) + const [terminal] = (await runtime.listTerminals()).terminals + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await expect( + dispatcher.dispatch(request('terminal.agentStatus', { terminal: terminal.handle })) + ).resolves.toMatchObject({ + ok: true, + result: { agentStatus: { isRunningAgent: false, status: null } } + }) + + const send = dispatcher.dispatch( + request('terminal.send', { + terminal: terminal.handle, + text: '$(touch should-not-run)', + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + await vi.advanceTimersByTimeAsync(1_500) + + await expect(send).resolves.toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0, refusedReason: 'no-agent' } } + }) + expect(write).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts index bf07fe38fc8..60c4c86fe44 100644 --- a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts +++ b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts @@ -7,9 +7,24 @@ import { } from './terminal-stream-byte-length' import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../shared/terminal-multiplex-flow-control' -// Byte-for-byte copy of the pre-change implementation (shared/clipboard-text.ts +// Copy of the pre-change implementation (shared/clipboard-text.ts // measureClipboardTextByteLength), kept here so equivalence is checked against the -// ACTUAL old code path rather than a paraphrase of it. +// ACTUAL old code path rather than a paraphrase of it. The one deliberate deviation is +// `legacyCodePointAt`: raw `String.prototype.codePointAt` reads one code unit past the end +// of a sliced string once V8 optimizes its caller, so the naive copy is not a stable +// reference. See src/shared/utf8-byte-limits.ts (readUtf8CodePointAt). +function legacyCodePointAt(text: string, index: number): number { + const leadUnit = text.charCodeAt(index) + if (leadUnit < 0xd800 || leadUnit > 0xdbff || index + 1 >= text.length) { + return leadUnit + } + const trailUnit = text.charCodeAt(index + 1) + if (trailUnit < 0xdc00 || trailUnit > 0xdfff) { + return leadUnit + } + return (leadUnit - 0xd800) * 0x400 + (trailUnit - 0xdc00) + 0x10000 +} + function legacyUtf8ByteLengthForCodePoint(codePoint: number): number { if (codePoint <= 0x7f) { return 1 @@ -30,7 +45,7 @@ function legacyMeasure( const stopAfterBytes = options.stopAfterBytes let byteLength = 0 for (let index = 0; index < text.length; index += 1) { - const codePoint = text.codePointAt(index) ?? 0 + const codePoint = legacyCodePointAt(text, index) byteLength += legacyUtf8ByteLengthForCodePoint(codePoint) if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) { return { byteLength, exceededLimit: true } @@ -128,6 +143,39 @@ const EDGE_STRINGS = [ `${'é'.repeat(500)}\ud800` ] +// Regression for the intermittent "measurement diverged at 13 units" failure: the fuzzers +// build a rope and cut it at a fixed code-unit count, which can split a surrogate pair and +// leave the low half in the parent just past the slice. Optimized `codePointAt` pairs across +// that boundary, so the scan measured one byte too many, but only after the enclosing function +// tiered up, which made the failure look load-dependent. 13 code units is V8's minimum length +// for a sliced string, which is why the divergence started exactly there. +describe('measuring a prefix slice that cuts a surrogate pair in half', () => { + // Kept first in the file so the scan is still specializing on this shape when it tiers up. + it('measures the slice like the encoder does in every JIT tier', () => { + const sliced = 'abcdefghijkl\u{1f600}'.slice(0, 13) + expect(sliced.length).toBe(13) + expect(sliced.charCodeAt(12)).toBe(0xd83d) + // 12 ASCII bytes plus U+FFFD for the orphaned high surrogate. + expect(Buffer.byteLength(sliced, 'utf8')).toBe(15) + + const observedByteLengths = new Set() + const observedExceeded = new Set() + const observedMeasurements = new Set() + for (let iteration = 0; iteration < 200_000; iteration += 1) { + observedByteLengths.add(terminalStreamByteLength(sliced)) + observedExceeded.add(terminalStreamByteLengthExceeds(sliced, 15)) + observedMeasurements.add( + JSON.stringify(measureTerminalStreamByteLength(sliced, { stopAfterBytes: 15 })) + ) + } + expect([...observedByteLengths]).toEqual([15]) + expect([...observedExceeded]).toEqual([false]) + expect([...observedMeasurements]).toEqual([ + JSON.stringify({ byteLength: 15, exceededLimit: false }) + ]) + }) +}) + describe('terminal stream byte length equivalence with the legacy code-point scan', () => { it('matches the legacy total byte length on edge strings', () => { for (const text of EDGE_STRINGS) { diff --git a/src/main/runtime/rpc/workspace-creator-context.ts b/src/main/runtime/rpc/workspace-creator-context.ts new file mode 100644 index 00000000000..de0b27965fd --- /dev/null +++ b/src/main/runtime/rpc/workspace-creator-context.ts @@ -0,0 +1,14 @@ +import type { WorkspaceCreatorProvenance } from '../../../shared/types' +import type { RpcContext } from './core' + +export function resolveRpcWorkspaceCreatorProvenance( + context: Pick +): WorkspaceCreatorProvenance { + if (context.pairedDeviceId) { + return { kind: 'paired-device', deviceId: context.pairedDeviceId } + } + if (context.clientId || context.clientKind || context.connectionId) { + throw new Error('authenticated_device_identity_missing') + } + return { kind: 'host' } +} diff --git a/src/main/runtime/runtime-folder-workspace.test.ts b/src/main/runtime/runtime-folder-workspace.test.ts new file mode 100644 index 00000000000..f00da160330 --- /dev/null +++ b/src/main/runtime/runtime-folder-workspace.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import type { Repo, WorktreeMeta } from '../../shared/types' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { + getRuntimeFolderWorkspaceInstanceId, + getRuntimeFolderWorkspaceRootId, + isRuntimeFolderWorkspaceIdForRepo, + mergeRuntimeFolderWorkspace +} from './runtime-folder-workspace' + +// characterization: current behavior + +function repo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/Users/dev/projects/site', + displayName: 'site', + badgeColor: '#000000', + addedAt: 0, + ...overrides + } +} + +function meta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +describe('getRuntimeFolderWorkspaceRootId', () => { + it.each([ + { label: 'posix path', input: repo(), expected: 'repo-1::/Users/dev/projects/site' }, + { + label: 'windows path', + input: repo({ path: 'C:\\Users\\dev\\site' }), + expected: 'repo-1::C:\\Users\\dev\\site' + }, + { + label: 'ssh repo (connectionId is not part of the id)', + input: repo({ connectionId: 'ssh-1', path: '/home/dev/site' }), + expected: 'repo-1::/home/dev/site' + }, + { label: 'empty path', input: repo({ path: '' }), expected: 'repo-1::' } + ])('joins repo id and path for $label', ({ input, expected }) => { + expect(getRuntimeFolderWorkspaceRootId(input)).toBe(expected) + }) +}) + +describe('getRuntimeFolderWorkspaceInstanceId', () => { + it('appends the instance id after the separator', () => { + expect(getRuntimeFolderWorkspaceInstanceId(repo(), 'abc')).toBe( + `repo-1::/Users/dev/projects/site${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}abc` + ) + }) + + it('appends an empty instance id without complaint', () => { + expect(getRuntimeFolderWorkspaceInstanceId(repo(), '')).toBe( + `repo-1::/Users/dev/projects/site${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + ) + }) +}) + +describe('isRuntimeFolderWorkspaceIdForRepo', () => { + const root = 'repo-1::/Users/dev/projects/site' + + it.each([ + { label: 'the root id itself', worktreeId: root, expected: true }, + { + label: 'an instance id under the root', + worktreeId: `${root}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}abc`, + expected: true + }, + { + label: 'a sibling path that merely shares the root as a prefix', + worktreeId: `${root}-other`, + expected: false + }, + { label: 'another repo', worktreeId: 'repo-2::/Users/dev/projects/site', expected: false }, + { label: 'an unrelated id', worktreeId: '', expected: false } + ])('$label', ({ worktreeId, expected }) => { + expect(isRuntimeFolderWorkspaceIdForRepo(repo(), worktreeId)).toBe(expected) + }) +}) + +describe('mergeRuntimeFolderWorkspace', () => { + it('projects repo + meta onto a branchless, headless worktree', () => { + const merged = mergeRuntimeFolderWorkspace(repo(), 'repo-1::/Users/dev/projects/site', meta()) + + expect(merged).toEqual({ + id: 'repo-1::/Users/dev/projects/site', + repoId: 'repo-1', + path: '/Users/dev/projects/site', + head: '', + branch: '', + isBare: false, + isMainWorktree: true, + displayName: 'site', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'in-progress', + diffComments: undefined, + mobileDiffReview: undefined + }) + }) + + it('marks a non-root id as a secondary workspace', () => { + const worktreeId = `repo-1::/Users/dev/projects/site${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}abc` + const merged = mergeRuntimeFolderWorkspace(repo(), worktreeId, meta({ instanceId: 'abc' })) + + expect(merged.isMainWorktree).toBe(false) + expect(merged.instanceId).toBe('abc') + expect(merged.path).toBe('/Users/dev/projects/site') + }) + + it('falls back to the repo display name when meta has a blank one', () => { + expect(mergeRuntimeFolderWorkspace(repo(), 'id', meta()).displayName).toBe('site') + expect( + mergeRuntimeFolderWorkspace(repo(), 'id', meta({ displayName: 'custom' })).displayName + ).toBe('custom') + }) + + it('omits optional keys rather than writing undefined for them', () => { + const merged = mergeRuntimeFolderWorkspace(repo(), 'id', meta()) + + expect('instanceId' in merged).toBe(false) + expect('projectId' in merged).toBe(false) + expect('hostId' in merged).toBe(false) + expect('projectHostSetupId' in merged).toBe(false) + expect('manualOrder' in merged).toBe(false) + expect('createdAt' in merged).toBe(false) + expect('createdWithAgent' in merged).toBe(false) + expect('automationProvenance' in merged).toBe(false) + expect('cliProvenance' in merged).toBe(false) + expect('priorWorktreeIds' in merged).toBe(false) + }) + + it('carries project-first ownership fields through when present', () => { + const merged = mergeRuntimeFolderWorkspace( + repo(), + 'id', + meta({ + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + manualOrder: 3, + createdAt: 42, + priorWorktreeIds: ['old-id'] + }) + ) + + expect(merged.projectId).toBe('project-1') + expect(merged.hostId).toBe('local') + expect(merged.projectHostSetupId).toBe('setup-1') + expect(merged.manualOrder).toBe(3) + expect(merged.createdAt).toBe(42) + expect(merged.priorWorktreeIds).toEqual(['old-id']) + }) + + it('keeps an explicit workspace status instead of the default', () => { + expect( + mergeRuntimeFolderWorkspace(repo(), 'id', meta({ workspaceStatus: 'done' })).workspaceStatus + ).toBe('done') + }) +}) diff --git a/src/main/runtime/runtime-folder-workspace.ts b/src/main/runtime/runtime-folder-workspace.ts new file mode 100644 index 00000000000..fcbbacbd51d --- /dev/null +++ b/src/main/runtime/runtime-folder-workspace.ts @@ -0,0 +1,74 @@ +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' +import type { Repo, Worktree, WorktreeMeta } from '../../shared/types' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' + +export function getRuntimeFolderWorkspaceRootId(repo: Repo): string { + return `${repo.id}::${repo.path}` +} + +export function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { + return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` +} + +export function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { + const rootId = getRuntimeFolderWorkspaceRootId(repo) + return ( + worktreeId === rootId || + worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) +} + +export function mergeRuntimeFolderWorkspace( + repo: Repo, + worktreeId: string, + meta: WorktreeMeta +): Worktree { + const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta.creatorProvenance) + return { + id: worktreeId, + ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), + repoId: repo.id, + ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), + ...(creatorProvenance ? { creatorProvenance } : {}), + path: repo.path, + head: '', + branch: '', + isBare: false, + isMainWorktree: worktreeId === getRuntimeFolderWorkspaceRootId(repo), + displayName: meta.displayName || repo.displayName, + comment: meta.comment || '', + linkedIssue: meta.linkedIssue ?? null, + linkedPR: meta.linkedPR ?? null, + linkedLinearIssue: meta.linkedLinearIssue ?? null, + linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, + linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, + linkedGitLabMR: meta.linkedGitLabMR ?? null, + linkedGitLabIssue: meta.linkedGitLabIssue ?? null, + linkedBitbucketPR: meta.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: meta.linkedGiteaPR ?? null, + linkedWorkItem: meta.linkedWorkItem ?? null, + linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, + isArchived: meta.isArchived ?? false, + isUnread: meta.isUnread ?? false, + isPinned: meta.isPinned ?? false, + sortOrder: meta.sortOrder ?? 0, + ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: meta.lastActivityAt ?? 0, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), + ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), + ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), + workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + diffComments: meta.diffComments, + mobileDiffReview: meta.mobileDiffReview + } +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 2df636da4c0..5ccffe24550 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -6026,7 +6026,7 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { const wideStartGate = new Promise((resolve) => { releaseWideStart = resolve }) - let wideStopSpy: ReturnType | null = null + let wideStopSpy: ReturnType = null vi.spyOn(target, 'startWebSocketTransport').mockImplementation(async (opts) => { if (opts.host === '0.0.0.0') { // Why: hold the widen mid-flight (loopback already stopped) so stop() must race the rebind. diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f4d5006303a..326801c6fa9 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -316,6 +316,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'host.wsl.isAvailable', 'host.wsl.listDistros', 'hostedReview.create', + 'hostedReview.createStacked', 'hostedReview.forBranch', 'hostedReview.getCreationEligibility', 'linear.getCustomView', @@ -715,6 +716,7 @@ export class OrcaRuntimeRpcServer { endpoint, deviceToken: device.token, publicKeyB64, + pairedDeviceId: device.deviceId, scope }) return { @@ -927,6 +929,7 @@ export class OrcaRuntimeRpcServer { endpoint: direct.endpoint, deviceToken: device.token, publicKeyB64, + pairedDeviceId: device.deviceId, scope: 'mobile', relay: relayPairing.relay }) @@ -1406,7 +1409,7 @@ export class OrcaRuntimeRpcServer { // never orphan a running 0.0.0.0 listener outside activeTransports (and thus outside stop()). this.activeTransports[index] = widened.transport const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') - if (metaIndex >= 0) { + if (metaIndex !== -1) { this.transports[metaIndex] = { kind: 'websocket', endpoint: widened.endpoint } } try { @@ -1448,7 +1451,7 @@ export class OrcaRuntimeRpcServer { ) this.activeTransports.splice(index, 1) const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') - if (metaIndex >= 0) { + if (metaIndex !== -1) { this.transports.splice(metaIndex, 1) } this.wsBoundHost = null @@ -1463,7 +1466,7 @@ export class OrcaRuntimeRpcServer { // orphan a live transport outside activeTransports (and thus outside stop()). this.activeTransports[index] = restored.transport const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') - if (metaIndex >= 0) { + if (metaIndex !== -1) { this.transports[metaIndex] = { kind: 'websocket', endpoint: restored.endpoint } } try { diff --git a/src/main/runtime/selected-review-branch.test.ts b/src/main/runtime/selected-review-branch.test.ts new file mode 100644 index 00000000000..533c971480b --- /dev/null +++ b/src/main/runtime/selected-review-branch.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { + getSelectedReviewBranch, + getSelectedReviewLookupHints, + isAllowedPushTargetRemoteConflict, + isMatchingSelectedGitHubPr, + type SelectedReviewBranchInput +} from './selected-review-branch' + +// characterization: current behavior + +const PROVIDER_CASES: { + label: string + input: SelectedReviewBranchInput + expected: { provider: string; number: number } | null +}[] = [ + { label: 'no linked review', input: {}, expected: null }, + { label: 'github', input: { linkedPR: 7 }, expected: { provider: 'github', number: 7 } }, + { + label: 'gitlab', + input: { linkedGitLabMR: 12 }, + expected: { provider: 'gitlab', number: 12 } + }, + { + label: 'bitbucket', + input: { linkedBitbucketPR: 3 }, + expected: { provider: 'bitbucket', number: 3 } + }, + { + label: 'azure devops', + input: { linkedAzureDevOpsPR: 44 }, + expected: { provider: 'azure-devops', number: 44 } + }, + { label: 'gitea', input: { linkedGiteaPR: 9 }, expected: { provider: 'gitea', number: 9 } }, + { + label: 'zero is a real number', + input: { linkedPR: 0 }, + expected: { provider: 'github', number: 0 } + }, + { + label: 'null linked ids are skipped', + input: { linkedPR: null, linkedGitLabMR: null }, + expected: null + }, + { + label: 'github wins over gitlab when both are set', + input: { linkedPR: 1, linkedGitLabMR: 2 }, + expected: { provider: 'github', number: 1 } + }, + { + label: 'gitlab wins over bitbucket when both are set', + input: { linkedGitLabMR: 2, linkedBitbucketPR: 3 }, + expected: { provider: 'gitlab', number: 2 } + } +] + +describe('getSelectedReviewBranch', () => { + it.each(PROVIDER_CASES)('resolves $label', ({ input, expected }) => { + expect(getSelectedReviewBranch(input)).toEqual(expected) + }) +}) + +type ExistingPr = Parameters[0] + +const pr = (number: number): ExistingPr => ({ number }) as ExistingPr + +describe('isMatchingSelectedGitHubPr', () => { + it('matches when the PR number and the branch override both line up', () => { + expect( + isMatchingSelectedGitHubPr(pr(7), { linkedPR: 7, branchNameOverride: 'feat' }, 'feat') + ).toBe(true) + }) + + it('rejects a different PR number', () => { + expect( + isMatchingSelectedGitHubPr(pr(8), { linkedPR: 7, branchNameOverride: 'feat' }, 'feat') + ).toBe(false) + }) + + it('rejects when the branch override does not equal the branch', () => { + expect( + isMatchingSelectedGitHubPr(pr(7), { linkedPR: 7, branchNameOverride: 'other' }, 'feat') + ).toBe(false) + }) + + it('rejects when there is no branch override at all', () => { + expect(isMatchingSelectedGitHubPr(pr(7), { linkedPR: 7 }, 'feat')).toBe(false) + }) + + it('rejects a non-GitHub selection even when the branch override matches', () => { + expect( + isMatchingSelectedGitHubPr(pr(7), { linkedGitLabMR: 7, branchNameOverride: 'feat' }, 'feat') + ).toBe(false) + }) + + it('returns false for a null existing PR', () => { + expect( + isMatchingSelectedGitHubPr(null, { linkedPR: 7, branchNameOverride: 'feat' }, 'feat') + ).toBe(false) + }) +}) + +describe('isAllowedPushTargetRemoteConflict', () => { + const args: SelectedReviewBranchInput = { + linkedGitLabMR: 12, + branchNameOverride: 'feat', + pushTarget: { remoteName: 'origin', branchName: 'feat' } + } + + it('allows a remote conflict on the selected review branch', () => { + expect(isAllowedPushTargetRemoteConflict('remote', 'feat', args)).toBe(true) + }) + + it('does not allow a local conflict', () => { + expect(isAllowedPushTargetRemoteConflict('local', 'feat', args)).toBe(false) + }) + + it('does not allow a null conflict kind', () => { + expect(isAllowedPushTargetRemoteConflict(null, 'feat', args)).toBe(false) + }) + + it('requires a linked review', () => { + expect( + isAllowedPushTargetRemoteConflict('remote', 'feat', { + branchNameOverride: 'feat', + pushTarget: args.pushTarget + }) + ).toBe(false) + }) + + it('requires the push target to name the same branch', () => { + expect( + isAllowedPushTargetRemoteConflict('remote', 'feat', { + ...args, + pushTarget: { remoteName: 'origin', branchName: 'elsewhere' } + }) + ).toBe(false) + }) + + it('requires a push target', () => { + expect( + isAllowedPushTargetRemoteConflict('remote', 'feat', { + linkedGitLabMR: 12, + branchNameOverride: 'feat' + }) + ).toBe(false) + }) +}) + +describe('getSelectedReviewLookupHints', () => { + it('normalizes every absent hint to null', () => { + expect(getSelectedReviewLookupHints({})).toEqual({ + linkedGitHubPR: null, + linkedGitLabMR: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null + }) + }) + + it('renames linkedPR to linkedGitHubPR and passes the rest through', () => { + expect( + getSelectedReviewLookupHints({ + linkedPR: 7, + linkedGitLabMR: 12, + linkedBitbucketPR: 3, + linkedAzureDevOpsPR: 44, + linkedGiteaPR: 9 + }) + ).toEqual({ + linkedGitHubPR: 7, + linkedGitLabMR: 12, + linkedBitbucketPR: 3, + linkedAzureDevOpsPR: 44, + linkedGiteaPR: 9 + }) + }) + + it('keeps 0 rather than coercing it to null', () => { + expect(getSelectedReviewLookupHints({ linkedPR: 0 }).linkedGitHubPR).toBe(0) + }) +}) diff --git a/src/main/runtime/selected-review-branch.ts b/src/main/runtime/selected-review-branch.ts new file mode 100644 index 00000000000..f874ab8a1ab --- /dev/null +++ b/src/main/runtime/selected-review-branch.ts @@ -0,0 +1,93 @@ +import type { GitPushTarget } from '../../shared/types' +import type { ForgeProviderId } from '../source-control/forge-provider' +import type { getPRForBranch } from '../github/client' + +export type SelectedReviewBranchInput = { + branchNameOverride?: string + linkedPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + pushTarget?: GitPushTarget +} + +type SelectedReviewBranch = { + provider: ForgeProviderId + number: number +} + +export function getSelectedReviewBranch( + args: SelectedReviewBranchInput +): SelectedReviewBranch | null { + if (typeof args.linkedPR === 'number') { + return { provider: 'github', number: args.linkedPR } + } + if (typeof args.linkedGitLabMR === 'number') { + return { provider: 'gitlab', number: args.linkedGitLabMR } + } + if (typeof args.linkedBitbucketPR === 'number') { + return { provider: 'bitbucket', number: args.linkedBitbucketPR } + } + if (typeof args.linkedAzureDevOpsPR === 'number') { + return { provider: 'azure-devops', number: args.linkedAzureDevOpsPR } + } + if (typeof args.linkedGiteaPR === 'number') { + return { provider: 'gitea', number: args.linkedGiteaPR } + } + return null +} + +function isSelectedGitHubPrBranchOverride( + args: SelectedReviewBranchInput, + branchName: string +): boolean { + return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName +} + +function isSelectedReviewBranchOverride( + args: SelectedReviewBranchInput, + branchName: string +): boolean { + return getSelectedReviewBranch(args) !== null && args.branchNameOverride === branchName +} + +export function isMatchingSelectedGitHubPr( + existingPR: Awaited>, + args: SelectedReviewBranchInput, + branchName: string +): boolean { + return Boolean( + existingPR && + isSelectedGitHubPrBranchOverride(args, branchName) && + existingPR.number === args.linkedPR + ) +} + +export function isAllowedPushTargetRemoteConflict( + conflictKind: 'local' | 'remote' | null, + branchName: string, + args: SelectedReviewBranchInput +): boolean { + return ( + conflictKind === 'remote' && + isSelectedReviewBranchOverride(args, branchName) && + args.pushTarget?.branchName === branchName + ) +} + +export function getSelectedReviewLookupHints(args: SelectedReviewBranchInput): { + linkedGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null +} { + return { + linkedGitHubPR: args.linkedPR ?? null, + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null + } +} diff --git a/src/main/runtime/terminal-output-path-candidates.ts b/src/main/runtime/terminal-output-path-candidates.ts new file mode 100644 index 00000000000..de9eb82c0f8 --- /dev/null +++ b/src/main/runtime/terminal-output-path-candidates.ts @@ -0,0 +1,269 @@ +/* eslint-disable no-control-regex -- Why: PTY output interleaves ANSI escapes with paths, so candidate scanning must terminate matches on the control bytes themselves. */ +// Path candidates harvested from PTY output so terminal artifact links can be +// resolved without re-scanning the whole transcript. +const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 +const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 +const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024 + +export function appendRecentPtyPathCandidates( + previous: string[] | undefined, + data: string +): string[] { + const extractedCandidates = extractTerminalOutputPathCandidates(data) + if (extractedCandidates.length === 0) { + // Why: pathless output is the hot path; reuse immutable history so each chunk doesn't clone and byte-scan up to 1,024 old candidates. + return previous ?? [] + } + const next = previous ? previous.slice() : [] + for (const candidate of extractedCandidates) { + if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { + continue + } + next.push(candidate) + } + return pruneRecentPtyPathCandidates(next) +} + +export function recentTerminalPathCandidatesIncludePath( + recentCandidates: readonly string[], + pathText: string, + absolutePath: string +): boolean { + const candidates = new Set( + [ + pathText, + absolutePath, + ...wslTerminalOutputAliases(pathText), + ...wslTerminalOutputAliases(absolutePath) + ] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0) + ) + for (const recent of recentCandidates) { + if (candidates.has(recent)) { + return true + } + } + return false +} + +function pruneRecentPtyPathCandidates(candidates: string[]): string[] { + const countBounded = + candidates.length > RECENT_PTY_PATH_CANDIDATE_LIMIT + ? candidates.slice(-RECENT_PTY_PATH_CANDIDATE_LIMIT) + : candidates + let totalBytes = 0 + let startIndex = countBounded.length + for (let index = countBounded.length - 1; index >= 0; index -= 1) { + const nextTotal = totalBytes + Buffer.byteLength(countBounded[index]!, 'utf8') + if (nextTotal > RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES) { + break + } + totalBytes = nextTotal + startIndex = index + } + return startIndex === 0 ? countBounded : countBounded.slice(startIndex) +} + +export function recentTerminalOutputIncludesPath( + recentOutput: string, + pathText: string, + absolutePath: string +): boolean { + const candidates = new Set( + [pathText, absolutePath] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0) + ) + if (candidates.size === 0) { + return false + } + for (const candidate of candidates) { + if (outputContainsPathCandidate(recentOutput, candidate)) { + return true + } + } + const decodedOutput = decodeTerminalOutputPercentEscapes(recentOutput) + if (decodedOutput !== recentOutput) { + for (const candidate of candidates) { + if (outputContainsPathCandidate(decodedOutput, candidate)) { + return true + } + } + } + return false +} + +function outputContainsPathCandidate(output: string, candidate: string): boolean { + let start = output.indexOf(candidate) + while (start !== -1) { + const end = start + candidate.length + if (isPathCandidateStartBoundary(output, start) && isPathCandidateEndBoundary(output, end)) { + return true + } + start = output.indexOf(candidate, start + 1) + } + return false +} + +function isPathCandidateStartBoundary(output: string, start: number): boolean { + if (start === 0) { + return true + } + if (output.slice(0, start).endsWith('file://')) { + return true + } + if ( + /^[A-Za-z]:[\\/]/.test(output.slice(start)) && + /file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)?\/$/i.test(output.slice(0, start)) + ) { + return true + } + if (/file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)$/i.test(output.slice(0, start))) { + return true + } + return !isPathCandidateContinuationChar(output[start - 1]!) +} + +function isPathCandidateEndBoundary(output: string, end: number): boolean { + const next = output[end] + if (!next) { + return true + } + if (next === ':' && /^\d+(?::\d+)?(?:\D|$)/.test(output.slice(end + 1))) { + return true + } + return !isPathCandidateContinuationChar(next) +} + +function isPathCandidateContinuationChar(char: string): boolean { + return /[A-Za-z0-9._~/%+@\\()[\]-]/.test(char) +} + +function decodeTerminalOutputPercentEscapes(value: string): string { + return value.replace(/(?:%[0-9a-f]{2})+/gi, (match) => { + try { + return decodeURIComponent(match) + } catch { + return match + } + }) +} + +// Why: the extension regex backtracks quadratically on the PTY hot path; candidates can't cross a newline, so scan per line and skip over-long lines (dropped anyway). +function extractTerminalOutputPathCandidates(data: string): string[] { + const candidates: string[] = [] + const add = (value: string): void => { + const candidate = trimTerminalOutputPathCandidate(value) + if (candidate.length > 0) { + candidates.push(candidate) + const drivePath = normalizeTerminalOutputFileUriDrivePath(candidate) + if (drivePath) { + candidates.push(drivePath) + } + } + } + for (const line of data.split(/[\r\n]+/)) { + if (line.length === 0 || line.length > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { + continue + } + collectTerminalOutputLinePathCandidates(line, add) + } + return candidates +} + +function collectTerminalOutputLinePathCandidates(line: string, add: (value: string) => void): void { + for (const match of line.matchAll(/file:\/\/([^/\s]*)(\/[^\s\x1b"'<>)]*)/gi)) { + const authority = match[1] ?? '' + const uriPath = match[2] + if (uriPath) { + const decoded = decodeTerminalOutputPercentEscapes(uriPath) + add(isTerminalOutputLoopbackAuthority(authority) ? decoded : `//${authority}${decoded}`) + } + } + for (const match of line.matchAll( + /(?:\/(?:tmp|private\/tmp)\/|[A-Za-z]:[\\/])[^\r\n\x1b"'<>]+/g + )) { + if (isInsideNonLocalFileUri(line, match.index)) { + continue + } + add(match[0]) + } + for (const match of line.matchAll( + /\/[^\r\n\x1b"'<>]*\.[A-Za-z0-9_+-]+(?:[#:\s][^\r\n\x1b"'<>]*)?/g + )) { + if (isInsideNonLocalFileUri(line, match.index)) { + continue + } + add(match[0]) + } +} + +function normalizeTerminalOutputFileUriDrivePath(candidate: string): string | null { + return /^\/[A-Za-z]:[\\/]/.test(candidate) ? candidate.slice(1) : null +} + +function trimTerminalOutputPathCandidate(value: string): string { + let candidate = value.trim().replace(/[),;.]+$/g, '') + if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { + return '' + } + let selected: string | null = null + for (const match of candidate.matchAll( + /.+?\.[A-Za-z0-9_+-]+(?:#L\d+(?:C\d+)?|(?::\d+)?(?::\d+)?)?(?=\s+|$)/gi + )) { + const end = match.index + match[0].length + const text = candidate.slice(0, end) + if (countTerminalOutputPathStarts(text) > 1) { + continue + } + // Same as the tap parsers: a line-end token extends the candidate only when the added segment is path-like, so trailing prose isn't swallowed. + if ( + end < candidate.length || + selected === null || + /[\\/]/.test(candidate.slice(selected.length, end)) + ) { + selected = text + } + } + return trimTerminalOutputPathLocator(selected ?? candidate) +} + +function isTerminalOutputLoopbackAuthority(authority: string): boolean { + const normalized = authority.toLowerCase() + return ( + normalized === '' || + normalized === 'localhost' || + normalized === '127.0.0.1' || + normalized === '::1' || + normalized === '[::1]' + ) +} + +function isInsideNonLocalFileUri(output: string, pathStart: number): boolean { + const prefix = output.slice(0, pathStart) + const match = /file:\/\/([^/\s]*)$/i.exec(prefix) + return !!match && !isTerminalOutputLoopbackAuthority(match[1] ?? '') +} + +function countTerminalOutputPathStarts(value: string): number { + let count = 0 + for (const match of value.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { + void match + count += 1 + } + return count +} + +function trimTerminalOutputPathLocator(value: string): string { + return value.replace(/#L\d+(?:C\d+)?$/i, '').replace(/:\d+(?::\d+)?$/, '') +} + +function wslTerminalOutputAliases(value: string): string[] { + const match = /^\\\\wsl(?:\.localhost|\$)\\[^\\]+(\\.*)$/i.exec(value) + if (!match) { + return [] + } + const linuxPath = match[1]!.replace(/\\/g, '/') + return linuxPath.startsWith('/') ? [linuxPath] : [`/${linuxPath}`] +} diff --git a/src/main/runtime/windows-firewall-remote-scope.ts b/src/main/runtime/windows-firewall-remote-scope.ts index 9845413d079..6968435a8aa 100644 --- a/src/main/runtime/windows-firewall-remote-scope.ts +++ b/src/main/runtime/windows-firewall-remote-scope.ts @@ -109,7 +109,7 @@ function maskPrefixLength(maskText: string, version: IpVersion): number | null { function parseIpRange(scope: string): IpRange | null { const trimmed = scope.trim() const dashIndex = trimmed.indexOf('-') - if (dashIndex >= 0) { + if (dashIndex !== -1) { if (dashIndex !== trimmed.lastIndexOf('-')) { return null } @@ -122,7 +122,7 @@ function parseIpRange(scope: string): IpRange | null { } const slashIndex = trimmed.indexOf('/') - if (slashIndex >= 0) { + if (slashIndex !== -1) { if (slashIndex !== trimmed.lastIndexOf('/')) { return null } @@ -195,7 +195,7 @@ function expandEmbeddedIpv4(address: string): string | null { } const lastColon = address.lastIndexOf(':') const ipv4 = parseIpv4(address.slice(lastColon + 1)) - if (lastColon < 0 || !ipv4) { + if (lastColon === -1 || !ipv4) { return null } const high = ((ipv4.value >> 16n) & 0xffffn).toString(16) diff --git a/src/main/runtime/workspace-session-failed-write-rollback.ts b/src/main/runtime/workspace-session-failed-write-rollback.ts new file mode 100644 index 00000000000..f770511bb27 --- /dev/null +++ b/src/main/runtime/workspace-session-failed-write-rollback.ts @@ -0,0 +1,62 @@ +import { isDeepStrictEqual } from 'node:util' +import type { WorkspaceSessionState } from '../../shared/types' + +const MISSING = Symbol('missing') +type RollbackValue = unknown + +function isRecord(value: RollbackValue): value is Record { + return ( + value !== MISSING && + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) +} + +function rollbackValue( + original: RollbackValue, + staged: RollbackValue, + current: RollbackValue +): RollbackValue { + if (isDeepStrictEqual(original, staged)) { + return current + } + if (isDeepStrictEqual(current, staged)) { + return original + } + if (!isRecord(original) || !isRecord(staged) || !isRecord(current)) { + return current + } + let changed = false + const next: Record = { ...current } + for (const key of new Set([ + ...Object.keys(original), + ...Object.keys(staged), + ...Object.keys(current) + ])) { + const value = rollbackValue( + Object.hasOwn(original, key) ? original[key] : MISSING, + Object.hasOwn(staged, key) ? staged[key] : MISSING, + Object.hasOwn(current, key) ? current[key] : MISSING + ) + if (value === MISSING) { + if (Object.hasOwn(next, key)) { + delete next[key] + changed = true + } + } else if (!Object.hasOwn(current, key) || !isDeepStrictEqual(current[key], value)) { + next[key] = value + changed = true + } + } + return changed ? next : current +} + +export function rollbackWorkspaceSessionAfterFailedAsyncWrite( + original: WorkspaceSessionState, + staged: WorkspaceSessionState, + current: WorkspaceSessionState +): WorkspaceSessionState { + return rollbackValue(original, staged, current) as WorkspaceSessionState +} diff --git a/src/main/shell-prompt-readiness-probe.test.ts b/src/main/shell-prompt-readiness-probe.test.ts new file mode 100644 index 00000000000..c2c783953ac --- /dev/null +++ b/src/main/shell-prompt-readiness-probe.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const lineEditorProbe = vi.hoisted(() => vi.fn()) +const processReadinessProbe = vi.hoisted(() => vi.fn()) +const resolveExecutablePath = vi.hoisted(() => vi.fn((value: string) => Promise.resolve(value))) +vi.mock('../shared/pty-slave-line-discipline-echo', () => ({ + createPtySlaveLineEditorProbe: () => lineEditorProbe +})) +vi.mock('../shared/shell-process-readiness', () => ({ + readShellProcessReadiness: processReadinessProbe, + resolveShellExecutablePath: resolveExecutablePath +})) + +import { createShellPromptReadinessProbe } from './shell-prompt-readiness-probe' + +describe('shell prompt readiness probe', () => { + beforeEach(() => { + vi.useFakeTimers() + lineEditorProbe.mockReset() + processReadinessProbe.mockReset() + resolveExecutablePath.mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('accepts only the identified shell pid in line-editor mode and foreground', async () => { + lineEditorProbe.mockResolvedValue('line-editor') + processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true }) + const onPromptReady = vi.fn() + const probe = createShellPromptReadinessProbe({ + slavePath: '/dev/ttys048', + shellPath: '/bin/zsh', + getShellPid: () => 42, + onPromptReady, + settleMs: 10 + }) + + probe?.notifyOutput('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(10) + + expect(onPromptReady).toHaveBeenCalledOnce() + }) + + it('preserves an unset child PATH for executable resolution', async () => { + lineEditorProbe.mockResolvedValue('line-editor') + processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true }) + const probe = createShellPromptReadinessProbe({ + slavePath: '/dev/ttys048', + shellPath: '/bin/zsh', + getShellPid: () => 42, + onPromptReady: vi.fn(), + settleMs: 10 + }) + + probe?.notifyOutput('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(10) + + expect(resolveExecutablePath).toHaveBeenCalledWith('/bin/zsh', process.cwd(), undefined) + }) + + it.each([ + ['silent read', 'other', { executablePath: '/bin/zsh', foreground: true }], + ['background shell', 'line-editor', { executablePath: '/bin/zsh', foreground: false }], + ['different foreground process', 'line-editor', null], + [ + 'non-shell replacement image', + 'line-editor', + { executablePath: '/usr/bin/sqlite3', foreground: true } + ], + [ + 'non-shell image with the shell basename', + 'line-editor', + { executablePath: '/tmp/zsh', foreground: true } + ] + ])('rejects %s', async (_name, terminalState, rows) => { + lineEditorProbe.mockResolvedValue(terminalState) + processReadinessProbe.mockResolvedValue(rows) + const onPromptReady = vi.fn() + const probe = createShellPromptReadinessProbe({ + slavePath: '/dev/ttys048', + shellPath: '/bin/zsh', + getShellPid: () => 42, + onPromptReady, + settleMs: 10 + }) + + probe?.notifyOutput('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(10) + + expect(onPromptReady).not.toHaveBeenCalled() + if (terminalState === 'other') { + expect(processReadinessProbe).not.toHaveBeenCalled() + } + }) + + it('does no external work when the ready marker cancels the settle window', async () => { + const probe = createShellPromptReadinessProbe({ + slavePath: '/dev/ttys048', + shellPath: '/bin/zsh', + getShellPid: () => 42, + onPromptReady: vi.fn(), + settleMs: 10 + }) + + probe?.notifyOutput('\x1b[?2004h') + probe?.dispose() + await vi.advanceTimersByTimeAsync(10) + + expect(lineEditorProbe).not.toHaveBeenCalled() + expect(processReadinessProbe).not.toHaveBeenCalled() + }) + + it('invalidates an in-flight result when newer output arrives', async () => { + const pending: { resolve?: (value: string) => void } = {} + lineEditorProbe.mockImplementation( + () => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void)) + ) + processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true }) + const onPromptReady = vi.fn() + const probe = createShellPromptReadinessProbe({ + slavePath: '/dev/ttys048', + shellPath: '/bin/zsh', + getShellPid: () => 42, + onPromptReady, + settleMs: 10 + }) + + probe?.notifyOutput('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(10) + probe?.notifyOutput('\x1b[?2004h') + pending.resolve?.('line-editor') + await vi.advanceTimersByTimeAsync(0) + + expect(onPromptReady).not.toHaveBeenCalled() + expect(processReadinessProbe).not.toHaveBeenCalled() + }) + + it('does not inspect a process after disposal during a line-editor probe', async () => { + const pending: { resolve?: (value: string) => void } = {} + lineEditorProbe.mockImplementation( + () => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void)) + ) + const probe = createShellPromptReadinessProbe({ + slavePath: '/dev/ttys048', + shellPath: '/bin/zsh', + getShellPid: () => 42, + onPromptReady: vi.fn(), + settleMs: 10 + }) + + probe?.notifyOutput('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(10) + probe?.dispose() + pending.resolve?.('line-editor') + await vi.advanceTimersByTimeAsync(0) + + expect(processReadinessProbe).not.toHaveBeenCalled() + }) + + it('invalidates process readiness that resolves after disposal', async () => { + const pending: { resolve?: (value: { executablePath: string; foreground: boolean }) => void } = + {} + lineEditorProbe.mockResolvedValue('line-editor') + processReadinessProbe.mockImplementation( + () => new Promise((resolve) => (pending.resolve = resolve)) + ) + const onPromptReady = vi.fn() + const probe = createShellPromptReadinessProbe({ + slavePath: '/dev/ttys048', + shellPath: '/bin/zsh', + getShellPid: () => 42, + onPromptReady, + settleMs: 10 + }) + + probe?.notifyOutput('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(10) + probe?.dispose() + pending.resolve?.({ executablePath: '/bin/zsh', foreground: true }) + await vi.advanceTimersByTimeAsync(0) + + expect(onPromptReady).not.toHaveBeenCalled() + }) + + it('ignores slow startup output until the line editor enables its protocol', async () => { + const probe = createShellPromptReadinessProbe({ + slavePath: '/dev/ttys048', + shellPath: '/bin/zsh', + getShellPid: () => 42, + onPromptReady: vi.fn(), + settleMs: 10 + }) + + for (let index = 0; index < 20; index += 1) { + probe?.notifyOutput(`startup ${index}\n`) + await vi.advanceTimersByTimeAsync(20) + } + + expect(lineEditorProbe).not.toHaveBeenCalled() + expect(processReadinessProbe).not.toHaveBeenCalled() + }) + + it('bounds rejected line-editor retries', async () => { + lineEditorProbe.mockResolvedValue('line-editor') + processReadinessProbe.mockResolvedValue({ + executablePath: '/usr/bin/sqlite3', + foreground: true + }) + const probe = createShellPromptReadinessProbe({ + slavePath: '/dev/ttys048', + shellPath: '/bin/zsh', + getShellPid: () => 42, + onPromptReady: vi.fn(), + settleMs: 10 + }) + + for (let index = 0; index < 10; index += 1) { + probe?.notifyOutput('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(10) + } + + expect(lineEditorProbe).toHaveBeenCalledTimes(4) + expect(processReadinessProbe).toHaveBeenCalledTimes(4) + }) +}) diff --git a/src/main/shell-prompt-readiness-probe.ts b/src/main/shell-prompt-readiness-probe.ts new file mode 100644 index 00000000000..11583fd2de7 --- /dev/null +++ b/src/main/shell-prompt-readiness-probe.ts @@ -0,0 +1,107 @@ +import { createPtySlaveLineEditorProbe } from '../shared/pty-slave-line-discipline-echo' +import { + readShellProcessReadiness, + resolveShellExecutablePath +} from '../shared/shell-process-readiness' +import { + createLineEditorReadyOutputScanState, + scanForLineEditorReadyOutput +} from './line-editor-ready-output-scanner' +import { basename } from 'node:path' + +export const SHELL_PROMPT_PROBE_SETTLE_MS = 50 +export const MAX_SHELL_PROMPT_PROBES = 4 + +export type ShellPromptReadinessProbe = { + notifyOutput(data: string): void + dispose(): void +} + +export function createShellPromptReadinessProbe(options: { + slavePath: string | undefined + getShellPid: () => number | null + shellPath: string | undefined + shellCwd?: string + shellPathEnv?: string + onPromptReady: () => void + settleMs?: number +}): ShellPromptReadinessProbe | null { + const lineEditorProbe = createPtySlaveLineEditorProbe(options.slavePath) + if (!lineEditorProbe) { + return null + } + const settleMs = options.settleMs ?? SHELL_PROMPT_PROBE_SETTLE_MS + const expectedShellName = options.shellPath ? basename(options.shellPath).toLowerCase() : null + const outputScanState = createLineEditorReadyOutputScanState() + let disposed = false + let timer: ReturnType | null = null + let generation = 0 + let probesStarted = 0 + + const probe = async (scheduledGeneration: number): Promise => { + if (disposed || scheduledGeneration !== generation) { + return + } + const shellPid = options.getShellPid() + if (!shellPid || (await lineEditorProbe()) !== 'line-editor') { + return + } + if (disposed || scheduledGeneration !== generation) { + return + } + const [shell, expectedPath] = await Promise.all([ + readShellProcessReadiness(shellPid), + options.shellPath + ? resolveShellExecutablePath( + options.shellPath, + options.shellCwd ?? process.cwd(), + options.shellPathEnv + ) + : Promise.resolve(null) + ]) + if (disposed || scheduledGeneration !== generation) { + return + } + if ( + !shell?.foreground || + !expectedShellName || + !expectedPath || + basename(shell.executablePath).toLowerCase() !== expectedShellName || + shell.executablePath !== expectedPath + ) { + return + } + disposed = true + options.onPromptReady() + } + + return { + notifyOutput(data: string): void { + if ( + disposed || + probesStarted >= MAX_SHELL_PROMPT_PROBES || + !scanForLineEditorReadyOutput(outputScanState, data) + ) { + return + } + generation += 1 + const scheduledGeneration = generation + if (timer) { + clearTimeout(timer) + } + timer = setTimeout(() => { + timer = null + probesStarted += 1 + void probe(scheduledGeneration).catch(() => {}) + }, settleMs) + }, + dispose(): void { + disposed = true + generation += 1 + if (timer) { + clearTimeout(timer) + timer = null + } + } + } +} diff --git a/src/main/shell-startup-identity-scanner.test.ts b/src/main/shell-startup-identity-scanner.test.ts new file mode 100644 index 00000000000..2b1607c23e6 --- /dev/null +++ b/src/main/shell-startup-identity-scanner.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + createShellStartupIdentityScanState, + drainShellStartupIdentityHeldBytes, + scanForShellStartupIdentity +} from './shell-startup-identity-scanner' + +describe('shell startup identity scanner', () => { + it('strips a split identity marker and returns its shell pid', () => { + const state = createShellStartupIdentityScanState() + expect(scanForShellStartupIdentity(state, 'before\x1b]777;orca-shell-st')).toEqual({ + output: 'before', + shellPid: null + }) + expect(scanForShellStartupIdentity(state, 'art:12345\x07after')).toEqual({ + output: 'after', + shellPid: 12345 + }) + }) + + it('forwards lookalikes unchanged', () => { + const state = createShellStartupIdentityScanState() + const input = 'a\x1b]777;orca-shell-start:nope\x07b' + expect(scanForShellStartupIdentity(state, input)).toEqual({ output: input, shellPid: null }) + }) + + it('forwards an unrelated OSC ending in digits', () => { + const state = createShellStartupIdentityScanState() + const input = '\x1b]1337;remote-session:12345' + expect(scanForShellStartupIdentity(state, input)).toEqual({ output: input, shellPid: null }) + expect(state.heldBytes).toBe('') + }) + + it('releases an incomplete marker on teardown', () => { + const state = createShellStartupIdentityScanState() + scanForShellStartupIdentity(state, '\x1b]777;orca-shell-start:12') + expect(drainShellStartupIdentityHeldBytes(state)).toBe('\x1b]777;orca-shell-start:12') + }) + + it('does not retain an unbounded digit stream', () => { + const state = createShellStartupIdentityScanState() + const input = `\x1b]777;orca-shell-start:${'1'.repeat(100)}` + expect(scanForShellStartupIdentity(state, input).output).toBe(input) + expect(state.heldBytes).toBe('') + }) +}) diff --git a/src/main/shell-startup-identity-scanner.ts b/src/main/shell-startup-identity-scanner.ts new file mode 100644 index 00000000000..234b69e6bbf --- /dev/null +++ b/src/main/shell-startup-identity-scanner.ts @@ -0,0 +1,72 @@ +export const SHELL_STARTUP_IDENTITY_PREFIX = '\x1b]777;orca-shell-start:' +const POSSIBLE_PID_SUFFIX = /^\d{0,20}$/ + +export type ShellStartupIdentityScanState = { + heldBytes: string +} + +export type ShellStartupIdentityScanResult = { + output: string + shellPid: number | null +} + +export function createShellStartupIdentityScanState(): ShellStartupIdentityScanState { + return { heldBytes: '' } +} + +export function drainShellStartupIdentityHeldBytes(state: ShellStartupIdentityScanState): string { + const heldBytes = state.heldBytes + state.heldBytes = '' + return heldBytes +} + +function isPossibleMarker(candidate: string): boolean { + if (candidate.length <= SHELL_STARTUP_IDENTITY_PREFIX.length) { + return SHELL_STARTUP_IDENTITY_PREFIX.startsWith(candidate) + } + if (!candidate.startsWith(SHELL_STARTUP_IDENTITY_PREFIX)) { + return false + } + const suffix = candidate.slice(SHELL_STARTUP_IDENTITY_PREFIX.length) + return POSSIBLE_PID_SUFFIX.test(suffix) +} + +export function scanForShellStartupIdentity( + state: ShellStartupIdentityScanState, + data: string +): ShellStartupIdentityScanResult { + let pending = state.heldBytes + data + let output = '' + state.heldBytes = '' + + while (pending.length > 0) { + const start = pending.indexOf(SHELL_STARTUP_IDENTITY_PREFIX[0] as string) + if (start === -1) { + output += pending + break + } + output += pending.slice(0, start) + const candidate = pending.slice(start) + if (isPossibleMarker(candidate)) { + state.heldBytes = candidate + break + } + if (candidate.startsWith(SHELL_STARTUP_IDENTITY_PREFIX)) { + const suffix = candidate.slice(SHELL_STARTUP_IDENTITY_PREFIX.length) + const terminator = suffix.indexOf('\x07') + const pidText = terminator === -1 ? '' : suffix.slice(0, terminator) + if (/^\d+$/.test(pidText)) { + const shellPid = Number(pidText) + const markerLength = SHELL_STARTUP_IDENTITY_PREFIX.length + terminator + 1 + return { + output: output + candidate.slice(markerLength), + shellPid: Number.isSafeInteger(shellPid) && shellPid > 0 ? shellPid : null + } + } + } + output += candidate[0] + pending = candidate.slice(1) + } + + return { output, shellPid: null } +} diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index e6372979069..5b481217d90 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -5,11 +5,17 @@ function quotePosixSingle(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } +export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `if [[ "\${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then + unset ORCA_SHELL_STARTUP_IDENTITY + printf "\\033]777;orca-shell-start:%s\\007" "$$" +fi` + export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string { const header = headerPrefix ? `Orca ${headerPrefix} zsh shell-ready wrapper` : 'Orca zsh shell-ready wrapper' return `# ${header} +${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} # Why: capture the runtime wrapper dir before it is unset below. On WSL this # file is generated with a Windows path but sourced via /mnt/c, so the baked # literal is unusable there and ZDOTDIR must be restored from this value. diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index ae5e5623a97..c31d84b3c87 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -1,7 +1,9 @@ +import { supportsHostedReviewCreation } from '../../shared/hosted-review-creation-providers' import { beforeEach, describe, expect, it, vi } from 'vitest' const { createGitHubPullRequestMock, + createBitbucketPullRequestMock, createGitLabMergeRequestMock, createAzureDevOpsPullRequestMock, createGiteaPullRequestMock, @@ -16,6 +18,7 @@ const { getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), + createBitbucketPullRequestMock: vi.fn(), createGitLabMergeRequestMock: vi.fn(), createAzureDevOpsPullRequestMock: vi.fn(), createGiteaPullRequestMock: vi.fn(), @@ -60,6 +63,10 @@ vi.mock('../bitbucket/client', () => ({ getBitbucketPullRequest: vi.fn() })) +vi.mock('../bitbucket/pull-request-creation', () => ({ + createBitbucketPullRequest: createBitbucketPullRequestMock +})) + vi.mock('../azure-devops/client', () => ({ getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, getAzureDevOpsPullRequestForBranch: vi.fn(), @@ -99,6 +106,7 @@ describe('forge provider interface', () => { beforeEach(() => { createGitHubPullRequestMock.mockReset() createGitLabMergeRequestMock.mockReset() + createBitbucketPullRequestMock.mockReset() createAzureDevOpsPullRequestMock.mockReset() createGiteaPullRequestMock.mockReset() getAzureDevOpsRepoSlugMock.mockReset() @@ -170,10 +178,16 @@ describe('forge provider interface', () => { ).toEqual([ ['gitlab', true], ['github', true], - ['bitbucket', false], + ['bitbucket', true], ['azure-devops', true], ['gitea', true] ]) + // Why: the shared list is what the Create blocker and the renderer read. + // When it drifted from this one, Bitbucket had a working createReview but + // still reported "provider does not support creating a pull request". + for (const provider of FORGE_PROVIDERS) { + expect(supportsHostedReviewCreation(provider.id)).toBe(provider.supportsReviewCreation) + } createGitHubPullRequestMock.mockResolvedValue({ ok: true, number: 12, @@ -201,6 +215,28 @@ describe('forge provider interface', () => { }) }) + it('routes Bitbucket review creation through the shared provider contract', async () => { + createBitbucketPullRequestMock.mockResolvedValue({ + ok: true, + number: 23, + url: 'https://bitbucket.org/team/orca/pull-requests/23' + }) + + const provider = getForgeProviderById('bitbucket') + const input = { + provider: 'bitbucket' as const, + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + } + await expect(provider.createReview?.('/repo', input)).resolves.toEqual({ + ok: true, + number: 23, + url: 'https://bitbucket.org/team/orca/pull-requests/23' + }) + expect(createBitbucketPullRequestMock).toHaveBeenCalledWith('/repo', input) + }) + it('routes GitLab review creation through the shared provider contract', async () => { createGitLabMergeRequestMock.mockResolvedValue({ ok: true, diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index 19526ad71fa..a3aef433110 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -15,6 +15,7 @@ import { getBitbucketPullRequestForBranchOrThrow, getBitbucketRepoSlug } from '../bitbucket/client' +import { createBitbucketPullRequest } from '../bitbucket/pull-request-creation' import { getGiteaPullRequest, getGiteaPullRequestForBranchOrThrow, @@ -65,7 +66,7 @@ export type ForgeReviewByNumberInput = ForgeProviderRepositoryContext & { export type ForgeProvider = { id: ForgeProviderId supportsReviewCreation: boolean - resolveRepository(context: ForgeProviderRepositoryContext): Promise + resolveRepository(context: ForgeProviderRepositoryContext): Promise getReviewForBranch(input: ForgeReviewForBranchInput): Promise getReviewByNumber(input: ForgeReviewByNumberInput): Promise createReview?( @@ -198,7 +199,7 @@ const gitHubForgeProvider = { const bitbucketForgeProvider = { id: 'bitbucket', - supportsReviewCreation: false, + supportsReviewCreation: true, resolveRepository: (context) => getBitbucketRepoSlug( context.repoPath, @@ -225,7 +226,8 @@ const bitbucketForgeProvider = { ...hostedReviewExecutionArgs(input) ) return pr ? mapBitbucketReview(pr) : null - } + }, + createReview: createBitbucketPullRequest } satisfies ForgeProvider const azureDevOpsForgeProvider = { diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index f2fa711ee54..2cf262e8cca 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -472,29 +472,31 @@ describe('getHostedReviewCreationEligibility', () => { blockedReason: null, nextAction: null, defaultBaseRef: 'origin/main', - head: 'feature/create-pr' + head: 'feature/create-pr', + stackedCreationSupported: true }) }) it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => { mockGitHubEnterpriseProvider() - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ + const result = await getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + + expect(result).toMatchObject({ provider: 'github', canCreate: true, blockedReason: null, nextAction: null }) + expect(result).not.toHaveProperty('stackedCreationSupported') // Enterprise auth was already confirmed during detection; the gate must not // fire a redundant gh probe. diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index f28c319f3ea..0fa50ef3a32 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -18,7 +18,10 @@ import { } from '../../shared/hosted-review-creation-providers' import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation' import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation' +import { isBitbucketReviewCreationAuthenticated } from '../bitbucket/pull-request-creation' import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' +import { getRepoSlug } from '../github/client' +import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/types' @@ -291,6 +294,14 @@ function reviewCopy(provider: HostedReviewProvider): { authInstruction: 'Set ORCA_GITEA_TOKEN' } } + if (provider === 'bitbucket') { + return { + shortLabel: 'PR', + reviewLabel: 'pull request', + providerName: 'Bitbucket', + authInstruction: 'Connect Bitbucket in Settings > Integrations' + } + } return { shortLabel: 'PR', reviewLabel: 'pull request', @@ -314,6 +325,11 @@ async function isProviderAuthenticated( if (provider === 'gitea') { return isGiteaReviewCreationAuthenticated() } + if (provider === 'bitbucket') { + // Why: falling through to the GitHub check made Create PR unusable for + // anyone with Bitbucket connected but no `gh auth login`. + return isBitbucketReviewCreationAuthenticated() + } return isGitHubAuthenticated(repoPath, connectionId, options) } @@ -521,12 +537,19 @@ export async function getHostedReviewCreationEligibility( : lookupFailed ? 'unavailable' : 'not_found' + const githubRepository = + provider === 'github' + ? await getRepoSlug(args.repoPath, args.connectionId, args).catch(() => null) + : null const baseResult = { provider, review: review ? { number: review.number, url: review.url } : null, reviewLookupOutcome, defaultBaseRef, - head: branch || null + head: branch || null, + ...(githubRepository && isDefaultGitHubHost(githubRepository.host) + ? { stackedCreationSupported: true } + : {}) } if (!branch || branch === 'HEAD') { diff --git a/src/main/source-control/stacked-hosted-review-creation.test.ts b/src/main/source-control/stacked-hosted-review-creation.test.ts new file mode 100644 index 00000000000..7814e66e6c8 --- /dev/null +++ b/src/main/source-control/stacked-hosted-review-creation.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { prepareMock, registerMock, createMock } = vi.hoisted(() => ({ + prepareMock: vi.fn(), + registerMock: vi.fn(), + createMock: vi.fn() +})) + +vi.mock('../github/stacked-pr-creation', () => ({ + prepareGitHubStackedPullRequest: prepareMock, + registerGitHubStackedPullRequest: registerMock +})) + +vi.mock('./hosted-review-creation', () => ({ createHostedReview: createMock })) + +import { createStackedHostedReview } from './stacked-hosted-review-creation' + +const input = { + provider: 'github' as const, + base: 'stack/parent', + head: 'stack/child', + title: 'Child' +} +const repository = { owner: 'acme', repo: 'orca', host: 'github.com' } +const parentReview = { number: 41, url: 'https://github.com/acme/orca/pull/41' } +const currentReview = { number: 42, url: 'https://github.com/acme/orca/pull/42' } + +beforeEach(() => { + prepareMock.mockReset() + registerMock.mockReset() + createMock.mockReset() +}) + +describe('createStackedHostedReview', () => { + it('creates the current PR before registering the stack', async () => { + prepareMock.mockResolvedValue({ + ok: true, + repository, + parentReview, + currentReview: null + }) + createMock.mockResolvedValue({ ok: true, ...currentReview }) + registerMock.mockResolvedValue({ + ok: true, + ...currentReview, + parentReview, + stackNumber: 50 + }) + + const result = await createStackedHostedReview('/repo', input, 'ssh-1') + + expect(result).toMatchObject({ ok: true, stackNumber: 50 }) + expect(createMock).toHaveBeenCalledWith('/repo', input, 'ssh-1', {}) + expect(registerMock).toHaveBeenCalledWith( + expect.objectContaining({ parentReview, currentReview, connectionId: 'ssh-1' }) + ) + }) + + it('retries registration without creating a duplicate PR', async () => { + prepareMock.mockResolvedValue({ + ok: true, + repository, + parentReview, + currentReview + }) + registerMock.mockResolvedValue({ + ok: true, + ...currentReview, + parentReview, + stackNumber: 50 + }) + + await createStackedHostedReview('/repo', input) + + expect(createMock).not.toHaveBeenCalled() + expect(registerMock).toHaveBeenCalledOnce() + }) + + it('does not create a PR when the parent topology is invalid', async () => { + prepareMock.mockResolvedValue({ + ok: false, + code: 'validation', + error: 'Choose the top pull request.' + }) + + const result = await createStackedHostedReview('/repo', input) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + expect(createMock).not.toHaveBeenCalled() + expect(registerMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/source-control/stacked-hosted-review-creation.ts b/src/main/source-control/stacked-hosted-review-creation.ts new file mode 100644 index 00000000000..53327cbd404 --- /dev/null +++ b/src/main/source-control/stacked-hosted-review-creation.ts @@ -0,0 +1,60 @@ +import type { + CreateStackedHostedReviewInput, + CreateStackedHostedReviewResult, + HostedReviewSummary +} from '../../shared/hosted-review' +import { + prepareGitHubStackedPullRequest, + registerGitHubStackedPullRequest +} from '../github/stacked-pr-creation' +import { createHostedReview } from './hosted-review-creation' +import type { HostedReviewExecutionOptions } from './hosted-review-git-options' + +export async function createStackedHostedReview( + repoPath: string, + input: CreateStackedHostedReviewInput, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const plan = await prepareGitHubStackedPullRequest(repoPath, input, connectionId, options) + if (!plan.ok) { + return plan + } + + let currentReview: (HostedReviewSummary & { number: number }) | null = plan.currentReview + if (!currentReview) { + const created = await createHostedReview(repoPath, input, connectionId, options) + if (!created.ok) { + if (!created.existingReview?.number) { + return created + } + return { + ok: false, + code: 'validation', + error: + 'An open pull request already exists for this branch but does not target the selected parent branch.', + createdReview: { + number: created.existingReview.number, + url: created.existingReview.url + } + } + } + currentReview = { number: created.number, url: created.url } + } + if (!currentReview) { + return { + ok: false, + code: 'unknown_completion', + error: 'Pull request creation may have completed. Retry to finish stack registration.' + } + } + + return registerGitHubStackedPullRequest({ + repoPath, + repository: plan.repository, + parentReview: plan.parentReview, + currentReview, + connectionId, + options + }) +} diff --git a/src/main/sqlite/sync-database.test.ts b/src/main/sqlite/sync-database.test.ts new file mode 100644 index 00000000000..34677095b26 --- /dev/null +++ b/src/main/sqlite/sync-database.test.ts @@ -0,0 +1,153 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import SyncDatabase from './sync-database' + +const temporaryDirectories: string[] = [] +const openDatabases: SyncDatabase.Database[] = [] + +async function createDatabase(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-')) + temporaryDirectories.push(directory) + const db = new SyncDatabase(join(directory, 'test.db')) + openDatabases.push(db) + db.exec( + 'CREATE TABLE items (id TEXT PRIMARY KEY, label TEXT); ' + + "INSERT INTO items (id, label) VALUES ('a', 'alpha'), ('b', 'beta')" + ) + return db +} + +afterEach(async () => { + for (const db of openDatabases.splice(0)) { + try { + db.close() + } catch { + // already closed by the test + } + } + await Promise.all( + temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +describe('SyncDatabase statement cache', () => { + it('reuses the same statement object for identical SQL', async () => { + const db = await createDatabase() + const sql = 'SELECT label FROM items WHERE id = ?' + + expect(db.prepare(sql)).toBe(db.prepare(sql)) + expect(db.prepare('SELECT id FROM items WHERE id = ?')).not.toBe(db.prepare(sql)) + }) + + it('returns correct rows when a reused statement is bound to different values', async () => { + const db = await createDatabase() + const sql = 'SELECT label FROM items WHERE id = ?' + + expect(db.prepare(sql).get('a')).toEqual({ label: 'alpha' }) + expect(db.prepare(sql).get('b')).toEqual({ label: 'beta' }) + expect(db.prepare(sql).get('missing')).toBeUndefined() + expect(db.prepare(sql).all('a')).toEqual([{ label: 'alpha' }]) + }) + + it('bounds the cache so per-arity SQL cannot grow it without limit', async () => { + const db = await createDatabase() + const first = 'SELECT 0 AS n' + const firstStatement = db.prepare(first) + for (let index = 1; index <= 256; index += 1) { + db.prepare(`SELECT ${index} AS n`) + } + + expect(db.prepare(first)).not.toBe(firstStatement) + expect(db.prepare('SELECT 256 AS n')).toBe(db.prepare('SELECT 256 AS n')) + }) + + it('drops cached statements on close', async () => { + const db = await createDatabase() + db.prepare('SELECT label FROM items WHERE id = ?') + db.close() + + const cache = (db as unknown as { statementCache: Map }).statementCache + expect(cache.size).toBe(0) + }) + + it('does not serve a stale statement after DDL adds a column', async () => { + const db = await createDatabase() + expect(db.prepare('SELECT * FROM items WHERE id = ?').all('a')).toEqual([ + { id: 'a', label: 'alpha' } + ]) + + db.exec("ALTER TABLE items ADD COLUMN note TEXT; UPDATE items SET note = 'noted'") + + expect(db.prepare('SELECT * FROM items WHERE id = ?').all('a')).toEqual([ + { id: 'a', label: 'alpha', note: 'noted' } + ]) + expect(db.prepare('SELECT label FROM items WHERE id = ?').all('a')).toEqual([ + { label: 'alpha' } + ]) + }) + + it('keeps a cached statement correct when another connection changes the schema', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-')) + temporaryDirectories.push(directory) + const path = join(directory, 'shared.db') + const writer = new SyncDatabase(path) + const reader = new SyncDatabase(path) + openDatabases.push(writer, reader) + writer.exec("CREATE TABLE items (id TEXT PRIMARY KEY); INSERT INTO items VALUES ('a')") + const sql = 'SELECT id FROM items ORDER BY id' + expect(reader.prepare(sql).all()).toEqual([{ id: 'a' }]) + + writer.exec("ALTER TABLE items ADD COLUMN note TEXT; INSERT INTO items VALUES ('b', 'noted')") + + expect(reader.prepare(sql).all()).toEqual([{ id: 'a' }, { id: 'b' }]) + expect(reader.prepare('SELECT note FROM items WHERE id = ?').all('b')).toEqual([ + { note: 'noted' } + ]) + }) + + it('does not cache wildcard selects or pragma statements', async () => { + const db = await createDatabase() + + expect(db.prepare('SELECT * FROM items')).not.toBe(db.prepare('SELECT * FROM items')) + expect(db.prepare('PRAGMA table_info(items)')).not.toBe(db.prepare('PRAGMA table_info(items)')) + expect(db.prepare('SELECT COUNT(*) AS n FROM items')).toBe( + db.prepare('SELECT COUNT(*) AS n FROM items') + ) + }) + + it('keeps cached statements across transaction control and other non-DDL exec calls', async () => { + const db = await createDatabase() + const sql = 'SELECT label FROM items WHERE id = ?' + const statement = db.prepare(sql) + + db.exec('BEGIN IMMEDIATE') + db.exec("INSERT INTO items (id, label) VALUES ('c', 'gamma')") + db.exec('COMMIT') + + expect(db.prepare(sql)).toBe(statement) + expect(statement.get('c')).toEqual({ label: 'gamma' }) + }) + + it('preserves pragma and exec behavior', async () => { + const db = await createDatabase() + + expect(db.pragma('journal_mode', { simple: true })).toBe('delete') + expect(db.pragma('table_info(items)')).toEqual([ + expect.objectContaining({ name: 'id' }), + expect.objectContaining({ name: 'label' }) + ]) + expect(db.pragma('table_info(missing_table)')).toEqual([]) + expect(db.pragma('table_info(missing_table)', { simple: true })).toBeUndefined() + }) + + it('rejects a missing file when fileMustExist is set', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-')) + temporaryDirectories.push(directory) + + expect(() => new SyncDatabase(join(directory, 'absent.db'), { fileMustExist: true })).toThrow( + /does not exist/ + ) + }) +}) diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 70d8fa1c06a..68faef8b83c 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -15,6 +15,18 @@ type PragmaOptions = { export type SqliteStatement = StatementSync +// Why: dynamic `IN (?,?,…)` clauses mint a new SQL string per arity, so the cache must stay bounded. +const STATEMENT_CACHE_LIMIT = 256 +const AGGREGATE_STAR = /\(\s*\*\s*\)/g +const PRAGMA_STATEMENT = /^\s*PRAGMA\b/i +const SCHEMA_CHANGING_SQL = /\b(?:ALTER|CREATE|DROP|REINDEX|VACUUM|ATTACH|DETACH)\b/i + +// Why: node:sqlite builds the first post-schema-change row from stale column names, so a reused +// wildcard SELECT can drop a freshly added column; PRAGMAs are one-shot config, never hot-path. +function isStatementCacheable(sql: string): boolean { + return !PRAGMA_STATEMENT.test(sql) && !sql.replace(AGGREGATE_STAR, '').includes('*') +} + // Why: SSH companions target Node 18 and import this adapter without opening SQLite. function loadDatabaseSync(): typeof DatabaseSync { if (typeof process.getBuiltinModule !== 'function') { @@ -26,6 +38,7 @@ function loadDatabaseSync(): typeof DatabaseSync { class SyncDatabase { private readonly db: DatabaseSync + private readonly statementCache = new Map() constructor(path: SqlitePath, options: SyncDatabaseOptions = {}) { if ( @@ -44,11 +57,31 @@ class SyncDatabase { } exec(sql: string): void { + // Why: drop cached statements before DDL lands so a partially applied batch cannot leave stale ones. + if (SCHEMA_CHANGING_SQL.test(sql)) { + this.statementCache.clear() + } this.db.exec(sql) } prepare(sql: string): StatementSync { - return this.db.prepare(sql) + const cached = this.statementCache.get(sql) + if (cached) { + this.statementCache.delete(sql) + this.statementCache.set(sql, cached) + return cached + } + const statement = this.db.prepare(sql) + if (isStatementCacheable(sql)) { + if (this.statementCache.size >= STATEMENT_CACHE_LIMIT) { + const oldest = this.statementCache.keys().next().value + if (oldest !== undefined) { + this.statementCache.delete(oldest) + } + } + this.statementCache.set(sql, statement) + } + return statement } pragma(sql: string, options?: PragmaOptions): unknown { @@ -64,6 +97,7 @@ class SyncDatabase { } close(): void { + this.statementCache.clear() this.db.close() } } diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index da61921ca6a..f675ac6ed9d 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -63,24 +63,8 @@ export const JsonRpcErrorCode = { * 256KB raw → ~340KB base64, well under MAX_MESSAGE_SIZE. */ export const STREAM_CHUNK_SIZE = 256 * 1024 -/** Cap on concurrent in-flight streams per relay; mirrors fs.watch's - * 20-watcher cap idiom. Prevents file-descriptor exhaustion from a buggy - * client. */ -export const MAX_CONCURRENT_STREAMS = 16 - // ── Git response streaming (see docs/relay-git-response-stream-design.md) ── -/** Serialized-JSON size above which the relay chunks a streamable git response - * (diff family + exec) onto the bulk lane instead of one JSON-RPC frame. Mirror - * of the relay-side constant; the client only opts in — the relay owns the - * decision — so this is documentation of the shared contract. */ -export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 - -/** Per-chunk size (serialized-result UTF-8 bytes) for git response streaming. - * The client reassembles by concatenation and does not depend on this value, - * so it stays cross-version safe. */ -export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 - /** Sentinel the relay returns as the RPC result when the real payload streams * as git.responseChunk frames. Absent from old relays, so a new client falls * back to the plain result they return. */ @@ -131,6 +115,13 @@ export type JsonRpcNotification = { export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification +const JSON_RPC_PAYLOAD_BYTES = Symbol('jsonRpcPayloadBytes') + +export type PreparedJsonRpcPayload = Readonly<{ + byteLength: number + [JSON_RPC_PAYLOAD_BYTES]: Buffer +}> + // ── Framing: encode / decode ──────────────────────────────────────── /** @@ -157,11 +148,23 @@ export function encodeFrame( } export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer { + return encodePreparedJsonRpcFrame(prepareJsonRpcPayload(msg), id, ack) +} + +export function prepareJsonRpcPayload(msg: JsonRpcMessage): PreparedJsonRpcPayload { const payload = Buffer.from(JSON.stringify(msg), 'utf-8') if (payload.length > MAX_MESSAGE_SIZE) { throw new Error(`Message too large: ${payload.length} bytes (max ${MAX_MESSAGE_SIZE})`) } - return encodeFrame(MessageType.Regular, id, ack, payload) + return Object.freeze({ byteLength: payload.length, [JSON_RPC_PAYLOAD_BYTES]: payload }) +} + +export function encodePreparedJsonRpcFrame( + payload: PreparedJsonRpcPayload, + id: number, + ack: number +): Buffer { + return encodeFrame(MessageType.Regular, id, ack, payload[JSON_RPC_PAYLOAD_BYTES]) } export function encodeKeepAliveFrame(id: number, ack: number): Buffer { diff --git a/src/main/ssh/sftp-namespace-resolution.test.ts b/src/main/ssh/sftp-namespace-resolution.test.ts index 98ba2637c7d..5239e834152 100644 --- a/src/main/ssh/sftp-namespace-resolution.test.ts +++ b/src/main/ssh/sftp-namespace-resolution.test.ts @@ -35,10 +35,7 @@ const MARKER_STATS = { size: 0 } -function makeSftp(options: { - startPath?: string | Error | unknown - lstat?: (path: string) => LstatOutcome -}): { +function makeSftp(options: { startPath?: unknown; lstat?: (path: string) => LstatOutcome }): { sftp: SFTPWrapper realpathCalls: string[] lstatCalls: string[] diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 0000f2f353b..d87a7ad8f5b 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -39,7 +39,7 @@ export type SshMultiplexerRequestOptions = { export type NotificationHandler = (method: string, params: Record) => void export type MethodNotificationHandler = (params: Record) => void -export type RequestHandler = (params: Record) => Promise | unknown +export type RequestHandler = (params: Record) => unknown export type MultiplexerDisposeReason = 'shutdown' | 'connection_lost' diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts index 09be7c1f686..f903d71acf0 100644 --- a/src/main/ssh/ssh-port-scanner.test.ts +++ b/src/main/ssh/ssh-port-scanner.test.ts @@ -235,6 +235,40 @@ describe('PortScanner', () => { expect(scanner.getDetectedPorts('t1')).toEqual([]) }) + it('aborts in-flight requests before stopping or replacing a target scan', async () => { + const harness = createVisibilityHarness(true) + const signals: AbortSignal[] = [] + const request = vi.fn( + (_method: string, _params?: Record, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const signal = options?.signal + if (!signal) { + reject(new Error('missing request signal')) + return + } + signals.push(signal) + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const mux = { request } as unknown as SshChannelMultiplexer + const scanner = new PortScanner(harness.visibility) + + scanner.startScanning('t1', mux, vi.fn()) + expect(signals).toHaveLength(1) + expect(signals[0].aborted).toBe(false) + + scanner.startScanning('t1', mux, vi.fn()) + expect(signals).toHaveLength(2) + expect(signals[0].aborted).toBe(true) + expect(signals[1].aborted).toBe(false) + + scanner.stopScanning('t1') + expect(signals[1].aborted).toBe(true) + await vi.advanceTimersByTimeAsync(10 * BASE) + expect(request).toHaveBeenCalledTimes(2) + expect(harness.listenerCount()).toBe(0) + }) + it('keeps targets independent: stopping one host leaves the other scanning', async () => { const harness = createVisibilityHarness(true) let nextA = 3000 diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts index 6404d035f22..2dbabff597a 100644 --- a/src/main/ssh/ssh-port-scanner.ts +++ b/src/main/ssh/ssh-port-scanner.ts @@ -19,6 +19,7 @@ export type PortScannerWindowVisibility = { type ScanHandle = { timer: ReturnType | null + requestAbortController: AbortController | null intervalMs: number // Why: while the window is hidden the scan chain is parked outright — no // timer wakeups, no remote requests — and the visibility listener resumes @@ -48,6 +49,7 @@ export class PortScanner { const handle: ScanHandle = { timer: null, + requestAbortController: null, intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS, parkedWhileHidden: false, unsubscribeVisibility: () => {}, @@ -65,8 +67,12 @@ export class PortScanner { return } polling = true + const requestAbortController = new AbortController() + handle.requestAbortController = requestAbortController try { - const result = (await mux.request('ports.detect')) as { + const result = (await mux.request('ports.detect', undefined, { + signal: requestAbortController.signal + })) as { ports: DetectedPort[] platform: string } @@ -94,6 +100,9 @@ export class PortScanner { } catch { // Relay disconnected or request timed out — retry on next interval } finally { + if (handle.requestAbortController === requestAbortController) { + handle.requestAbortController = null + } polling = false } } @@ -142,6 +151,8 @@ export class PortScanner { if (handle.timer) { clearTimeout(handle.timer) } + handle.requestAbortController?.abort() + handle.requestAbortController = null handle.unsubscribeVisibility() this.handles.delete(targetId) } diff --git a/src/main/ssh/ssh-relay-session-relay-loss.test.ts b/src/main/ssh/ssh-relay-session-relay-loss.test.ts index 9e70466ab31..5053426131d 100644 --- a/src/main/ssh/ssh-relay-session-relay-loss.test.ts +++ b/src/main/ssh/ssh-relay-session-relay-loss.test.ts @@ -232,6 +232,53 @@ describe('SshRelaySession relay loss during setup', () => { expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') }) + // #13548 follow-up: cancelling the in-flight port scan emits rpc.cancel on the control + // lane, which a saturated writer turns into mux.dispose('connection_lost'). That fires + // during our own teardown, so it must not be reported as a lost relay. + it.each([ + ['detach', (session: SshRelaySession) => session.beginShutdownDetach()], + ['dispose', (session: SshRelaySession) => session.dispose()] + ])( + 'does not report relay loss when the port-scan cancel kills the mux on %s', + async (_path, teardown) => { + const { session, onRelayLost } = createSession() + // Mirrors SshChannelMultiplexer.request: an in-flight request whose signal aborts + // emits rpc.cancel, and the mock mux dies on that notify like a saturated writer. + muxRequestMock.mockImplementation( + async (method: string, _params?: unknown, options?: { signal?: AbortSignal }) => { + if (method !== 'ports.detect') { + return [] + } + const mux = session.getMux() as unknown as { + failNotifyMethod: string | null + notify: (method: string, params?: unknown) => void + } + mux.failNotifyMethod = 'rpc.cancel' + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + mux.notify('rpc.cancel', { id: 1 }) + reject(new Error('cancelled')) + }, + { once: true } + ) + }) + } + ) + + await session.establish({} as SshConnection) + expect(session.getState()).toBe('ready') + expect(muxRequestMock).toHaveBeenCalledWith('ports.detect', undefined, expect.anything()) + const mux = session.getMux() as unknown as { notify: ReturnType } + + teardown(session) + + expect(mux.notify).toHaveBeenCalledWith('rpc.cancel', { id: 1 }) + expect(onRelayLost).not.toHaveBeenCalled() + } + ) + // #11953: reattachKnownPtys swallows every per-PTY failure, so a mux killed by the // reattach burst itself never reaches the catch — the post-reattach gate has to notice. it('routes a mux that dies during PTY reattach into relay-loss recovery', async () => { diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index 48461af68ca..c5f084a8785 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -1,6 +1,5 @@ import { vi, type Mock } from 'vitest' import type { BrowserWindow } from 'electron' -import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session' import type { SshConnection } from './ssh-connection' import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' @@ -54,9 +53,3 @@ export function mockDeploySuccess(): void { platform: 'linux-x64' }) } - -export function createMismatchedOwnerRecoveryError(): unknown { - return Object.assign(new Error('Owner recovery lease is stale'), { - code: PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR - }) -} diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 8cb6e642812..820eb7e1360 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -438,7 +438,7 @@ export class SshRelaySession { async requestAiVaultSessionList( params: SshAiVaultRelayListParams, options: { signal?: AbortSignal; timeoutMs?: number } = {} - ): Promise { + ): Promise { if (this.aiVaultListMethodSupported === false) { return null } @@ -465,7 +465,7 @@ export class SshRelaySession { async requestAiVaultSessionTitles( params: SshAiVaultRelayTitleParams, options: { signal?: AbortSignal; timeoutMs?: number } = {} - ): Promise { + ): Promise { if (this.aiVaultTitleMethodSupported === false) { return null } @@ -640,6 +640,7 @@ export class SshRelaySession { return } + this.releaseRelayLossWatcher() // Cancel any in-flight reconnect this.abortController?.abort() const abortController = new AbortController() @@ -835,6 +836,7 @@ export class SshRelaySession { private runDisposal(pendingDetach: Promise | null): Promise { // Why: the whole in-memory half runs before any await so a concurrent connect can never observe // a half-torn session; only the durability barriers below are deferred onto the returned promise. + this.releaseRelayLossWatcher() this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() @@ -908,6 +910,7 @@ export class SshRelaySession { return } detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) + this.releaseRelayLossWatcher() this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() @@ -927,6 +930,7 @@ export class SshRelaySession { // step — a fast reconnect must reclaim this identity instead of minting one, even if a // teardown call below throws unexpectedly. detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) + this.releaseRelayLossWatcher() this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() @@ -947,9 +951,21 @@ export class SshRelaySession { // ── Private ─────────────────────────────────────────────────────── + // Why: teardown itself can kill the mux — an aborted request emits rpc.cancel, and a saturated + // control lane turns that admission failure into mux.dispose('connection_lost'). Every teardown + // path must release the watcher before its first mux write, or our own shutdown re-enters + // recovery as a spurious relay loss. teardownProviders is not early enough: stopPortScanning + // runs ahead of it and is what emits that frame. Call this ahead of abortController.abort() + // too — that signal reaches no mux request today, but plumbing it into one would otherwise + // reopen the same hole silently. + private releaseRelayLossWatcher(): void { + this.muxDisposeCleanup?.() + this.muxDisposeCleanup = null + } + // Why: onStateChange only fires on SSH-level reconnects, so watch for relay-channel loss while SSH stays up and fire onRelayLost. private watchMuxForRelayLoss(mux: SshChannelMultiplexer): void { - this.muxDisposeCleanup?.() + this.releaseRelayLossWatcher() this.muxDisposeCleanup = mux.onDispose((reason) => { if (reason === 'connection_lost' && this.mux === mux && !this.isDisposed()) { console.warn( @@ -1420,7 +1436,7 @@ export class SshRelaySession { }) } - // Why: ship plugin/extension source from Orca so agent-event changes don't force a relay redeploy (agent-status-over-ssh.md §4/§8). Best-effort. + // Why: ship plugin/extension source from Orca so agent-event changes don't force a relay redeploy — the relay is versioned independently. Best-effort: failure only costs agent status on this host. private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise { if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { return @@ -1497,7 +1513,7 @@ export class SshRelaySession { if (typeof envelope.paneKey !== 'string') { return } - // Why: forward env/version verbatim so cross-build warn-once diagnostics fire on remote events too (agent-status-over-ssh.md §3). + // Why: forward the agent CLI's env/version verbatim (not the relay's) so warn-once protocol-mismatch diagnostics fire for remote events too. agentHookServer.ingestRemote( { paneKey: envelope.paneKey, @@ -1560,8 +1576,7 @@ export class SshRelaySession { reason: 'shutdown' | 'connection_lost', outputGenerationReason: string = reason ): void { - this.muxDisposeCleanup?.() - this.muxDisposeCleanup = null + this.releaseRelayLossWatcher() this.muxNotificationCleanup?.() this.muxNotificationCleanup = null for (const cleanup of this.ptyRecoveryNotificationCleanups) { diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 23d57757b5b..482b0e8b113 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -134,6 +134,7 @@ describe('isRelayAlreadyInstalled', () => { const cmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(cmd).toContain('relay.js') expect(cmd).toContain('relay-watcher.js') + expect(cmd).toContain('relay-ai-vault-service.js') expect(cmd).toContain('managed-hook-runtime.js') expect(cmd).toContain('.install-complete') }) diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 13a1e2e246a..18738a930c6 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -117,6 +117,7 @@ describe('ssh remote command builders', () => { const probe = probeRelayInstalledCommand(posix, '/home/me/relay') expect(probe).toContain('test -d') expect(probe).toContain('managed-hook-runtime.js') + expect(probe).toContain('relay-ai-vault-service.js') }) it('uses encoded PowerShell for Windows deploy commands', () => { @@ -127,6 +128,7 @@ describe('ssh remote command builders', () => { const probe = probeRelayInstalledCommand(windows, 'C:/Users/me/relay') expect(probe).toContain('-EncodedCommand') expect(decodePowerShellCommand(probe)).toContain('managed-hook-runtime.js') + expect(decodePowerShellCommand(probe)).toContain('relay-ai-vault-service.js') }) it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => { diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 7cec0d027c2..603b6356a1f 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -91,6 +91,7 @@ export function probeRelayInstalledCommand( ): string { const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js') const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js') + const relayAiVaultServiceJs = joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js') const managedHookRuntimeJs = joinRemotePath(host, remoteRelayDir, 'managed-hook-runtime.js') const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete') if (!isWindowsRemoteHost(host)) { @@ -98,6 +99,7 @@ export function probeRelayInstalledCommand( `test -d ${shellEscape(remoteRelayDir)} ` + `&& test -f ${shellEscape(relayJs)} ` + `&& test -f ${shellEscape(relayWatcherJs)} ` + + `&& test -f ${shellEscape(relayAiVaultServiceJs)} ` + `&& test -f ${shellEscape(managedHookRuntimeJs)} ` + `&& test -f ${shellEscape(installComplete)} ` + `&& echo OK || echo MISSING` @@ -108,9 +110,10 @@ export function probeRelayInstalledCommand( `$dir = ${powerShellLiteral(remoteRelayDir)}`, `$relay = ${powerShellLiteral(relayJs)}`, `$watcher = ${powerShellLiteral(relayWatcherJs)}`, + `$aiVaultService = ${powerShellLiteral(relayAiVaultServiceJs)}`, `$managedHooks = ${powerShellLiteral(managedHookRuntimeJs)}`, `$complete = ${powerShellLiteral(installComplete)}`, - "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" + "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $aiVaultService -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" ].join('; ') ) } diff --git a/src/main/ssh/ssh-remote-node-toolchain-probe.ts b/src/main/ssh/ssh-remote-node-toolchain-probe.ts index b1e0b8d544a..d6d7f1d7a06 100644 --- a/src/main/ssh/ssh-remote-node-toolchain-probe.ts +++ b/src/main/ssh/ssh-remote-node-toolchain-probe.ts @@ -50,7 +50,7 @@ export function nodeToolchainVersionsMeetRequirements(versionOutput: string): bo function markedVersionMajor(output: string, marker: string): number | null { const lines = output.split(/\r?\n/) const markerIndex = lines.indexOf(marker) - if (markerIndex < 0) { + if (markerIndex === -1) { return null } for (const line of lines.slice(markerIndex + 1)) { diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index be5ed622927..6199dfeef52 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -55,6 +55,7 @@ exec /bin/sh -c "$cmd" function writeFakeRelay(dir: string): void { writeFileSync(join(dir, 'relay-watcher.js'), '') + writeFileSync(join(dir, 'relay-ai-vault-service.js'), '') writeFileSync(join(dir, 'managed-hook-runtime.js'), '') writeFileSync( join(dir, 'relay.js'), diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index eaf9f42aeec..54ecdc61424 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -396,6 +396,46 @@ describe('configureElectronNetworkCompatibility', () => { }) }) +describe('disableUnsupportedChromiumFeatures', () => { + it('disables FedCM before Chromium sessions are created', async () => { + const { app } = await import('electron') + const { disableUnsupportedChromiumFeatures } = await import('./configure-process') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + disableUnsupportedChromiumFeatures() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-features', 'FedCm') + }) + + it('preserves existing disabled Chromium features', async () => { + const { app } = await import('electron') + const { disableUnsupportedChromiumFeatures } = await import('./configure-process') + + vi.mocked(app.commandLine.getSwitchValue).mockReturnValueOnce('ExistingFeature') + vi.mocked(app.commandLine.appendSwitch).mockClear() + disableUnsupportedChromiumFeatures() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'disable-features', + 'FedCm,ExistingFeature' + ) + }) + + it('does not duplicate FedCM when disable-features already includes it', async () => { + const { app } = await import('electron') + const { disableUnsupportedChromiumFeatures } = await import('./configure-process') + + vi.mocked(app.commandLine.getSwitchValue).mockReturnValueOnce('FedCm,ExistingFeature') + vi.mocked(app.commandLine.appendSwitch).mockClear() + disableUnsupportedChromiumFeatures() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'disable-features', + 'FedCm,ExistingFeature' + ) + }) +}) + describe('enableMainProcessGpuFeatures', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const originalE2EUserDataDir = process.env.ORCA_E2E_USER_DATA_DIR diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index e545d89da9c..e39963bc9f2 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -66,6 +66,20 @@ export function configureElectronNetworkCompatibility( app.commandLine.appendSwitch('disable-http2') } +export function disableUnsupportedChromiumFeatures(): void { + appendDisabledChromiumFeatures(['FedCm']) +} + +function appendDisabledChromiumFeatures(features: string[]): void { + const existingFeatures = app.commandLine + .getSwitchValue('disable-features') + .split(',') + .map((feature) => feature.trim()) + .filter(Boolean) + const disabledFeatures = Array.from(new Set([...features, ...existingFeatures])).join(',') + app.commandLine.appendSwitch('disable-features', disabledFeatures) +} + function getProcessPathDelimiter(): string { return process.platform === 'win32' ? ';' : ':' } @@ -300,11 +314,7 @@ export function enableMainProcessGpuFeatures(): void { app.commandLine.appendSwitch('enable-features', features) } - const existingDisabledFeatures = app.commandLine.getSwitchValue('disable-features') // Why: IntensiveWakeUpThrottling clamps hidden-page timers to 1/min after 5min, delaying agent-done/bell notifications ~60s. // This opt-out is skipped under GPU fallback (win32-only today); if throttling ever reaches Windows it must move out of this path. - const disabledFeatures = ['IntensiveWakeUpThrottling', existingDisabledFeatures] - .filter(Boolean) - .join(',') - app.commandLine.appendSwitch('disable-features', disabledFeatures) + appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling']) } diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 001bc5252d1..3e40b5e5f57 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -26,15 +26,20 @@ describe('startup ordering', () => { expect(attachBlock).toContain( 'awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady' ) - expect(source).toContain('firstWindowStartupServicesReady = startupServices.firstWindowReady') - expect(source).toContain('localPtyStartupReady = startupServices.localPtyReady') + expect(source).toContain( + 'firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)' + ) + expect(source).toContain('localPtyStartupReady = services.then((value) => value.localPtyReady)') - const windowIndex = desktopStartup.indexOf('Promise.resolve(openMainWindow())') + const windowIndex = desktopStartup.indexOf('Promise.resolve(desktopWindow ?? openMainWindow())') const rpcStartIndex = desktopStartup.indexOf('desktopRuntimeRpc.start()') const legacyRpcStartIndex = desktopStartup.indexOf('runtimeRpc.start()') expect(windowIndex).toBeGreaterThanOrEqual(0) expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0) + expect(desktopStartup).toMatch( + /shellPathReady\s*\.then\(\(\) => (?:desktopRuntimeRpc|runtimeRpc)\.start\(\)\)/ + ) expect(desktopStartup).toContain('recordRuntimeRpcStartFailure(') // Why: `void`, not `await` — awaiting the dialog would park the rest of startup behind a modal. expect(desktopStartup).toMatch(/void showRuntimeRpcStartupFailureDialog\(\s*win,/) @@ -60,7 +65,7 @@ describe('startup ordering', () => { it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") - const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart) + const barrierEnd = source.indexOf("'app:startupDiagnostic'", barrierStart) const barrier = source.slice(barrierStart, barrierEnd) const reconciliationStart = source.indexOf( 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' @@ -68,22 +73,40 @@ describe('startup ordering', () => { const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) const serveEnd = source.indexOf('return', serveReady) - const desktopWindowStart = source.indexOf('Promise.resolve(openMainWindow())') + const desktopWindowStart = source.indexOf( + 'const desktopStartup = startWindowsDesktopBeforeShellPathReady(' + ) + const desktopWindowJoin = source.indexOf( + 'Promise.resolve(desktopWindow ?? openMainWindow())', + serveEnd + ) const serveStartup = source.slice(serveStart, serveEnd) - const desktopStartup = source.slice(serveEnd, desktopWindowStart) + const desktopStartup = source.slice(reconciliationStart, serveStart) + expect(barrierStart).toBeGreaterThanOrEqual(0) + expect(barrierEnd).toBeGreaterThan(barrierStart) expect(reconciliationStart).toBeGreaterThanOrEqual(0) expect(serveStart).toBeGreaterThan(reconciliationStart) expect(serveEnd).toBeGreaterThan(serveStart) // Why: bound against serveEnd, not reconciliationStart — an earlier openMainWindow() call // would steal this anchor, collapse desktopStartup to '', and pass the negative check below. - expect(desktopWindowStart).toBeGreaterThan(serveEnd) + expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) + expect(desktopWindowStart).toBeLessThan(serveStart) + expect(desktopWindowJoin).toBeGreaterThan(serveEnd) expect(serveStartup).toContain('await managedWslCliStartupBarrierReady') expect(serveStartup).not.toContain('await managedWslCliReconciliationReady') expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan( serveStartup.indexOf('await runtimeRpc.start()') ) expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') + expect(desktopStartup).toContain( + "process.platform === 'win32' && app.isPackaged && !serveOptions" + ) + expect(desktopStartup).toContain( + 'openWindow: () => openMainWindow({ revealOnDidFinishLoad: true })' + ) + expect(desktopStartup).toContain('shellPathReady,') + expect(desktopStartup).toContain('startServices: startTerminalRuntimeStartupServices') expect(barrier).toContain('managedWslCliStartupBarrierReady') expect(barrier).not.toContain('managedWslCliReconciliationReady') expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'") @@ -98,19 +121,29 @@ describe('startup ordering', () => { it('reconciles retained Codex homes after authoritative daemon inventory', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal') - const routeGateIndex = source.indexOf( - 'codexRuntimeHome?.isHostSystemDefaultRealHome()', + const retainedPaneGateIndex = source.indexOf( + 'hasRecordedManagedHostCodexPane()', daemonInitIndex ) const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex) + const hookReconciliationIndex = source.indexOf( + 'reconcileRetainedCodexHookHomes({', + inventoryIndex + ) const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex) - const desktopIndex = source.indexOf('Promise.resolve(openMainWindow())', serveIndex) + const desktopIndex = source.indexOf( + 'Promise.resolve(desktopWindow ?? openMainWindow())', + serveIndex + ) expect(daemonInitIndex).toBeGreaterThanOrEqual(0) - expect(routeGateIndex).toBeGreaterThan(daemonInitIndex) - expect(inventoryIndex).toBeGreaterThan(routeGateIndex) + expect(retainedPaneGateIndex).toBeGreaterThan(daemonInitIndex) + expect(inventoryIndex).toBeGreaterThan(daemonInitIndex) + expect(inventoryIndex).toBeGreaterThan(retainedPaneGateIndex) + expect(hookReconciliationIndex).toBeGreaterThan(inventoryIndex) + expect(hookReconciliationIndex).toBeLessThan(reconciliationIndex) expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) expect(serveIndex).toBeGreaterThan(reconciliationIndex) expect(desktopIndex).toBeGreaterThan(serveIndex) diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index 7ff6ee2ae81..f096dd03ed3 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -93,6 +93,26 @@ describe('hydrateShellPath', () => { expect(spawnCount).toBe(2) }) + it('continues the probe queue after a rejected hydration', async () => { + const rejectedSpawner = vi.fn(async () => { + throw new Error('profile failed') + }) + const successfulSpawner = vi.fn(async () => ({ + segments: ['/current'], + ok: true, + failureReason: 'none' + })) + + await expect( + hydrateShellPath({ shellOverride: '/bin/zsh', spawner: rejectedSpawner, force: true }) + ).rejects.toThrow('profile failed') + await expect( + hydrateShellPath({ shellOverride: '/bin/bash', spawner: successfulSpawner, force: true }) + ).resolves.toEqual({ segments: ['/current'], ok: true, failureReason: 'none' }) + + expect(successfulSpawner).toHaveBeenCalledWith('/bin/bash') + }) + it('returns failureReason:no_shell when no shell is available (Windows path)', async () => { const result = await hydrateShellPath({ shellOverride: null, @@ -162,6 +182,10 @@ describe('hydrateShellPath', () => { describe('mergePathSegments', () => { const originalPath = process.env.PATH + beforeEach(() => { + _resetHydrateShellPathCache() + }) + afterEach(() => { if (originalPath === undefined) { delete process.env.PATH diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 9411b0437d3..38a16816507 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -1,18 +1,15 @@ import { spawn } from 'node:child_process' -import { delimiter } from 'node:path' +import { delimiter, win32 as pathWin32 } from 'node:path' import type { ShellHydrationFailureReason } from '../../shared/types' +import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' +import { WindowsShellPathOwnership, windowsPathSegmentKey } from './windows-shell-path-ownership' -// Why: GUI-launched Electron on macOS/Linux inherits a minimal PATH from launchd -// that does not include dirs appended by the user's shell rc files (~/.zshrc, -// ~/.bashrc). Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta +// Why: GUI-launched Electron can miss PATH entries added by shell profiles. +// Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta/fnm // shims, and countless other user-local locations end up invisible to our // `which` probe even though they work fine from Terminal (see stablyai/orca#829). // -// Rather than play whack-a-mole adding every agent's install dir to a hardcoded -// list, we spawn the user's login shell once per app session and read the PATH -// it would export. This matches the behavior of every popular Electron app that -// handles this problem (Hyper, VS Code, Cursor, etc. via shell-env/fix-path) — -// we implement it inline to avoid adding a dependency. +// Probe the profile-loading shell once instead of hard-coding every tool's install path. const DELIMITER = '__ORCA_SHELL_PATH__' const SPAWN_TIMEOUT_MS = 5000 @@ -34,15 +31,35 @@ export type HydrationResult = } let cached: Promise | null = null +let probeQueue = Promise.resolve() +let configuredWindowsShell = 'powershell.exe' +let configuredWindowsGitBashPath: string | null = null +let configuredWindowsFallbackShell: string | null = null +let windowsShellConfigurationVersion = 0 +const windowsPathOwnership = new WindowsShellPathOwnership() /** @internal - tests need a clean hydration cache between cases. */ export function _resetHydrateShellPathCache(): void { cached = null + probeQueue = Promise.resolve() + configuredWindowsShell = 'powershell.exe' + configuredWindowsGitBashPath = null + configuredWindowsFallbackShell = null + windowsShellConfigurationVersion = 0 + windowsPathOwnership.reset() } function pickShell(): string | null { if (process.platform === 'win32') { - return null + const family = resolveWindowsShellStartupFamily(configuredWindowsShell) + if (family === 'cmd') { + return null + } + if (family === 'posix') { + return configuredWindowsGitBashPath + } + const basename = pathWin32.basename(configuredWindowsShell).toLowerCase() + return basename === 'powershell.exe' || basename === 'pwsh.exe' ? configuredWindowsShell : null } const shell = process.env.SHELL if (shell && shell.length > 0) { @@ -51,14 +68,14 @@ function pickShell(): string | null { return process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash' } -function parseCapturedPath(stdout: string): string[] { +function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): string[] { const cleaned = stdout.replace(ANSI_RE, '') const first = cleaned.indexOf(DELIMITER) - if (first < 0) { + if (first === -1) { return [] } const second = cleaned.indexOf(DELIMITER, first + DELIMITER.length) - if (second < 0) { + if (second === -1) { return [] } const value = cleaned.slice(first + DELIMITER.length, second).trim() @@ -70,32 +87,48 @@ function parseCapturedPath(stdout: string): string[] { return [ ...new Set( value - .split(delimiter) + .split(pathDelimiter) .map((s) => s.trim()) .filter(Boolean) ) ] } +function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } { + if (process.platform !== 'win32') { + const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` + return { args: ['-ilc', command], pathDelimiter: delimiter } + } + if (resolveWindowsShellStartupFamily(shell) === 'posix') { + // Why: native child processes cannot resolve Git Bash's /c/... PATH entries. + const command = `printf '%s' '${DELIMITER}'; cygpath -wp "$PATH"; printf '%s' '${DELIMITER}'` + return { args: ['-ilc', command], pathDelimiter: ';' } + } + const command = + `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` + + `[Console]::Write('${DELIMITER}'); [Console]::Write($env:Path); ` + + `[Console]::Write('${DELIMITER}')` + // Why: omitting -NoProfile is the behavior this probe exists to capture. + return { args: ['-NoLogo', '-Command', command], pathDelimiter: ';' } +} + function spawnShellAndReadPath(shell: string): Promise { return new Promise((resolve) => { - // Why: printing $PATH between delimiters is resilient to rc-file banners, - // MOTDs, and `echo` invocations that shells like fish print unprompted. - // `-ilc` runs the shell as a login+interactive so both .profile/.zprofile - // and .bashrc/.zshrc are sourced — matches what `which` in Terminal sees. - const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` + // Why: delimiters isolate PATH from profile banners and MOTDs. + const probe = shellPathProbe(shell) let finished = false let stdout = '' let timer: ReturnType | null = null - const child = spawn(shell, ['-ilc', command], { + const child = spawn(shell, probe.args, { // Why: inherit current env so the shell sees the same baseline, then let // it layer its own rc files on top. Do NOT forward stdio — some shells // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, // and we don't want that in Orca's console. env: process.env, stdio: ['ignore', 'pipe', 'ignore'], - detached: false + detached: false, + windowsHide: true }) const cleanup = (): void => { @@ -137,7 +170,7 @@ function spawnShellAndReadPath(shell: string): Promise { } const onClose = (): void => { - const segments = parseCapturedPath(stdout) + const segments = parseCapturedPath(stdout, probe.pathDelimiter) if (segments.length === 0) { finish({ segments: [], ok: false, failureReason: 'empty_path' }) return @@ -160,7 +193,7 @@ type HydrateOptions = { } /** - * Spawn the user's login shell once and return the PATH it would export. + * Spawn the user's profile-loading shell once and return the PATH it would export. * Caches the promise for the lifetime of the process — call * `_resetHydrateShellPathCache()` in tests or `hydrateShellPath({ force: true })` * when the user asks to re-probe (e.g. after installing a new CLI). @@ -169,17 +202,76 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise { + if (platform === 'win32') { + windowsPathOwnership.restore(process.env) + } + const result = await spawner(shell) + if (!result.ok && result.failureReason === 'spawn_error' && fallbackShell) { + return spawner(fallbackShell) + } + return result + }) + // Why: one rejected profile must not block later refreshes or shell changes. + probeQueue = probe.then( + () => undefined, + () => undefined + ) + cached = probe.then((result) => { + if ( + platform === 'win32' && + options.shellOverride === undefined && + configurationVersion !== windowsShellConfigurationVersion + ) { + return hydrateShellPath() + } + return result + }) return cached } +export function configureWindowsShellPathHydration( + shell: string | null | undefined, + gitBashPath: string | null = null, + fallbackShell: string | null = null +): void { + const next = shell?.trim() || 'powershell.exe' + if ( + next === configuredWindowsShell && + gitBashPath === configuredWindowsGitBashPath && + fallbackShell === configuredWindowsFallbackShell + ) { + return + } + windowsPathOwnership.restore(process.env) + configuredWindowsShell = next + configuredWindowsGitBashPath = gitBashPath + configuredWindowsFallbackShell = fallbackShell + windowsShellConfigurationVersion += 1 + cached = null +} + +function uniquePathSegments(segments: string[], pathKey: (segment: string) => string): string[] { + const seen = new Set() + return segments.filter((segment) => { + const key = pathKey(segment) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + /** * Promote shell-discovered PATH segments to the front of process.env.PATH, * preserving shell ordering and avoiding duplicates. Returns the segments that @@ -189,23 +281,33 @@ export function mergePathSegments(segments: string[]): string[] { if (segments.length === 0) { return [] } - const current = process.env.PATH ?? '' - const currentSegments = current.split(delimiter).filter(Boolean) - const shellSegments = [...new Set(segments)] - const shellSegmentSet = new Set(shellSegments) - const existing = new Set(currentSegments) - const added = shellSegments.filter((segment) => !existing.has(segment)) + if (process.platform === 'win32') { + windowsPathOwnership.restore(process.env) + } + const current = process.env.PATH ?? process.env.Path ?? '' + const pathDelimiter = process.platform === 'win32' ? pathWin32.delimiter : delimiter + const currentSegments = current.split(pathDelimiter).filter(Boolean) + const pathKey = + process.platform === 'win32' ? windowsPathSegmentKey : (segment: string): string => segment + const shellSegments = uniquePathSegments(segments, pathKey) + const shellSegmentSet = new Set(shellSegments.map(pathKey)) + const existing = new Set(currentSegments.map(pathKey)) + const added = shellSegments.filter((segment) => !existing.has(pathKey(segment))) const merged = [ ...shellSegments, - ...currentSegments.filter((segment) => !shellSegmentSet.has(segment)) + ...currentSegments.filter((segment) => !shellSegmentSet.has(pathKey(segment))) ] - const next = merged.join(delimiter) + const next = merged.join(pathDelimiter) if (next === current) { return [] } // Why: shell-provided entries must win over hardcoded packaged-app fallbacks. // A seeded fallback can point at a stale CLI while the user's shell resolves // a healthy one from the same directory list in a different order. - process.env.PATH = next + if (process.platform === 'win32') { + windowsPathOwnership.apply(process.env, next) + } else { + process.env.PATH = next + } return added } diff --git a/src/main/startup/hydrate-shell-path.windows.test.ts b/src/main/startup/hydrate-shell-path.windows.test.ts new file mode 100644 index 00000000000..ee1957c8a42 --- /dev/null +++ b/src/main/startup/hydrate-shell-path.windows.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { + _resetHydrateShellPathCache, + configureWindowsShellPathHydration, + hydrateShellPath, + mergePathSegments, + type HydrationResult +} from './hydrate-shell-path' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +type HydrationSpawner = (shell: string) => Promise + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function createMockShellProcess(): ChildProcessWithoutNullStreams { + const proc = new EventEmitter() as ChildProcessWithoutNullStreams + Object.assign(proc, { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin: new EventEmitter(), + kill: vi.fn() + }) + return proc +} + +describe('Windows shell PATH hydration', () => { + const originalPath = process.env.PATH + + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + _resetHydrateShellPathCache() + spawnMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + }) + + it('hydrates the default PowerShell profile PATH on Windows (#13328)', async () => { + const spawner = vi.fn(async () => ({ + segments: ['C:\\Users\\tester\\AppData\\Local\\fnm'], + ok: true, + failureReason: 'none' + })) + + await expect(hydrateShellPath({ spawner })).resolves.toEqual({ + segments: ['C:\\Users\\tester\\AppData\\Local\\fnm'], + ok: true, + failureReason: 'none' + }) + expect(spawner).toHaveBeenCalledWith('powershell.exe') + }) + + it('uses the configured PowerShell executable', async () => { + const spawner = vi.fn(async () => ({ + segments: ['C:\\tools'], + ok: true, + failureReason: 'none' + })) + + configureWindowsShellPathHydration('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + await hydrateShellPath({ spawner }) + + expect(spawner).toHaveBeenCalledWith('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + }) + + it('falls back to Windows PowerShell when preferred pwsh cannot spawn', async () => { + const spawner = vi.fn() + spawner + .mockResolvedValueOnce({ segments: [], ok: false, failureReason: 'spawn_error' }) + .mockResolvedValueOnce({ + segments: ['C:\\WindowsPowerShell-profile'], + ok: true, + failureReason: 'none' + }) + configureWindowsShellPathHydration('pwsh.exe', null, 'powershell.exe') + + await expect(hydrateShellPath({ spawner })).resolves.toEqual({ + segments: ['C:\\WindowsPowerShell-profile'], + ok: true, + failureReason: 'none' + }) + expect(spawner.mock.calls).toEqual([['pwsh.exe'], ['powershell.exe']]) + }) + + it('uses the resolved Git Bash executable for the configured sentinel', async () => { + const spawner = vi.fn(async () => ({ + segments: ['C:\\tools'], + ok: true, + failureReason: 'none' + })) + + configureWindowsShellPathHydration('git-bash', 'C:\\Program Files\\Git\\bin\\bash.exe') + await hydrateShellPath({ spawner }) + + expect(spawner).toHaveBeenCalledWith('C:\\Program Files\\Git\\bin\\bash.exe') + }) + + it('loads PowerShell profiles and parses their Windows PATH', async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + const resultPromise = hydrateShellPath({ force: true }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) + + proc.stdout.emit( + 'data', + Buffer.from( + 'profile banner\r\n__ORCA_SHELL_PATH__C:\\profile-node;C:\\Windows__ORCA_SHELL_PATH__' + ) + ) + proc.emit('close', 0, null) + + await expect(resultPromise).resolves.toEqual({ + segments: ['C:\\profile-node', 'C:\\Windows'], + ok: true, + failureReason: 'none' + }) + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + ['-NoLogo', '-Command', expect.stringContaining('$env:Path')], + expect.objectContaining({ windowsHide: true }) + ) + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('-NoProfile') + }) + + it('converts a Git Bash PATH to Windows segments before parsing it', async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + const resultPromise = hydrateShellPath({ + shellOverride: 'C:\\Program Files\\Git\\bin\\bash.exe', + force: true + }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) + + proc.stdout.emit( + 'data', + Buffer.from('__ORCA_SHELL_PATH__C:\\git-node;C:\\Windows__ORCA_SHELL_PATH__') + ) + proc.emit('close', 0, null) + + await expect(resultPromise).resolves.toEqual({ + segments: ['C:\\git-node', 'C:\\Windows'], + ok: true, + failureReason: 'none' + }) + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\Program Files\\Git\\bin\\bash.exe', + ['-ilc', expect.stringContaining('cygpath -wp "$PATH"')], + expect.objectContaining({ windowsHide: true }) + ) + }) + + it.each(['cmd.exe', 'wsl.exe', 'git-bash', 'C:\\Tools\\zsh.exe'])( + 'does not merge %s guest or profile-less paths', + async (shell) => { + const spawner = vi.fn() + + configureWindowsShellPathHydration(shell) + + await expect(hydrateShellPath({ spawner })).resolves.toEqual({ + segments: [], + ok: false, + failureReason: 'no_shell' + }) + expect(spawner).not.toHaveBeenCalled() + } + ) + + it('serializes shell changes, discards stale results, and caches the newest request', async () => { + const powerShellResult = deferred() + const gitBashResult = deferred() + let activeProbes = 0 + let maxActiveProbes = 0 + const powerShellSpawner = vi.fn(async () => { + activeProbes += 1 + maxActiveProbes = Math.max(maxActiveProbes, activeProbes) + const result = await powerShellResult.promise + activeProbes -= 1 + return result + }) + const gitBashSpawner = vi.fn(async () => { + activeProbes += 1 + maxActiveProbes = Math.max(maxActiveProbes, activeProbes) + const result = await gitBashResult.promise + activeProbes -= 1 + return result + }) + process.env.PATH = 'C:\\Windows' + const powerShellReady = hydrateShellPath({ spawner: powerShellSpawner, force: true }) + const powerShellMerged = powerShellReady.then((result) => mergePathSegments(result.segments)) + await vi.waitFor(() => expect(powerShellSpawner).toHaveBeenCalledWith('powershell.exe')) + + configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe') + const gitBashReady = hydrateShellPath({ spawner: gitBashSpawner, force: true }) + const gitBashMerged = gitBashReady.then((result) => mergePathSegments(result.segments)) + const cachedGitBashReady = hydrateShellPath({ spawner: gitBashSpawner }) + expect(cachedGitBashReady).toBe(gitBashReady) + expect(gitBashSpawner).not.toHaveBeenCalled() + + powerShellResult.resolve({ + segments: ['C:\\stale-powershell'], + ok: true, + failureReason: 'none' + }) + await vi.waitFor(() => expect(gitBashSpawner).toHaveBeenCalledWith('C:\\Git\\bin\\bash.exe')) + gitBashResult.resolve({ + segments: ['C:\\current-git-bash'], + ok: true, + failureReason: 'none' + }) + await Promise.all([powerShellMerged, gitBashMerged, cachedGitBashReady]) + + expect(maxActiveProbes).toBe(1) + expect(powerShellSpawner).toHaveBeenCalledOnce() + expect(gitBashSpawner).toHaveBeenCalledOnce() + expect(process.env.PATH).toBe('C:\\current-git-bash;C:\\Windows') + }) + + it('discards an active probe when the configured shell has no profile', async () => { + const powerShellResult = deferred() + const spawner = vi.fn(() => powerShellResult.promise) + const staleReady = hydrateShellPath({ spawner, force: true }) + await vi.waitFor(() => expect(spawner).toHaveBeenCalledOnce()) + + configureWindowsShellPathHydration('cmd.exe') + const currentReady = hydrateShellPath({ spawner }) + powerShellResult.resolve({ + segments: ['C:\\stale-powershell'], + ok: true, + failureReason: 'none' + }) + + await expect(Promise.all([staleReady, currentReady])).resolves.toEqual([ + { segments: [], ok: false, failureReason: 'no_shell' }, + { segments: [], ok: false, failureReason: 'no_shell' } + ]) + expect(spawner).toHaveBeenCalledOnce() + }) + + it('deduplicates PATH entries case-insensitively', () => { + process.env.PATH = 'C:\\Tools;C:\\Windows' + + expect(mergePathSegments(['c:\\tools', 'C:\\profile-node'])).toEqual(['C:\\profile-node']) + expect(process.env.PATH).toBe('c:\\tools;C:\\profile-node;C:\\Windows') + }) + + it('deduplicates PATH entries with trailing slashes', () => { + process.env.PATH = 'C:\\Windows\\;C:\\Tools' + + expect(mergePathSegments(['C:\\Windows', 'C:\\profile-node\\'])).toEqual(['C:\\profile-node\\']) + expect(process.env.PATH).toBe('C:\\Windows;C:\\profile-node\\;C:\\Tools') + }) + + it('removes profile entries when switching to a shell without a profile', () => { + process.env.PATH = 'C:\\Inherited;C:\\Windows' + mergePathSegments(['C:\\PowerShell-profile', 'C:\\Inherited']) + + configureWindowsShellPathHydration('cmd.exe') + + expect(process.env.PATH).toBe('C:\\Inherited;C:\\Windows') + }) + + it('replaces profile entries introduced by the previously configured shell', () => { + process.env.PATH = 'C:\\Inherited;C:\\Windows' + mergePathSegments(['C:\\PowerShell-profile', 'C:\\Inherited']) + + configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe') + mergePathSegments(['C:\\GitBash-profile', 'C:\\Inherited']) + + expect(process.env.PATH).toBe('C:\\GitBash-profile;C:\\Inherited;C:\\Windows') + }) +}) diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts index d1cb5bfd2fb..239a7618567 100644 --- a/src/main/startup/serve-desktop-activation-wiring.test.ts +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -16,7 +16,7 @@ describe('serve desktop activation wiring', () => { it('settles the persistent provider before headless PTY registration', () => { const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') const startupIndex = source.indexOf( - '\n startTerminalRuntimeStartupServices()\n', + 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))', appReadyIndex ) const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) diff --git a/src/main/startup/windows-desktop-shell-path-startup.test.ts b/src/main/startup/windows-desktop-shell-path-startup.test.ts new file mode 100644 index 00000000000..3778e0b2688 --- /dev/null +++ b/src/main/startup/windows-desktop-shell-path-startup.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +describe('Windows desktop shell PATH startup', () => { + it('opens the first window while shell PATH hydration remains unresolved', async () => { + const shellPath = deferred() + const window = { visible: true } + const openWindow = vi.fn(() => window) + const startServices = vi.fn(() => ({ + firstWindowReady: Promise.resolve(), + localPtyReady: Promise.resolve(), + localPtyProviderReady: Promise.resolve() + })) + + const startup = startWindowsDesktopBeforeShellPathReady({ + openWindow, + shellPathReady: shellPath.promise, + startServices + }) + + expect(startup.window).toBe(window) + expect(openWindow).toHaveBeenCalledOnce() + expect(startServices).not.toHaveBeenCalled() + + shellPath.resolve() + await startup.services + expect(startServices).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/startup/windows-desktop-shell-path-startup.ts b/src/main/startup/windows-desktop-shell-path-startup.ts new file mode 100644 index 00000000000..3933a79b64b --- /dev/null +++ b/src/main/startup/windows-desktop-shell-path-startup.ts @@ -0,0 +1,18 @@ +export type WindowsDesktopStartupServices = { + firstWindowReady: Promise + localPtyReady: Promise + localPtyProviderReady: Promise +} + +type WindowsDesktopShellPathStartupOptions = { + openWindow: () => TWindow + shellPathReady: Promise + startServices: () => WindowsDesktopStartupServices +} + +export function startWindowsDesktopBeforeShellPathReady( + options: WindowsDesktopShellPathStartupOptions +): { window: TWindow; services: Promise } { + const services = options.shellPathReady.then(options.startServices) + return { window: options.openWindow(), services } +} diff --git a/src/main/startup/windows-shell-path-hydration.test.ts b/src/main/startup/windows-shell-path-hydration.test.ts new file mode 100644 index 00000000000..87175008bfd --- /dev/null +++ b/src/main/startup/windows-shell-path-hydration.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest' +import { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import type { HydrationResult } from './hydrate-shell-path' + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function success(segment: string): HydrationResult { + return { segments: [segment], ok: true, failureReason: 'none' } +} + +describe('Windows shell PATH hydration coordination', () => { + it('serializes probes and never merges a superseded shell result', async () => { + const first = deferred() + const second = deferred() + const hydrate = vi.fn<() => Promise>() + hydrate.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const merge = vi.fn<(segments: string[]) => string[]>(() => []) + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + hydrate, + merge, + resolveGitBashPath: (shell) => (shell === 'git-bash' ? 'C:\\Git\\bin\\bash.exe' : null) + }) + + const powerShellReady = coordinator.hydrate('powershell.exe') + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(1)) + const gitBashReady = coordinator.hydrate('git-bash') + + first.resolve(success('C:\\stale-powershell')) + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(2)) + expect(merge).not.toHaveBeenCalled() + + second.resolve(success('C:\\current-git-bash')) + await Promise.all([powerShellReady, gitBashReady]) + + expect(merge).toHaveBeenCalledOnce() + expect(merge).toHaveBeenCalledWith(['C:\\current-git-bash']) + expect(configure).toHaveBeenLastCalledWith('git-bash', 'C:\\Git\\bin\\bash.exe', null) + }) + + it('coalesces queued shell changes to the latest configured shell', async () => { + const active = deferred() + const latest = deferred() + const hydrate = vi.fn<() => Promise>() + hydrate.mockReturnValueOnce(active.promise).mockReturnValueOnce(latest.promise) + const merge = vi.fn<(segments: string[]) => string[]>(() => []) + const coordinator = createWindowsShellPathHydration({ hydrate, merge }) + + const activeReady = coordinator.hydrate('powershell.exe') + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(1)) + const skippedReady = coordinator.hydrate('cmd.exe') + const latestReady = coordinator.hydrate('powershell.exe') + active.resolve(success('C:\\stale')) + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(2)) + latest.resolve(success('C:\\latest')) + + await Promise.all([activeReady, skippedReady, latestReady]) + expect(hydrate).toHaveBeenCalledTimes(2) + expect(merge).toHaveBeenCalledOnce() + expect(merge).toHaveBeenCalledWith(['C:\\latest']) + }) + + it('keeps the startup barrier behind shell changes queued while it waits', async () => { + const first = deferred() + const second = deferred() + const hydrate = vi.fn<() => Promise>() + hydrate.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const coordinator = createWindowsShellPathHydration({ hydrate }) + void coordinator.hydrate('powershell.exe', 'powershell.exe') + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledOnce()) + let startupReady = false + const startupBarrier = coordinator.whenReady().then(() => { + startupReady = true + }) + + void coordinator.hydrate('git-bash') + first.resolve(success('C:\\stale')) + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(2)) + expect(startupReady).toBe(false) + + second.resolve(success('C:\\latest')) + await startupBarrier + expect(startupReady).toBe(true) + }) + + it('uses the terminal safety chain for a custom PowerShell path', () => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: () => [ + 'C:\\Resolved\\pwsh.exe', + 'C:\\Resolved\\powershell.exe', + 'C:\\Resolved\\cmd.exe' + ] + }) + + coordinator.configure('C:\\Program Files\\PowerShell\\7\\pwsh.exe', 'pwsh.exe') + + expect(configure).toHaveBeenCalledWith( + 'C:\\Resolved\\pwsh.exe', + null, + 'C:\\Resolved\\powershell.exe' + ) + }) + + it('configures refresh callers without spawning in development', () => { + const configure = vi.fn() + const hydrate = vi.fn<() => Promise>() + const coordinator = createWindowsShellPathHydration({ + configure, + hydrate, + resolveGitBashPath: () => 'C:\\Git\\bin\\bash.exe' + }) + + coordinator.configure('git-bash') + + expect(configure).toHaveBeenCalledWith('git-bash', 'C:\\Git\\bin\\bash.exe', null) + expect(hydrate).not.toHaveBeenCalled() + }) + + it.each([ + { + implementation: 'auto' as const, + expectedShell: 'pwsh.exe', + expectedFallback: 'powershell.exe' + }, + { + implementation: 'pwsh.exe' as const, + expectedShell: 'pwsh.exe', + expectedFallback: 'powershell.exe' + }, + { + implementation: 'powershell.exe' as const, + expectedShell: 'powershell.exe', + expectedFallback: null + } + ])( + 'configures $implementation to hydrate the effective PowerShell profile', + ({ implementation, expectedShell, expectedFallback }) => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: (family) => + family === 'pwsh.exe' + ? ['pwsh.exe', 'powershell.exe', 'cmd.exe'] + : ['powershell.exe', 'cmd.exe'] + }) + + coordinator.configure('powershell.exe', implementation) + + expect(configure).toHaveBeenCalledWith(expectedShell, null, expectedFallback) + } + ) + + it('reconfigures when the PowerShell implementation changes', () => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: (family) => + family === 'pwsh.exe' + ? ['pwsh.exe', 'powershell.exe', 'cmd.exe'] + : ['powershell.exe', 'cmd.exe'] + }) + + coordinator.configure('powershell.exe', 'powershell.exe') + coordinator.configure('powershell.exe', 'pwsh.exe') + + expect(configure).toHaveBeenNthCalledWith(1, 'powershell.exe', null, null) + expect(configure).toHaveBeenNthCalledWith(2, 'pwsh.exe', null, 'powershell.exe') + }) + + it('uses inbox PowerShell when pwsh has no safe executable behind its Store alias', () => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: () => [ + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'C:\\Windows\\System32\\cmd.exe' + ] + }) + + coordinator.configure('powershell.exe', 'auto') + + expect(configure).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + null, + null + ) + }) + + it('skips hydration when no PowerShell executable resolves safely', () => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: () => ['C:\\Windows\\System32\\cmd.exe'] + }) + + coordinator.configure('powershell.exe', 'auto') + + expect(configure).toHaveBeenCalledWith('C:\\Windows\\System32\\cmd.exe', null, null) + }) +}) diff --git a/src/main/startup/windows-shell-path-hydration.ts b/src/main/startup/windows-shell-path-hydration.ts new file mode 100644 index 00000000000..083ea8dbe57 --- /dev/null +++ b/src/main/startup/windows-shell-path-hydration.ts @@ -0,0 +1,107 @@ +import { win32 as pathWin32 } from 'node:path' +import { resolveWindowsGitBashShellPath } from '../git-bash' +import { + resolveEffectiveWindowsPowerShell, + type WindowsPowerShellImplementation, + type WindowsPowerShellShellFamily +} from '../providers/windows-powershell' +import { resolveWindowsPowerShellSpawnChain } from '../providers/windows-powershell-executable' +import { + configureWindowsShellPathHydration, + hydrateShellPath, + mergePathSegments, + type HydrationResult +} from './hydrate-shell-path' + +type WindowsShellPathHydrationOptions = { + configure?: ( + shell: string | null | undefined, + gitBashPath: string | null, + fallbackShell: string | null + ) => void + hydrate?: () => Promise + merge?: (segments: string[]) => string[] + resolveGitBashPath?: (shell: string) => string | null + resolvePowerShellChain?: (family: 'powershell.exe' | 'pwsh.exe') => string[] + warn?: (error: unknown) => void +} + +export function createWindowsShellPathHydration(options: WindowsShellPathHydrationOptions = {}) { + const configure = options.configure ?? configureWindowsShellPathHydration + const hydrate = options.hydrate ?? hydrateShellPath + const merge = options.merge ?? mergePathSegments + const resolveGitBashPath = options.resolveGitBashPath ?? resolveWindowsGitBashShellPath + const resolvePowerShellChain = + options.resolvePowerShellChain ?? resolveWindowsPowerShellSpawnChain + const warn = + options.warn ?? + ((error: unknown) => { + console.warn('[shell-path] Windows profile hydration failed; using inherited PATH:', error) + }) + let generation = 0 + let ready = Promise.resolve() + + const setConfiguredShell = ( + shell: string | null | undefined, + implementation?: WindowsPowerShellImplementation + ): void => { + const requestedShell = shell?.trim() || 'powershell.exe' + const basename = pathWin32.basename(requestedShell).toLowerCase() + const shellFamily: WindowsPowerShellShellFamily = + basename === 'powershell.exe' || basename === 'pwsh.exe' ? basename : undefined + const effectivePowerShell = resolveEffectiveWindowsPowerShell({ + shellFamily, + implementation, + pwshAvailable: true + }) + if (!effectivePowerShell) { + configure(requestedShell, resolveGitBashPath(requestedShell), null) + return + } + const spawnChain = resolvePowerShellChain(effectivePowerShell) + const profileShell = spawnChain[0] ?? 'cmd.exe' + const fallbackShell = spawnChain.slice(1).find((candidate) => { + const candidateBasename = pathWin32.basename(candidate).toLowerCase() + return candidateBasename === 'powershell.exe' || candidateBasename === 'pwsh.exe' + }) + configure(profileShell, null, fallbackShell ?? null) + } + + return { + configure: (shell, implementation?: WindowsPowerShellImplementation) => { + generation += 1 + setConfiguredShell(shell, implementation) + }, + hydrate: (shell, implementation?: WindowsPowerShellImplementation) => { + generation += 1 + const requestGeneration = generation + setConfiguredShell(shell, implementation) + ready = ready.then(async () => { + if (requestGeneration !== generation) { + return + } + try { + const result = await hydrate() + if (requestGeneration === generation && result.ok) { + merge(result.segments) + } + } catch (error) { + if (requestGeneration === generation) { + warn(error) + } + } + }) + return ready + }, + whenReady: async () => { + let pending = ready + while (true) { + await pending + if (pending === ready) { + return + } + pending = ready + } + } + } +} diff --git a/src/main/startup/windows-shell-path-ownership.test.ts b/src/main/startup/windows-shell-path-ownership.test.ts new file mode 100644 index 00000000000..e4e4b128d93 --- /dev/null +++ b/src/main/startup/windows-shell-path-ownership.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { WindowsShellPathOwnership, windowsPathSegmentKey } from './windows-shell-path-ownership' + +describe('Windows shell PATH ownership', () => { + it.each([ + ['C:\\', 'c:\\'], + ['C:\\Tools\\', 'c:\\tools'], + ['c:/TOOLS', 'c:\\tools'], + ['\\\\Server\\Share\\', '\\\\server\\share\\'], + ['\\\\SERVER\\Share', '\\\\server\\share\\'] + ])('normalizes %s to %s', (segment, expected) => { + expect(windowsPathSegmentKey(segment)).toBe(expected) + }) + + it.each(['PATH', 'Path'])( + 'restores the complete %s baseline without changing its casing', + (key) => { + const env: Record = { + [key]: 'C:\\B;c:\\b;C:\\;\\\\Server\\Share\\' + } + const ownership = new WindowsShellPathOwnership() + ownership.apply(env, 'C:\\Profile;C:\\B;C:\\;\\\\Server\\Share\\') + + ownership.restore(env) + + expect(env).toEqual({ [key]: 'C:\\B;c:\\b;C:\\;\\\\Server\\Share\\' }) + } + ) + + it('preserves PATH entries appended outside shell hydration', () => { + const env = { Path: 'C:\\B;C:\\A' } + const ownership = new WindowsShellPathOwnership() + ownership.apply(env, 'C:\\Profile;C:\\A;C:\\B') + env.Path += ';C:\\NewlyInstalled' + + ownership.restore(env) + + expect(env.Path).toBe('C:\\B;C:\\A;C:\\NewlyInstalled') + }) + + it('updates the first effective key when both Windows casings exist', () => { + const env = { Path: 'C:\\B;C:\\A', PATH: 'C:\\ignored' } + const ownership = new WindowsShellPathOwnership() + ownership.apply(env, 'C:\\Profile;C:\\A;C:\\B') + + ownership.restore(env) + + expect(env).toEqual({ Path: 'C:\\B;C:\\A', PATH: 'C:\\ignored' }) + }) +}) diff --git a/src/main/startup/windows-shell-path-ownership.ts b/src/main/startup/windows-shell-path-ownership.ts new file mode 100644 index 00000000000..8a4d63ef02a --- /dev/null +++ b/src/main/startup/windows-shell-path-ownership.ts @@ -0,0 +1,68 @@ +import { win32 as pathWin32 } from 'node:path' + +type PathEnvironment = Record + +type AppliedWindowsPath = { + appliedValue: string + baselineValue: string + pathKey: string +} + +function firstPathKey(env: PathEnvironment): string | undefined { + return Object.keys(env).find((key) => key.toLowerCase() === 'path' && env[key] !== undefined) +} + +export function windowsPathSegmentKey(segment: string): string { + const normalized = pathWin32.normalize(segment) + const root = pathWin32.parse(normalized).root + const withoutTrailingSlash = + normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized + return withoutTrailingSlash.toLowerCase() +} + +function splitPath(pathValue: string): string[] { + return pathValue.split(pathWin32.delimiter).filter(Boolean) +} + +function externalAdditions(application: AppliedWindowsPath, currentValue: string): string[] { + if (currentValue === application.appliedValue) { + return [] + } + // Why: forced Windows preflight appends newly installed registry paths after hydration. + const appliedKeys = new Set(splitPath(application.appliedValue).map(windowsPathSegmentKey)) + return splitPath(currentValue).filter( + (segment) => !appliedKeys.has(windowsPathSegmentKey(segment)) + ) +} + +export class WindowsShellPathOwnership { + private application: AppliedWindowsPath | null = null + + reset(): void { + this.application = null + } + + restore(env: PathEnvironment): void { + const application = this.application + if (!application) { + return + } + const pathKey = firstPathKey(env) ?? application.pathKey + const currentValue = env[pathKey] ?? '' + const additions = externalAdditions(application, currentValue) + env[pathKey] = [application.baselineValue, ...additions] + .filter(Boolean) + .join(pathWin32.delimiter) + this.application = null + } + + apply(env: PathEnvironment, value: string): void { + const pathKey = firstPathKey(env) ?? 'Path' + this.application = { + appliedValue: value, + baselineValue: env[pathKey] ?? '', + pathKey + } + env[pathKey] = value + } +} diff --git a/src/main/startup/windows-shell-path-restoration.windows.test.ts b/src/main/startup/windows-shell-path-restoration.windows.test.ts new file mode 100644 index 00000000000..d612c3726eb --- /dev/null +++ b/src/main/startup/windows-shell-path-restoration.windows.test.ts @@ -0,0 +1,165 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, win32 as pathWin32 } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetHydrateShellPathCache, + configureWindowsShellPathHydration, + hydrateShellPath, + mergePathSegments, + type HydrationResult +} from './hydrate-shell-path' +import { createWindowsShellPathHydration } from './windows-shell-path-hydration' + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function successfulHydration(segments: string[]): HydrationResult { + return { segments, ok: true, failureReason: 'none' } +} + +function resolveProbe(pathValue: string): string { + const env: NodeJS.ProcessEnv = { ...process.env, PATH: pathValue, PATHEXT: '.CMD;.EXE' } + delete env.Path + const result = spawnSync( + process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/s', '/c', 'orca-path-probe.cmd'], + { encoding: 'utf8', env } + ) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + return result.stdout.trim() +} + +describe.runIf(process.platform === 'win32')('Windows shell PATH restoration', () => { + const originalPath = process.env.PATH + let fixtureRoot = '' + let shellADir = '' + let shellBDir = '' + let shellBProfileDir = '' + + beforeEach(() => { + _resetHydrateShellPathCache() + fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-shell-path-')) + shellADir = join(fixtureRoot, 'shell-a') + shellBDir = join(fixtureRoot, 'shell-b') + shellBProfileDir = join(fixtureRoot, 'shell-b-profile') + mkdirSync(shellADir) + mkdirSync(shellBDir) + mkdirSync(shellBProfileDir) + writeFileSync(join(shellADir, 'orca-path-probe.cmd'), '@echo shell-a\r\n') + writeFileSync(join(shellBDir, 'orca-path-probe.cmd'), '@echo shell-b\r\n') + }) + + afterEach(() => { + _resetHydrateShellPathCache() + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + rmSync(fixtureRoot, { force: true, recursive: true }) + }) + + it('restores the inherited baseline before applying a different shell profile', () => { + const baseline = [shellBDir, shellADir] + process.env.PATH = baseline.join(pathWin32.delimiter) + + mergePathSegments([shellADir, shellBDir]) + expect(process.env.PATH?.split(pathWin32.delimiter)).toEqual([shellADir, shellBDir]) + expect(resolveProbe(process.env.PATH ?? '')).toBe('shell-a') + + configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe') + const shellBOutput = [shellBProfileDir, ...(process.env.PATH ?? '').split(pathWin32.delimiter)] + mergePathSegments(shellBOutput) + + expect({ + path: process.env.PATH?.split(pathWin32.delimiter), + selectedExecutable: resolveProbe(process.env.PATH ?? '') + }).toEqual({ + path: [shellBProfileDir, ...baseline], + selectedExecutable: 'shell-b' + }) + }) + + it('generation-fences an active shell A probe before shell B inherits the baseline', async () => { + const baseline = [shellBDir, shellADir] + process.env.PATH = baseline.join(pathWin32.delimiter) + const shellAResult = deferred() + const hydrate = vi.fn<() => Promise>() + hydrate + .mockReturnValueOnce(shellAResult.promise) + .mockImplementationOnce(async () => + successfulHydration([ + shellBProfileDir, + ...(process.env.PATH ?? '').split(pathWin32.delimiter) + ]) + ) + const coordinator = createWindowsShellPathHydration({ hydrate }) + + const shellAReady = coordinator.hydrate('powershell.exe') + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledOnce()) + const shellBReady = coordinator.hydrate('git-bash') + shellAResult.resolve(successfulHydration([shellADir, shellBDir])) + await Promise.all([shellAReady, shellBReady]) + + expect(process.env.PATH?.split(pathWin32.delimiter)).toEqual([shellBProfileDir, ...baseline]) + expect(resolveProbe(process.env.PATH ?? '')).toBe('shell-b') + }) + + it('preserves a newly installed PATH entry across shell changes', () => { + const baseline = [shellBDir, shellADir] + const installedDir = join(fixtureRoot, 'newly-installed') + process.env.PATH = baseline.join(pathWin32.delimiter) + mergePathSegments([shellADir, shellBDir]) + process.env.PATH += `${pathWin32.delimiter}${installedDir}` + + configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe') + mergePathSegments([shellBProfileDir, ...(process.env.PATH ?? '').split(pathWin32.delimiter)]) + + expect(process.env.PATH?.split(pathWin32.delimiter)).toEqual([ + shellBProfileDir, + ...baseline, + installedDir + ]) + }) + + it.each(['cmd.exe', 'wsl.exe'])('restores the exact baseline before switching to %s', (shell) => { + const baseline = `${shellBDir};${shellBDir.toUpperCase()}\\;C:\\;\\\\Server\\Share\\` + process.env.PATH = baseline + mergePathSegments([shellADir, shellBDir]) + + configureWindowsShellPathHydration(shell) + + expect(process.env.PATH).toBe(baseline) + }) + + it('restores the baseline before a forced same-shell probe', async () => { + const baseline = [shellBDir, shellADir].join(pathWin32.delimiter) + process.env.PATH = baseline + mergePathSegments([shellADir, shellBDir]) + let inheritedPath = '' + + await hydrateShellPath({ + force: true, + spawner: async () => { + inheritedPath = process.env.PATH ?? '' + return { segments: [], ok: false, failureReason: 'empty_path' } + } + }) + + expect(inheritedPath).toBe(baseline) + expect(process.env.PATH).toBe(baseline) + }) +}) diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts index b9344954a5a..5e7b55e9f75 100644 --- a/src/main/updater-release-builds.test.ts +++ b/src/main/updater-release-builds.test.ts @@ -45,6 +45,25 @@ describe('listReleaseBuilds', () => { ]) }) + it('lists daily builds from the dedicated repo, newest first', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.160-daily.202607271300'), + release('v1.4.160-daily.202607291300'), + release('v1.4.160-daily.202607281300') + ]) + ) + + const builds = await listReleaseBuilds('daily') + + expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-daily') + expect(builds.map((build) => build.version)).toEqual([ + '1.4.160-daily.202607291300', + '1.4.160-daily.202607281300', + '1.4.160-daily.202607271300' + ]) + }) + // 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 () => { @@ -124,6 +143,15 @@ describe('resolveTargetBuild', () => { }) }) + it('pins a daily tag at the daily repo download path', () => { + expect(resolveTargetBuild('daily', 'v1.4.160-daily.202607281300')).toEqual({ + tag: 'v1.4.160-daily.202607281300', + version: '1.4.160-daily.202607281300', + feedUrl: + 'https://github.com/stablyai/orca-daily/releases/download/v1.4.160-daily.202607281300' + }) + }) + 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' diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index dce8b39a7b6..1f5cdc06994 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -301,6 +301,7 @@ describe('updater', () => { it.each([ ['hourly', 'v1.4.160-hourly.202607281400', 'Hourly builds are produced only for macOS.'], + ['daily', 'v1.4.160-daily.202607281300', 'Daily builds are produced only for macOS.'], ['adhoc', 'v1.4.160-adhoc.20260728140533', 'Adhoc builds are produced only for macOS.'] ] as const)( 'uses the display label in the mac-only %s pinned-build error', diff --git a/src/main/usage/usage-calendar-range.test.ts b/src/main/usage/usage-calendar-range.test.ts new file mode 100644 index 00000000000..73645c2ccc1 --- /dev/null +++ b/src/main/usage/usage-calendar-range.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getLocalUsageDay, getUsageRangeCutoff } from './usage-calendar-range' + +describe('usage calendar ranges', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 3, 10, 12)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + ['7d', '2026-04-04'], + ['30d', '2026-03-12'], + ['90d', '2026-01-11'], + ['all', null] + ] as const)('uses an inclusive local-calendar cutoff for %s', (range, expected) => { + expect(getUsageRangeCutoff(range)).toBe(expected) + }) + + it('maps timestamps to local calendar days and rejects invalid values', () => { + const localTimestamp = new Date(2026, 3, 4, 23, 30).toISOString() + + expect(getLocalUsageDay(localTimestamp)).toBe('2026-04-04') + expect(getLocalUsageDay('not-a-date')).toBeNull() + }) +}) diff --git a/src/main/usage/usage-calendar-range.ts b/src/main/usage/usage-calendar-range.ts new file mode 100644 index 00000000000..255ef245be5 --- /dev/null +++ b/src/main/usage/usage-calendar-range.ts @@ -0,0 +1,24 @@ +type UsageCalendarRange = '7d' | '30d' | '90d' | 'all' + +function formatLocalDay(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function getUsageRangeCutoff(range: UsageCalendarRange): string | null { + if (range === 'all') { + return null + } + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 + const cutoff = new Date() + cutoff.setHours(0, 0, 0, 0) + cutoff.setDate(cutoff.getDate() - (days - 1)) + return formatLocalDay(cutoff) +} + +export function getLocalUsageDay(timestamp: string): string | null { + const parsed = new Date(timestamp) + return Number.isNaN(parsed.getTime()) ? null : formatLocalDay(parsed) +} diff --git a/src/main/usage/usage-provider-store-lifecycle.test.ts b/src/main/usage/usage-provider-store-lifecycle.test.ts new file mode 100644 index 00000000000..02a5571363d --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.test.ts @@ -0,0 +1,293 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { UsageScanWorktreeRef } from './usage-provider-contract' +import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle' + +const { writeProbe } = vi.hoisted(() => ({ + writeProbe: { + opens: 0, + renames: 0, + blocked: false, + waiters: [] as (() => void)[] + } +})) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + open: (async (...args: Parameters) => { + if (args[1] === 'w') { + writeProbe.opens += 1 + if (writeProbe.blocked) { + await new Promise((resolve) => writeProbe.waiters.push(resolve)) + } + } + return actual.open(...args) + }) as typeof actual.open, + rename: ((...args: Parameters) => { + writeProbe.renames += 1 + return actual.rename(...args) + }) as typeof actual.rename + } +}) + +type TestSource = { id: string } +type TestSession = { id: string } +type TestDailyAggregate = { day: string } +type TestScanState = { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null +} +type TestState = { + schemaVersion: number + worktreeFingerprint: string | null + processedSources: TestSource[] + sessions: TestSession[] + dailyAggregates: TestDailyAggregate[] + scanState: TestScanState +} +type TestScanResult = Pick +type TestScan = ( + worktrees: UsageScanWorktreeRef[], + previous: TestSource[] +) => Promise + +const NOW = Date.parse('2026-04-10T16:00:00.000Z') +const EMPTY_WORKTREE_FINGERPRINT = '[]' + +function makeState( + overrides: Partial> & { scanState?: Partial } = {} +): TestState { + const { scanState, ...stateOverrides } = overrides + return { + schemaVersion: 1, + worktreeFingerprint: null, + processedSources: [], + sessions: [], + dailyAggregates: [], + ...stateOverrides, + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + ...scanState + } + } +} + +function emptyScanResult(): TestScanResult { + return { processedSources: [], sessions: [], dailyAggregates: [] } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +class TestUsageStore extends UsageProviderStoreLifecycle< + 'processedSources', + TestState, + 'hasAnyTestData' +> { + constructor(cacheFile: string, scan: TestScan) { + super( + { + getRepos: () => [], + getAllWorktreeMeta: () => ({}) + }, + { + logTag: '[test-usage]', + resolveCacheFile: () => cacheFile, + createDefaultState: makeState, + normalizeState: (state) => state, + sourceKey: 'processedSources', + dataPresenceKey: 'hasAnyTestData', + scan + } + ) + } + + replaceState(state: TestState): void { + this.state = state + } + + getState(): TestState { + return this.state + } +} + +describe('UsageProviderStoreLifecycle', () => { + let tempDirectory: string + let stores: TestUsageStore[] + let scan: ReturnType> + + function createStore( + cacheFile = join(tempDirectory, `usage-${stores.length}.json`) + ): TestUsageStore { + const store = new TestUsageStore(cacheFile, scan) + stores.push(store) + return store + } + + beforeEach(() => { + tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-')) + stores = [] + writeProbe.opens = 0 + writeProbe.renames = 0 + writeProbe.blocked = false + writeProbe.waiters = [] + scan = vi.fn().mockResolvedValue(emptyScanResult()) + vi.spyOn(Date, 'now').mockReturnValue(NOW) + }) + + afterEach(async () => { + await Promise.all(stores.map((store) => store.flush())) + rmSync(tempDirectory, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('skips disabled and fresh matching states', async () => { + const store = createStore() + + await store.refresh() + expect(scan).not.toHaveBeenCalled() + + store.replaceState( + makeState({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + await store.refresh() + + expect(scan).not.toHaveBeenCalled() + }) + + it('invalidates prior sources on fingerprint changes and reuses them for forced scans', async () => { + const store = createStore() + const refreshedSources = [{ id: 'refreshed' }] + scan.mockResolvedValueOnce({ + processedSources: refreshedSources, + sessions: [], + dailyAggregates: [] + }) + store.replaceState( + makeState({ + worktreeFingerprint: 'outdated', + processedSources: [{ id: 'stale' }], + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + + await store.refresh() + expect(scan).toHaveBeenLastCalledWith([], []) + + scan.mockClear() + await store.refresh(true) + + expect(scan).toHaveBeenCalledWith([], refreshedSources) + }) + + it('shares one in-flight scan and exposes its live state', async () => { + const pendingScan = createDeferred() + scan.mockReturnValueOnce(pendingScan.promise) + const store = createStore() + store.replaceState( + makeState({ scanState: { enabled: true, lastScanError: 'previous failure' } }) + ) + + const firstRefresh = store.refresh(true) + const secondRefresh = store.refresh(true) + await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1)) + + expect(store.getScanState()).toMatchObject({ + isScanning: true, + lastScanStartedAt: NOW, + lastScanError: null + }) + expect(writeProbe.opens).toBe(0) + + pendingScan.resolve({ + processedSources: [{ id: 'source' }], + sessions: [{ id: 'session' }], + dailyAggregates: [] + }) + await Promise.all([firstRefresh, secondRefresh]) + + expect(store.getScanState()).toMatchObject({ + isScanning: false, + lastScanCompletedAt: NOW, + hasAnyTestData: true + }) + expect(writeProbe.opens).toBe(1) + expect(store.getState().processedSources).toEqual([{ id: 'source' }]) + }) + + it('vetoes a superseded generation before rename', async () => { + const cacheFile = join(tempDirectory, 'usage-0.json') + const store = createStore() + + writeProbe.blocked = true + const first = store.setEnabled(true) + await vi.waitFor(() => expect(writeProbe.waiters).toHaveLength(1)) + const second = store.setEnabled(false) + writeProbe.blocked = false + writeProbe.waiters.splice(0).forEach((resolve) => resolve()) + await Promise.all([first, second]) + + expect(writeProbe.opens).toBe(2) + expect(writeProbe.renames).toBe(1) + expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.enabled).toBe(false) + expect(readdirSync(tempDirectory).filter((name) => name.endsWith('.tmp'))).toHaveLength(0) + }) + + it('sweeps a temp file orphaned before rename', async () => { + const cacheFile = join(tempDirectory, 'orphaned-usage.json') + const orphan = `${cacheFile}.${process.pid + 1}.1.test.tmp` + writeFileSync(orphan, '{}') + + createStore(cacheFile) + + await vi.waitFor(() => expect(existsSync(orphan)).toBe(false)) + }) + + it('retains the last successful projection when a scan fails', async () => { + const cacheFile = join(tempDirectory, 'usage-0.json') + const store = createStore() + const previousState = makeState({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + processedSources: [{ id: 'source' }], + sessions: [{ id: 'session' }], + dailyAggregates: [{ day: '2026-04-09' }], + scanState: { enabled: true, lastScanCompletedAt: NOW - 1_000 } + }) + store.replaceState(previousState) + scan.mockRejectedValueOnce(new Error('scan exploded')) + + await expect(store.refresh(true)).resolves.toMatchObject({ + isScanning: false, + lastScanCompletedAt: NOW - 1_000, + lastScanError: 'scan exploded' + }) + + expect(store.getState()).toMatchObject({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + processedSources: previousState.processedSources, + sessions: previousState.sessions, + dailyAggregates: previousState.dailyAggregates + }) + expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.lastScanError).toBe( + 'scan exploded' + ) + }) +}) diff --git a/src/main/usage/usage-provider-store-lifecycle.ts b/src/main/usage/usage-provider-store-lifecycle.ts new file mode 100644 index 00000000000..850d9e61bda --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { Store } from '../persistence' +import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' +import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata' +import type { UsageScanWorktreeRef } from './usage-provider-contract' +import { createWorktreeRefs, getUsageWorktreeFingerprint } from './usage-worktree-refs' + +const STALE_MS = 5 * 60_000 + +type UsageProviderScanState = { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null +} + +type UsageProviderStoreState = { + worktreeFingerprint: string | null + sessions: unknown[] + dailyAggregates: unknown[] + scanState: UsageProviderScanState +} & Record + +type UsageProviderScanProjection< + SourceKey extends string, + State extends UsageProviderStoreState +> = Pick + +type UsageProviderStoreLifecycleConfig< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> = { + logTag: string + resolveCacheFile: () => string + createDefaultState: () => State + normalizeState: (state: State) => State + sourceKey: SourceKey + dataPresenceKey: DataPresenceKey + jsonIndent?: number + scan: ( + worktrees: UsageScanWorktreeRef[], + previous: State[SourceKey] + ) => Promise> +} + +type PublicUsageProviderScanState = UsageProviderScanState & { + isScanning: boolean +} & Record + +export abstract class UsageProviderStoreLifecycle< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> { + protected state: State + private scanPromise: Promise | null = null + private readonly writer: UsageCacheSnapshotWriter + + constructor( + private readonly store: Pick, + private readonly config: UsageProviderStoreLifecycleConfig + ) { + this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile) + this.state = this.load() + } + + getScanState(): PublicUsageProviderScanState { + return { + ...this.state.scanState, + isScanning: this.scanPromise !== null, + [this.config.dataPresenceKey]: + this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 + } as PublicUsageProviderScanState + } + + /** Await queued cache writes so quit does not drop the final snapshot. */ + flush(): Promise { + return this.writer.flush() + } + + async setEnabled(enabled: boolean): Promise> { + this.state.scanState.enabled = enabled + await this.writeToDisk() + return this.getScanState() + } + + async refresh(force = false): Promise> { + if (!this.state.scanState.enabled) { + return this.getScanState() + } + const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() + if (!force && this.state.scanState.lastScanCompletedAt) { + const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt + if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { + return this.getScanState() + } + } + await this.runScan() + return this.getScanState() + } + + protected writeToDisk(): Promise { + return this.writer.write(() => JSON.stringify(this.state, null, this.config.jsonIndent)) + } + + private load(): State { + const defaults = this.config.createDefaultState() + try { + const cacheFile = this.config.resolveCacheFile() + if (!existsSync(cacheFile)) { + return defaults + } + const parsed = JSON.parse(readFileSync(cacheFile, 'utf-8')) as State + return this.config.normalizeState({ + ...defaults, + ...parsed, + scanState: { ...defaults.scanState, ...parsed.scanState } + }) + } catch (error) { + console.error(`${this.config.logTag} Failed to load persisted state, starting fresh:`, error) + return defaults + } + } + + private async runScan(): Promise { + if (this.scanPromise) { + await this.scanPromise + return + } + + this.state.scanState.lastScanStartedAt = Date.now() + this.state.scanState.lastScanError = null + + // Assign before yielding so concurrent refreshes share one scan. + this.scanPromise = (async () => { + try { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + const worktreeFingerprint = getUsageWorktreeFingerprint(worktreesByRepo) + const result = await this.config.scan( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint + ? this.state[this.config.sourceKey] + : this.config.createDefaultState()[this.config.sourceKey] + ) + this.state[this.config.sourceKey] = result[this.config.sourceKey] + this.state.sessions = result.sessions + this.state.dailyAggregates = result.dailyAggregates + this.state.worktreeFingerprint = worktreeFingerprint + this.state.scanState.lastScanCompletedAt = Date.now() + this.state.scanState.lastScanError = null + // Persistence failures do not turn a successful source scan into a scan failure. + await this.writeToDisk().catch(() => {}) + } catch (error) { + this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) + await this.writeToDisk().catch(() => {}) + } finally { + this.scanPromise = null + } + })() + + await this.scanPromise + } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) + } +} diff --git a/src/main/usage/usage-worktree-refs.test.ts b/src/main/usage/usage-worktree-refs.test.ts new file mode 100644 index 00000000000..56efe7e4fb8 --- /dev/null +++ b/src/main/usage/usage-worktree-refs.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' +import { getUsageWorktreeFingerprint } from './usage-worktree-refs' + +function worktree(worktreeId: string, path: string, displayName: string): UsageWorktreeRef { + return { worktreeId, path, displayName } +} + +describe('getUsageWorktreeFingerprint', () => { + it('is stable across repo and worktree ordering but changes with identity metadata', () => { + const first = new Map([ + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two'), + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One') + ] + ], + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]] + ]) + const reordered = new Map([ + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]], + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One'), + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two') + ] + ] + ]) + const renamed = new Map(reordered) + renamed.set('repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'Renamed A')]) + + expect(getUsageWorktreeFingerprint(first)).toBe(getUsageWorktreeFingerprint(reordered)) + expect(getUsageWorktreeFingerprint(renamed)).not.toBe(getUsageWorktreeFingerprint(first)) + }) +}) diff --git a/src/main/usage/usage-worktree-refs.ts b/src/main/usage/usage-worktree-refs.ts index 0b4347a90a5..f76ed7c0a58 100644 --- a/src/main/usage/usage-worktree-refs.ts +++ b/src/main/usage/usage-worktree-refs.ts @@ -1,6 +1,25 @@ import type { Repo } from '../../shared/types' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' import type { UsageScanWorktreeRef } from './usage-provider-contract' +export function getUsageWorktreeFingerprint( + worktreesByRepo: Map +): string { + const rows = [...worktreesByRepo.entries()] + .flatMap(([repoId, worktrees]) => + worktrees.map((worktree) => + JSON.stringify({ + repoId, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + ) + ) + .sort() + return JSON.stringify(rows) +} + export function createWorktreeRefs( repos: Repo[], worktreesByRepo: Map diff --git a/src/main/warp-themes/discovery.ts b/src/main/warp-themes/discovery.ts index 6f3f80b51da..fd29419c12c 100644 --- a/src/main/warp-themes/discovery.ts +++ b/src/main/warp-themes/discovery.ts @@ -142,7 +142,7 @@ export function getWarpThemeDirectories(): string[] { export function warpThemeSourceLabelForDirectory(directoryPath: string): string { const parts = directoryPath.split(/[\\/]+/).filter(Boolean) const themesIndex = parts.findLastIndex((part) => part.toLowerCase() === 'themes') - if (themesIndex < 0) { + if (themesIndex === -1) { return parts.at(-1) || 'Warp themes' } diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts index 2fd1ca680e2..df91e29a2ed 100644 --- a/src/main/window/clipboard-dashboard-popout-access.test.ts +++ b/src/main/window/clipboard-dashboard-popout-access.test.ts @@ -39,6 +39,7 @@ vi.mock('electron', () => ({ vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer })) vi.mock('./clipboard-remote-file-copy', () => ({ cleanupExpiredRemoteClipboardFiles: vi.fn(async () => undefined), + scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), writeRemoteFileToClipboard: vi.fn() })) diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index b1da7e9e226..e12f7d633db 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -12,6 +12,8 @@ const { spawnMock, childStdinEndMock, resolveAuthorizedPathMock, + fsAccessMock, + fsLstatMock, fsMkdirMock, fsOpendirMock, fsRmMock, @@ -45,6 +47,8 @@ const { return child }), resolveAuthorizedPathMock: vi.fn(), + fsAccessMock: vi.fn(), + fsLstatMock: vi.fn(), fsMkdirMock: vi.fn(), fsOpendirMock: vi.fn(), fsRmMock: vi.fn(), @@ -68,11 +72,14 @@ vi.mock('node:child_process', () => ({ })) vi.mock('node:fs/promises', () => ({ + access: fsAccessMock, + lstat: fsLstatMock, mkdir: fsMkdirMock, opendir: fsOpendirMock, rm: fsRmMock, open: fsOpenMock, stat: fsStatMock, + writeFile: fsWriteFileMock, default: { writeFile: fsWriteFileMock } @@ -134,6 +141,11 @@ import { setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' +const REMOTE_CLIPBOARD_STAGING_ROOT = join( + '/tmp', + `orca-clipboard-files${typeof process.getuid === 'function' ? `-${process.getuid()}` : ''}` +) + function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() for (const [channel, handler] of handleMock.mock.calls as [ @@ -187,6 +199,13 @@ describe('registerClipboardHandlers', () => { childStdinEndMock.mockClear() resolveAuthorizedPathMock.mockReset() resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + fsLstatMock.mockReset() + fsLstatMock.mockResolvedValue({ + isDirectory: () => true, + isSymbolicLink: () => false, + mode: 0o700, + uid: typeof process.getuid === 'function' ? process.getuid() : 0 + }) fsMkdirMock.mockReset() fsMkdirMock.mockResolvedValue(undefined) fsOpendirMock.mockReset() @@ -316,8 +335,8 @@ describe('registerClipboardHandlers', () => { const handlers = getRegisteredHandlers() const tempDir = join( - '/tmp', - 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + REMOTE_CLIPBOARD_STAGING_ROOT, + '1760000000000-00000000-0000-4000-8000-000000000000' ) const tempPath = join(tempDir, 'report.pdf') @@ -329,6 +348,10 @@ describe('registerClipboardHandlers', () => { ).resolves.toEqual({ ok: true }) expect(provider.stat).toHaveBeenCalledWith('/remote/report.pdf') + expect(fsMkdirMock).toHaveBeenCalledWith(REMOTE_CLIPBOARD_STAGING_ROOT, { + recursive: true, + mode: 0o700 + }) expect(fsMkdirMock).toHaveBeenCalledWith(tempDir, { mode: 0o700 }) expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) expect(fsStatMock).toHaveBeenCalledWith(tempPath) @@ -353,7 +376,6 @@ describe('registerClipboardHandlers', () => { ).resolves.toEqual({ ok: false, reason: 'is-directory' }) expect(provider.downloadFile).not.toHaveBeenCalled() - expect(fsMkdirMock).not.toHaveBeenCalled() expect(clipboardWriteBufferMock).not.toHaveBeenCalled() }) @@ -367,8 +389,8 @@ describe('registerClipboardHandlers', () => { const handlers = getRegisteredHandlers() const tempDir = join( - '/tmp', - 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + REMOTE_CLIPBOARD_STAGING_ROOT, + '1760000000000-00000000-0000-4000-8000-000000000000' ) const tempPath = join(tempDir, 'report.pdf') @@ -380,7 +402,12 @@ describe('registerClipboardHandlers', () => { ).rejects.toThrow('transfer failed') expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) - expect(fsRmMock).toHaveBeenCalledWith(tempDir, { recursive: true, force: true }) + expect(fsRmMock).toHaveBeenCalledWith(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }) expect(clipboardWriteBufferMock).not.toHaveBeenCalled() }) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 275c0e26240..450c6a6e140 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -31,6 +31,7 @@ import { } from './clipboard-file-copy' import { cleanupExpiredRemoteClipboardFiles, + scheduleLegacyRemoteClipboardFileCleanup, writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' @@ -85,6 +86,7 @@ export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:saveImageAsTempFile') void cleanupExpiredRemoteClipboardFiles() + scheduleLegacyRemoteClipboardFileCleanup() ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { assertTrustedClipboardTextSender(event) diff --git a/src/main/window/clipboard-remote-file-cleanup.bench.test.ts b/src/main/window/clipboard-remote-file-cleanup.bench.test.ts new file mode 100644 index 00000000000..5e671930af3 --- /dev/null +++ b/src/main/window/clipboard-remote-file-cleanup.bench.test.ts @@ -0,0 +1,147 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { access, mkdir, mkdtemp, opendir, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { + cleanupExpiredRemoteClipboardStaging, + createRemoteClipboardTransferDirectory +} from './clipboard-remote-file-staging' + +const BENCH_ENABLED = process.env.ORCA_CLIPBOARD_CLEANUP_BENCH === '1' +const FOREIGN_ENTRY_COUNT = Number(process.env.ORCA_CLIPBOARD_CLEANUP_BENCH_ENTRIES ?? 100_000) +const WARMUP_RUNS = 3 +const SAMPLE_RUNS_PER_ARM = 24 +const FIXTURE_PREFIX = 'orca-clipboard-cleanup-bench-' +const NOW_MS = 1_760_000_000_000 + +describe.skipIf(!BENCH_ENABLED)('remote clipboard cleanup benchmark', () => { + let fixtureRoot = '' + let freshTransfer = '' + + beforeAll(async () => { + fixtureRoot = await mkdtemp(join(tmpdir(), FIXTURE_PREFIX)) + await createForeignEntries(fixtureRoot, FOREIGN_ENTRY_COUNT) + const expiredTransfer = await createRemoteClipboardTransferDirectory( + fixtureRoot, + NOW_MS - 2 * 60 * 60 * 1000, + '00000000-0000-4000-8000-000000000000' + ) + freshTransfer = await createRemoteClipboardTransferDirectory( + fixtureRoot, + NOW_MS, + '00000000-0000-4000-8000-000000000001' + ) + const expiredAt = new Date(NOW_MS - 2 * 60 * 60 * 1000) + await utimes(expiredTransfer, expiredAt, expiredAt) + await cleanupExpiredRemoteClipboardStaging(fixtureRoot, NOW_MS) + + await expect(access(expiredTransfer)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(access(freshTransfer)).resolves.toBeUndefined() + expect(await countForeignEntries(fixtureRoot)).toBe(FOREIGN_ENTRY_COUNT) + }, 900_000) + + afterAll(async () => { + if ( + fixtureRoot && + dirname(fixtureRoot) === tmpdir() && + basename(fixtureRoot).startsWith(FIXTURE_PREFIX) + ) { + await rm(fixtureRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }) + } + }, 900_000) + + it('reports shared-root and owned-parent median and p95', async () => { + for (let index = 0; index < WARMUP_RUNS; index += 1) { + await scanSharedTempRoot(fixtureRoot) + await cleanupExpiredRemoteClipboardStaging(fixtureRoot, NOW_MS) + } + + const samples = { ownedParent: [] as number[], sharedRoot: [] as number[] } + const launchPositions = { + ownedParent: [0, 0], + sharedRoot: [0, 0] + } + for (let index = 0; index < SAMPLE_RUNS_PER_ARM; index += 1) { + const order = + index % 2 === 0 + ? (['sharedRoot', 'ownedParent'] as const) + : (['ownedParent', 'sharedRoot'] as const) + for (const [position, arm] of order.entries()) { + launchPositions[arm][position] += 1 + samples[arm].push( + await measure(() => + arm === 'sharedRoot' + ? scanSharedTempRoot(fixtureRoot) + : cleanupExpiredRemoteClipboardStaging(fixtureRoot, NOW_MS) + ) + ) + } + } + + expect(launchPositions).toEqual({ + ownedParent: [SAMPLE_RUNS_PER_ARM / 2, SAMPLE_RUNS_PER_ARM / 2], + sharedRoot: [SAMPLE_RUNS_PER_ARM / 2, SAMPLE_RUNS_PER_ARM / 2] + }) + expect(samples.ownedParent).toHaveLength(SAMPLE_RUNS_PER_ARM) + expect(samples.sharedRoot).toHaveLength(SAMPLE_RUNS_PER_ARM) + expect(await countForeignEntries(fixtureRoot)).toBe(FOREIGN_ENTRY_COUNT) + await expect(access(freshTransfer)).resolves.toBeUndefined() + const sharedRoot = summarize(samples.sharedRoot) + const ownedParent = summarize(samples.ownedParent) + console.log('correctness=pass') + console.log( + `sampling=ABBA positions-per-arm=${SAMPLE_RUNS_PER_ARM / 2}/${SAMPLE_RUNS_PER_ARM / 2} p95-order-drift<=1-slot` + ) + console.log(`post12917-shared-root median=${sharedRoot.median}ms p95=${sharedRoot.p95}ms`) + console.log(`owned-parent median=${ownedParent.median}ms p95=${ownedParent.p95}ms`) + }, 900_000) +}) + +async function createForeignEntries(root: string, count: number): Promise { + const batchSize = 128 + for (let start = 0; start < count; start += batchSize) { + const tasks: Promise[] = [] + for (let index = start; index < Math.min(start + batchSize, count); index += 1) { + const target = join(root, `foreign-${String(index).padStart(8, '0')}`) + tasks.push(index % 2 === 0 ? writeFile(target, '') : mkdir(target)) + } + await Promise.all(tasks) + } +} + +async function countForeignEntries(root: string): Promise { + return (await readdir(root)).filter((name) => name.startsWith('foreign-')).length +} + +async function scanSharedTempRoot(root: string): Promise { + const directory = await opendir(root) + try { + for await (const entry of directory) { + if (entry.isDirectory() && entry.name.startsWith('orca-clipboard-file-')) { + // Reproduces the remaining shared-root iteration after #12917. + } + } + } finally { + await directory.close().catch(() => undefined) + } +} + +async function measure(operation: () => Promise): Promise { + const startedAt = process.hrtime.bigint() + await operation() + return Number(process.hrtime.bigint() - startedAt) / 1_000_000 +} + +function summarize(samples: number[]): { median: string; p95: string } { + const sorted = [...samples].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + const median = + sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] + const p95 = sorted[Math.ceil(sorted.length * 0.95) - 1] + return { median: median.toFixed(3), p95: p95.toFixed(3) } +} diff --git a/src/main/window/clipboard-remote-file-cleanup.test.ts b/src/main/window/clipboard-remote-file-cleanup.test.ts deleted file mode 100644 index 56700cc025e..00000000000 --- a/src/main/window/clipboard-remote-file-cleanup.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' - -const { opendirMock, rmMock, statMock } = vi.hoisted(() => ({ - opendirMock: vi.fn(), - rmMock: vi.fn(), - statMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - mkdir: vi.fn(), - opendir: opendirMock, - rm: rmMock, - stat: statMock -})) -vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: vi.fn() -})) -vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) - -import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' - -const TTL_MS = 60 * 60 * 1000 -const NOW_MS = 1_760_000_000_000 - -function mockTempRoot(entries: Iterable<{ name: string; isDirectory: () => boolean }>): void { - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - yield* entries - }, - close: vi.fn().mockResolvedValue(undefined) - }) -} - -function* orcaStagingDirs(count: number): Generator<{ name: string; isDirectory: () => boolean }> { - for (let index = 0; index < count; index += 1) { - yield { name: `orca-clipboard-file-expired-${index}`, isDirectory: () => true } - } -} - -function* unrelatedTempEntries( - count: number -): Generator<{ name: string; isDirectory: () => boolean }> { - for (let index = 0; index < count; index += 1) { - // Mirrors a real temp root: mostly foreign entries, both files and directories. - yield { name: `unrelated-${index}`, isDirectory: () => index % 2 === 0 } - } -} - -describe('cleanupExpiredRemoteClipboardFiles', () => { - beforeEach(() => { - vi.clearAllMocks() - rmMock.mockResolvedValue(undefined) - statMock.mockResolvedValue({ mtimeMs: NOW_MS - TTL_MS - 1 }) - }) - - it('streams all entries with at most eight cleanups in flight', async () => { - mockTempRoot(orcaStagingDirs(257)) - let active = 0 - let peak = 0 - statMock.mockImplementation(async () => { - active += 1 - peak = Math.max(peak, active) - await new Promise((resolve) => setImmediate(resolve)) - active -= 1 - return { mtimeMs: NOW_MS - TTL_MS - 1 } - }) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(rmMock).toHaveBeenCalledTimes(257) - expect(peak).toBe(8) - }) - - it('does no per-entry work for foreign temp-root entries', async () => { - mockTempRoot(unrelatedTempEntries(200_000)) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(statMock).not.toHaveBeenCalled() - expect(rmMock).not.toHaveBeenCalled() - }) - - it('acts on owned directories while the temp root is still being enumerated', async () => { - // Regression: the sweep materialized the whole temp root and built one promise - // per entry before doing any work, so a %TEMP% with ~1.2M unrelated entries - // froze the main process at startup (#12835). Streaming means an owned - // directory is swept before enumeration reaches the end. - let sweptDuringEnumeration = false - function* enumeration(): Generator<{ name: string; isDirectory: () => boolean }> { - yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } - for (const entry of unrelatedTempEntries(1_000)) { - sweptDuringEnumeration ||= rmMock.mock.calls.length > 0 - yield entry - } - } - mockTempRoot(enumeration()) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(sweptDuringEnumeration).toBe(true) - expect(rmMock).toHaveBeenCalledTimes(1) - }) - - it('removes only expired staging directories it owns', async () => { - function* interleaved(): Generator<{ name: string; isDirectory: () => boolean }> { - yield* unrelatedTempEntries(50_000) - yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } - yield* unrelatedTempEntries(50_000) - yield { name: 'orca-clipboard-file-fresh', isDirectory: () => true } - yield { name: 'orca-clipboard-file-plain-file', isDirectory: () => false } - } - mockTempRoot(interleaved()) - statMock.mockImplementation(async (targetPath: string) => ({ - mtimeMs: targetPath.endsWith('expired') ? NOW_MS - TTL_MS - 1 : NOW_MS - 1000 - })) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(statMock).toHaveBeenCalledTimes(2) - expect(rmMock).toHaveBeenCalledTimes(1) - expect(rmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { - recursive: true, - force: true - }) - }) - - it('closes the temp-root handle and still sweeps when enumeration fails midway', async () => { - const close = vi.fn().mockResolvedValue(undefined) - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } - throw new Error('EIO') - }, - close - }) - - await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() - - expect(rmMock).toHaveBeenCalledTimes(1) - expect(close).toHaveBeenCalledTimes(1) - }) - - it('returns quietly when the temp root cannot be opened', async () => { - opendirMock.mockRejectedValue(new Error('EACCES')) - - await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() - - expect(statMock).not.toHaveBeenCalled() - expect(rmMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/window/clipboard-remote-file-copy.test.ts b/src/main/window/clipboard-remote-file-copy.test.ts new file mode 100644 index 00000000000..1f851e1ec09 --- /dev/null +++ b/src/main/window/clipboard-remote-file-copy.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + cleanupLegacyRemoteClipboardStagingMock, + createRemoteClipboardTransferDirectoryMock, + downloadFileMock, + requireSshFilesystemProviderMock, + RemoteClipboardStagingRootUnsafeErrorMock, + spanFailMock, + startSpanMock +} = vi.hoisted(() => { + class RemoteClipboardStagingRootUnsafeErrorMock extends Error {} + const spanFailMock = vi.fn() + return { + cleanupLegacyRemoteClipboardStagingMock: vi.fn(async () => undefined), + createRemoteClipboardTransferDirectoryMock: vi.fn(), + downloadFileMock: vi.fn(), + requireSshFilesystemProviderMock: vi.fn(), + RemoteClipboardStagingRootUnsafeErrorMock, + spanFailMock, + startSpanMock: vi.fn(() => ({ fail: spanFailMock })) + } +}) + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: requireSshFilesystemProviderMock +})) +vi.mock('../observability/tracer', () => ({ startSpan: startSpanMock })) +vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) +vi.mock('./clipboard-remote-file-staging', () => ({ + cleanupExpiredRemoteClipboardStaging: vi.fn(async () => undefined), + cleanupLegacyRemoteClipboardStaging: cleanupLegacyRemoteClipboardStagingMock, + createRemoteClipboardTransferDirectory: createRemoteClipboardTransferDirectoryMock, + RemoteClipboardStagingRootUnsafeError: RemoteClipboardStagingRootUnsafeErrorMock, + removeRemoteClipboardTransferDirectory: vi.fn(), + scheduleRemoteClipboardTransferCleanup: vi.fn() +})) + +import { + scheduleLegacyRemoteClipboardFileCleanup, + writeRemoteFileToClipboard +} from './clipboard-remote-file-copy' +import { RemoteClipboardStagingRootUnsafeError } from './clipboard-remote-file-staging' + +beforeEach(() => { + vi.clearAllMocks() + requireSshFilesystemProviderMock.mockReturnValue({ + stat: vi.fn(async () => ({ type: 'file' })), + downloadFile: downloadFileMock + }) +}) + +describe('remote clipboard staging failures', () => { + it('rejects remote directories before creating staging', async () => { + requireSshFilesystemProviderMock.mockReturnValue({ + stat: vi.fn(async () => ({ type: 'directory' })), + downloadFile: downloadFileMock + }) + + await expect( + writeRemoteFileToClipboard({ + remotePath: '/repo/src', + connectionId: 'ssh-1', + deps: { + platform: 'win32', + writeBuffer: vi.fn(), + runCommand: vi.fn() + } + }) + ).resolves.toEqual({ ok: false, reason: 'is-directory' }) + + expect(createRemoteClipboardTransferDirectoryMock).not.toHaveBeenCalled() + expect(downloadFileMock).not.toHaveBeenCalled() + }) + + it.each([ + { + category: 'unsafe-root', + error: new RemoteClipboardStagingRootUnsafeError() + }, + { + category: 'permissions', + error: Object.assign(new Error('access denied'), { code: 'EACCES' }) + }, + { + category: 'permissions', + error: Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + }, + { + category: 'path-conflict', + error: Object.assign(new Error('path exists'), { code: 'EEXIST' }) + }, + { + category: 'unavailable', + error: new Error('storage offline') + } + ])( + 'returns a toast-safe reason and records $category diagnostics', + async ({ category, error }) => { + createRemoteClipboardTransferDirectoryMock.mockRejectedValueOnce(error) + + await expect( + writeRemoteFileToClipboard({ + remotePath: '/repo/readme.md', + connectionId: 'ssh-1', + deps: { + platform: 'win32', + writeBuffer: vi.fn(), + runCommand: vi.fn() + } + }) + ).resolves.toEqual({ ok: false, reason: 'staging-unavailable' }) + + expect(startSpanMock).toHaveBeenCalledWith('clipboard.remote_staging_init', { + attributes: { + operation: 'create', + platform: process.platform, + failure_category: category + } + }) + expect(spanFailMock).toHaveBeenCalledWith(error) + expect(downloadFileMock).not.toHaveBeenCalled() + } + ) +}) + +describe('legacy remote clipboard cleanup scheduling', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('runs the shared-root compatibility scan once after the startup delay', async () => { + vi.useFakeTimers() + vi.spyOn(Date, 'now').mockReturnValue(1_760_000_000_000) + + scheduleLegacyRemoteClipboardFileCleanup() + scheduleLegacyRemoteClipboardFileCleanup() + await vi.advanceTimersByTimeAsync(29_999) + + expect(cleanupLegacyRemoteClipboardStagingMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledOnce() + expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledWith('/tmp', 1_760_000_000_000) + }) +}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 2afc60cda30..47bbbc0c5bb 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,25 +1,32 @@ import { randomUUID } from 'node:crypto' -import type { Dir } from 'node:fs' -import { mkdir, opendir, rm, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import { join } from 'node:path' import { app } from 'electron' import { getRuntimePathBasename } from '../../shared/cross-platform-path' +import { startSpan } from '../observability/tracer' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { writeFileToClipboard, type ClipboardFileDeps, type ClipboardFileResult } from './clipboard-file-copy' +import { + cleanupExpiredRemoteClipboardStaging, + cleanupLegacyRemoteClipboardStaging, + createRemoteClipboardTransferDirectory, + RemoteClipboardStagingRootUnsafeError, + removeRemoteClipboardTransferDirectory, + scheduleRemoteClipboardTransferCleanup +} from './clipboard-remote-file-staging' type RemoteClipboardFileDeps = Omit -const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 -const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' -const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 +const REMOTE_CLIPBOARD_LEGACY_CLEANUP_DELAY_MS = 30 * 1000 const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) +let legacyCleanupScheduled = false export async function writeRemoteFileToClipboard({ remotePath, @@ -39,11 +46,21 @@ export async function writeRemoteFileToClipboard({ throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') } - const tempDir = join( - app.getPath('temp'), - `${REMOTE_CLIPBOARD_FILE_PREFIX}${Date.now()}-${randomUUID()}` - ) - await mkdir(tempDir, { mode: 0o700 }) + const tempRoot = app.getPath('temp') + let tempDir: string + try { + tempDir = await createRemoteClipboardTransferDirectory(tempRoot, Date.now(), randomUUID()) + } catch (error) { + const span = startSpan('clipboard.remote_staging_init', { + attributes: { + operation: 'create', + platform: process.platform, + failure_category: getStagingFailureCategory(error) + } + }) + span.fail(error instanceof Error ? error : String(error)) + return { ok: false, reason: 'staging-unavailable' } + } const localPath = join( tempDir, sanitizeLocalClipboardFilename(getRuntimePathBasename(remotePath)) @@ -70,62 +87,33 @@ export async function writeRemoteFileToClipboard({ // Why: OS file clipboards keep a path reference, so the staged copy must // survive after this IPC call long enough for the user to paste it. keepTempFile = true - scheduleRemoteClipboardFileCleanup(tempDir) + scheduleRemoteClipboardTransferCleanup(tempRoot, tempDir) } return result } finally { if (!keepTempFile) { - await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + await removeRemoteClipboardTransferDirectory(tempRoot, tempDir) } } } -// Why: the OS temp root is shared with every other program and routinely holds -// millions of unrelated entries on long-lived machines, so this startup sweep -// streams it and only ever retains work for entries it actually owns. export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { - const tempRoot = app.getPath('temp') - let tempRootDir: Dir - try { - tempRootDir = await opendir(tempRoot) - } catch { + await cleanupExpiredRemoteClipboardStaging(app.getPath('temp'), nowMs) +} + +export async function cleanupLegacyRemoteClipboardFiles(nowMs = Date.now()): Promise { + await cleanupLegacyRemoteClipboardStaging(app.getPath('temp'), nowMs) +} + +export function scheduleLegacyRemoteClipboardFileCleanup(): void { + if (legacyCleanupScheduled) { return } - - const pending = new Set>() - try { - for await (const entry of tempRootDir) { - if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { - continue - } - const cleanup = cleanupExpiredRemoteClipboardDirectory(join(tempRoot, entry.name), nowMs) - pending.add(cleanup) - void cleanup.finally(() => pending.delete(cleanup)) - if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { - await Promise.race(pending) - } - } - } catch { - // Why: a partial best-effort sweep beats failing startup on a temp-root read error. - } finally { - // Why: exhausting the iterator already closes the handle; closing again is harmless. - await tempRootDir.close().catch(() => undefined) - } - await Promise.all(pending) -} - -async function cleanupExpiredRemoteClipboardDirectory( - tempDir: string, - nowMs: number -): Promise { - try { - const tempStats = await stat(tempDir) - if (nowMs - tempStats.mtimeMs >= REMOTE_CLIPBOARD_FILE_TTL_MS) { - await rm(tempDir, { recursive: true, force: true }) - } - } catch { - // Why: stale staged SSH files should not make startup cleanup noisy. - } + legacyCleanupScheduled = true + const timer = setTimeout(() => { + void cleanupLegacyRemoteClipboardFiles().catch(() => undefined) + }, REMOTE_CLIPBOARD_LEGACY_CLEANUP_DELAY_MS) + unrefTimer(timer) } function sanitizeLocalClipboardFilename(remoteBasename: string): string { @@ -140,11 +128,22 @@ function sanitizeLocalClipboardFilename(remoteBasename: string): string { return sanitized } -function scheduleRemoteClipboardFileCleanup(tempDir: string): void { - const timer = setTimeout(() => { - void rm(tempDir, { recursive: true, force: true }).catch(() => undefined) - }, REMOTE_CLIPBOARD_FILE_TTL_MS) +function unrefTimer(timer: ReturnType): void { if (typeof timer === 'object' && 'unref' in timer) { timer.unref() } } + +function getStagingFailureCategory(error: unknown): string { + if (error instanceof RemoteClipboardStagingRootUnsafeError) { + return 'unsafe-root' + } + const code = error instanceof Error && 'code' in error ? String(error.code) : undefined + if (code === 'EACCES' || code === 'EPERM') { + return 'permissions' + } + if (code === 'EEXIST' || code === 'ENOTDIR') { + return 'path-conflict' + } + return 'unavailable' +} diff --git a/src/main/window/clipboard-remote-file-staging-fs.test.ts b/src/main/window/clipboard-remote-file-staging-fs.test.ts new file mode 100644 index 00000000000..dc464528cb6 --- /dev/null +++ b/src/main/window/clipboard-remote-file-staging-fs.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { access, lstat, mkdir, mkdtemp, readdir, rm, symlink, utimes } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' + +import { + cleanupExpiredRemoteClipboardStaging, + createRemoteClipboardTransferDirectory, + getRemoteClipboardStagingRoot, + RemoteClipboardStagingRootUnsafeError +} from './clipboard-remote-file-staging' + +const FIXTURE_PREFIX = 'orca-clipboard-staging-test-' +const NOW_MS = 1_760_000_000_000 +const fixtures: string[] = [] + +afterEach(async () => { + await Promise.all( + fixtures.splice(0).map(async (fixture) => { + expect(dirname(fixture)).toBe(tmpdir()) + expect(basename(fixture)).toMatch(new RegExp(`^${FIXTURE_PREFIX}`)) + await rm(fixture, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }) + }) + ) +}) + +describe('remote clipboard staging filesystem contract', () => { + it('removes only expired owned transfers with real filesystem metadata', async () => { + const tempRoot = await makeFixture() + const foreignDirectory = join(tempRoot, 'foreign-directory') + await mkdir(foreignDirectory) + const expiredTransfer = await createRemoteClipboardTransferDirectory( + tempRoot, + NOW_MS - 2 * 60 * 60 * 1000, + '00000000-0000-4000-8000-000000000000' + ) + const freshTransfer = await createRemoteClipboardTransferDirectory( + tempRoot, + NOW_MS, + '00000000-0000-4000-8000-000000000001' + ) + const expiredAt = new Date(NOW_MS - 2 * 60 * 60 * 1000) + await utimes(expiredTransfer, expiredAt, expiredAt) + + await cleanupExpiredRemoteClipboardStaging(tempRoot, NOW_MS) + + await expect(access(expiredTransfer)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(access(freshTransfer)).resolves.toBeUndefined() + await expect(access(foreignDirectory)).resolves.toBeUndefined() + if (typeof process.getuid === 'function') { + const rootStats = await lstat(getRemoteClipboardStagingRoot(tempRoot)) + expect(rootStats.uid).toBe(process.getuid()) + expect(rootStats.mode & 0o777).toBe(0o700) + } + }) + + it('rejects a real staging-root symlink without writing through it', async () => { + const tempRoot = await makeFixture() + const outsideDirectory = join(tempRoot, 'outside') + const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) + await mkdir(outsideDirectory) + await symlink(outsideDirectory, stagingRoot, process.platform === 'win32' ? 'junction' : 'dir') + + await expect( + createRemoteClipboardTransferDirectory( + tempRoot, + NOW_MS, + '00000000-0000-4000-8000-000000000000' + ) + ).rejects.toBeInstanceOf(RemoteClipboardStagingRootUnsafeError) + + expect(await readdir(outsideDirectory)).toEqual([]) + }) +}) + +async function makeFixture(): Promise { + const fixture = await mkdtemp(join(tmpdir(), FIXTURE_PREFIX)) + fixtures.push(fixture) + return fixture +} diff --git a/src/main/window/clipboard-remote-file-staging.test.ts b/src/main/window/clipboard-remote-file-staging.test.ts new file mode 100644 index 00000000000..5ca20e68383 --- /dev/null +++ b/src/main/window/clipboard-remote-file-staging.test.ts @@ -0,0 +1,360 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join, resolve } from 'node:path' + +const { accessMock, lstatMock, mkdirMock, opendirMock, rmMock, writeFileMock } = vi.hoisted(() => ({ + accessMock: vi.fn(), + lstatMock: vi.fn(), + mkdirMock: vi.fn(), + opendirMock: vi.fn(), + rmMock: vi.fn(), + writeFileMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + access: accessMock, + lstat: lstatMock, + mkdir: mkdirMock, + opendir: opendirMock, + rm: rmMock, + writeFile: writeFileMock +})) + +import { + cleanupExpiredRemoteClipboardStaging, + cleanupLegacyRemoteClipboardStaging, + createRemoteClipboardTransferDirectory, + getRemoteClipboardStagingRoot, + removeRemoteClipboardTransferDirectory, + scheduleRemoteClipboardTransferCleanup +} from './clipboard-remote-file-staging' + +const TTL_MS = 60 * 60 * 1000 +const RETRY_MS = 60 * 1000 +const NOW_MS = 1_760_000_000_000 +const TEMP_ROOT = resolve('fixture-temp') +const UID_SUFFIX = typeof process.getuid === 'function' ? `-${process.getuid()}` : '' +const STAGING_ROOT = join(TEMP_ROOT, `orca-clipboard-files${UID_SUFFIX}`) +const MARKER_PATH = join(STAGING_ROOT, '.legacy-cleanup-complete') +const UUID_A = '00000000-0000-4000-8000-000000000000' +const UUID_B = '00000000-0000-4000-8000-000000000001' +const EXPIRED_TRANSFER = `1759990000000-${UUID_A}` +const FRESH_TRANSFER = `1760000000000-${UUID_B}` +const LEGACY_EXPIRED = `orca-clipboard-file-${EXPIRED_TRANSFER}` +const LEGACY_FRESH = `orca-clipboard-file-${FRESH_TRANSFER}` + +type MockDirent = { name: string; isDirectory: () => boolean } + +function directoryEntry(name: string, isDirectory = true): MockDirent { + return { name, isDirectory: () => isDirectory } +} + +function openedDirectory(entries: Iterable): { + [Symbol.asyncIterator]: () => AsyncGenerator + close: ReturnType +} { + return { + async *[Symbol.asyncIterator]() { + yield* entries + }, + close: vi.fn().mockResolvedValue(undefined) + } +} + +function safeDirectoryStats(mtimeMs = NOW_MS - TTL_MS - 1) { + return { + isDirectory: () => true, + isSymbolicLink: () => false, + mode: 0o700, + mtimeMs, + uid: typeof process.getuid === 'function' ? process.getuid() : 0 + } +} + +function missingError(): NodeJS.ErrnoException { + return Object.assign(new Error('missing'), { code: 'ENOENT' }) +} + +describe('remote clipboard staging ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + accessMock.mockRejectedValue(missingError()) + lstatMock.mockResolvedValue(safeDirectoryStats()) + mkdirMock.mockResolvedValue(undefined) + opendirMock.mockResolvedValue(openedDirectory([])) + rmMock.mockResolvedValue(undefined) + writeFileMock.mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('creates a private owned parent and a per-transfer child', async () => { + const result = await createRemoteClipboardTransferDirectory(TEMP_ROOT, NOW_MS, UUID_A) + + expect(result).toBe(join(STAGING_ROOT, `${NOW_MS}-${UUID_A}`)) + expect(mkdirMock).toHaveBeenNthCalledWith(1, STAGING_ROOT, { + recursive: true, + mode: 0o700 + }) + expect(mkdirMock).toHaveBeenNthCalledWith(2, result, { mode: 0o700 }) + }) + + it('scans only the owned parent during normal crash cleanup', async () => { + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(opendirMock).toHaveBeenCalledOnce() + expect(opendirMock).toHaveBeenCalledWith(STAGING_ROOT) + expect(opendirMock).not.toHaveBeenCalledWith(TEMP_ROOT) + }) + + it('removes expired crash leftovers and preserves fresh or unknown children', async () => { + opendirMock.mockResolvedValue( + openedDirectory([ + directoryEntry(EXPIRED_TRANSFER), + directoryEntry(FRESH_TRANSFER), + directoryEntry('unknown-child'), + directoryEntry('.legacy-cleanup-complete', false) + ]) + ) + lstatMock.mockImplementation(async (targetPath: string) => + safeDirectoryStats(targetPath.endsWith(FRESH_TRANSFER) ? NOW_MS - 1_000 : undefined) + ) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).toHaveBeenCalledOnce() + expect(rmMock).toHaveBeenCalledWith(join(STAGING_ROOT, EXPIRED_TRANSFER), { + force: true, + maxRetries: 3, + recursive: true, + retryDelay: 100 + }) + }) + + it('rejects a symlinked owned parent before opening it', async () => { + lstatMock.mockResolvedValue({ + ...safeDirectoryStats(), + isDirectory: () => false, + isSymbolicLink: () => true + }) + + await expect(cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS)).resolves.toBeUndefined() + + expect(opendirMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + }) + + it.skipIf(typeof process.getuid !== 'function')( + 'rejects wrong-owner and insecure POSIX parents', + async () => { + lstatMock + .mockResolvedValueOnce({ ...safeDirectoryStats(), uid: process.getuid!() + 1 }) + .mockResolvedValueOnce({ ...safeDirectoryStats(), mode: 0o755 }) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(opendirMock).not.toHaveBeenCalled() + } + ) + + it('rejects child symlinks and names outside the owned parent', async () => { + opendirMock.mockResolvedValue( + openedDirectory([directoryEntry(EXPIRED_TRANSFER), directoryEntry('..')]) + ) + lstatMock.mockImplementation(async (targetPath: string) => + targetPath === STAGING_ROOT + ? safeDirectoryStats() + : { + ...safeDirectoryStats(), + isDirectory: () => false, + isSymbolicLink: () => true + } + ) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(lstatMock).not.toHaveBeenCalledWith(TEMP_ROOT) + expect(rmMock).not.toHaveBeenCalled() + }) + + it('contains explicit removals to direct owned children', async () => { + expect( + await removeRemoteClipboardTransferDirectory(TEMP_ROOT, join(STAGING_ROOT, '..', 'victim')) + ).toBe(false) + expect(await removeRemoteClipboardTransferDirectory(TEMP_ROOT, STAGING_ROOT)).toBe(false) + + expect(rmMock).not.toHaveBeenCalled() + }) + + it('treats an already-removed transfer as cleanup success', async () => { + lstatMock.mockImplementation(async (targetPath: string) => { + if (targetPath === STAGING_ROOT) { + return safeDirectoryStats() + } + throw missingError() + }) + + await expect( + removeRemoteClipboardTransferDirectory(TEMP_ROOT, join(STAGING_ROOT, EXPIRED_TRANSFER)) + ).resolves.toBe(true) + + expect(rmMock).not.toHaveBeenCalled() + }) + + it('continues past Windows-style lock failures and retries on a later sweep', async () => { + opendirMock.mockResolvedValue( + openedDirectory([directoryEntry(EXPIRED_TRANSFER), directoryEntry(`1759990000000-${UUID_B}`)]) + ) + rmMock.mockImplementation(async (targetPath: string) => { + if (targetPath.endsWith(EXPIRED_TRANSFER)) { + throw Object.assign(new Error('locked'), { code: 'EPERM' }) + } + }) + + await expect(cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS)).resolves.toBeUndefined() + + expect(rmMock).toHaveBeenCalledTimes(2) + rmMock.mockResolvedValue(undefined) + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + expect(rmMock).toHaveBeenCalledTimes(4) + }) + + it('bounds locked-file timer retries', async () => { + vi.useFakeTimers() + rmMock.mockRejectedValue(Object.assign(new Error('locked'), { code: 'EPERM' })) + + scheduleRemoteClipboardTransferCleanup(TEMP_ROOT, join(STAGING_ROOT, EXPIRED_TRANSFER)) + await vi.advanceTimersByTimeAsync(TTL_MS + 4 * RETRY_MS) + + expect(rmMock).toHaveBeenCalledTimes(4) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps owned-child cleanup concurrency at eight', async () => { + const entries = Array.from({ length: 257 }, (_, index) => + directoryEntry(`1759990000000-00000000-0000-4000-8000-${String(index).padStart(12, '0')}`) + ) + opendirMock.mockResolvedValue(openedDirectory(entries)) + let active = 0 + let peak = 0 + lstatMock.mockImplementation(async () => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolveImmediate) => setImmediate(resolveImmediate)) + active -= 1 + return safeDirectoryStats() + }) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).toHaveBeenCalledTimes(257) + expect(peak).toBe(8) + }) +}) + +describe('legacy remote clipboard staging compatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + accessMock.mockRejectedValue(missingError()) + lstatMock.mockResolvedValue(safeDirectoryStats()) + mkdirMock.mockResolvedValue(undefined) + opendirMock.mockResolvedValue(openedDirectory([])) + rmMock.mockResolvedValue(undefined) + writeFileMock.mockResolvedValue(undefined) + }) + + it('marks migration complete when a legacy child falls beyond the 4096-entry window', async () => { + let visited = 0 + let reachedLateLegacyChild = false + function* foreignEntries(): Generator { + for (let index = 0; index < 200_000; index += 1) { + if (index === 4_096) { + reachedLateLegacyChild = true + yield directoryEntry(LEGACY_EXPIRED) + } + visited += 1 + yield directoryEntry(`foreign-${index}`, index % 2 === 0) + } + } + opendirMock.mockResolvedValue(openedDirectory(foreignEntries())) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(visited).toBe(4_096) + expect(reachedLateLegacyChild).toBe(false) + expect(lstatMock).toHaveBeenCalledOnce() + expect(rmMock).not.toHaveBeenCalled() + expect(writeFileMock).toHaveBeenCalledWith(MARKER_PATH, '', { + flag: 'wx', + mode: 0o600 + }) + }) + + it('removes only exact expired legacy children encountered inside the bound', async () => { + opendirMock.mockResolvedValue( + openedDirectory([ + directoryEntry('orca-clipboard-file-lookalike'), + directoryEntry(LEGACY_EXPIRED), + directoryEntry(LEGACY_EXPIRED, false) + ]) + ) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).toHaveBeenCalledOnce() + expect(rmMock).toHaveBeenCalledWith(join(TEMP_ROOT, LEGACY_EXPIRED), { + force: true, + maxRetries: 3, + recursive: true, + retryDelay: 100 + }) + expect(writeFileMock).toHaveBeenCalledOnce() + }) + + it('does not mark migration complete while a fresh or locked legacy child remains', async () => { + opendirMock.mockResolvedValue( + openedDirectory([directoryEntry(LEGACY_FRESH), directoryEntry(LEGACY_EXPIRED)]) + ) + lstatMock.mockImplementation(async (targetPath: string) => + safeDirectoryStats(targetPath.endsWith(LEGACY_FRESH) ? NOW_MS - 1_000 : undefined) + ) + rmMock.mockRejectedValue(Object.assign(new Error('locked'), { code: 'EBUSY' })) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(writeFileMock).not.toHaveBeenCalled() + }) + + it('never follows legacy symlinks outside the shared temp root', async () => { + opendirMock.mockResolvedValue(openedDirectory([directoryEntry(LEGACY_EXPIRED)])) + lstatMock.mockImplementation(async (targetPath: string) => + targetPath === STAGING_ROOT + ? safeDirectoryStats() + : { + ...safeDirectoryStats(), + isDirectory: () => false, + isSymbolicLink: () => true + } + ) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).not.toHaveBeenCalled() + expect(writeFileMock).toHaveBeenCalledOnce() + }) + + it('skips the shared root after the one-time marker exists', async () => { + accessMock.mockResolvedValue(undefined) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(accessMock).toHaveBeenCalledWith(MARKER_PATH) + expect(opendirMock).not.toHaveBeenCalled() + }) + + it('uses a platform-safe owned parent name', () => { + expect(getRemoteClipboardStagingRoot(TEMP_ROOT)).toBe(STAGING_ROOT) + }) +}) diff --git a/src/main/window/clipboard-remote-file-staging.ts b/src/main/window/clipboard-remote-file-staging.ts new file mode 100644 index 00000000000..58c1e0f2518 --- /dev/null +++ b/src/main/window/clipboard-remote-file-staging.ts @@ -0,0 +1,266 @@ +import type { Dir, Stats } from 'node:fs' +import { access, lstat, mkdir, opendir, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' + +const REMOTE_CLIPBOARD_STAGING_ROOT_NAME = 'orca-clipboard-files' +const REMOTE_CLIPBOARD_LEGACY_PREFIX = 'orca-clipboard-file-' +const REMOTE_CLIPBOARD_MIGRATION_MARKER = '.legacy-cleanup-complete' +const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 +const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 +const REMOTE_CLIPBOARD_CLEANUP_RETRY_MS = 60 * 1000 +const REMOTE_CLIPBOARD_CLEANUP_RETRY_LIMIT = 3 +// Why: compatibility cleanup must never restore O(shared temp root) work. +const REMOTE_CLIPBOARD_LEGACY_ENTRY_LIMIT = 4_096 +const UUID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const TRANSFER_DIRECTORY_PATTERN = new RegExp(`^\\d{1,16}-${UUID_PATTERN}$`, 'i') +const LEGACY_DIRECTORY_PATTERN = new RegExp( + `^${REMOTE_CLIPBOARD_LEGACY_PREFIX}\\d{1,16}-${UUID_PATTERN}$`, + 'i' +) +const REMOVE_OPTIONS = { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 +} as const + +type CleanupResult = 'failed' | 'fresh' | 'ignored' | 'removed' + +export class RemoteClipboardStagingRootUnsafeError extends Error { + constructor() { + super('Remote clipboard staging root is unsafe') + this.name = 'RemoteClipboardStagingRootUnsafeError' + } +} + +export function getRemoteClipboardStagingRoot(tempRoot: string): string { + const uidSuffix = typeof process.getuid === 'function' ? `-${process.getuid()}` : '' + return join(tempRoot, `${REMOTE_CLIPBOARD_STAGING_ROOT_NAME}${uidSuffix}`) +} + +export async function createRemoteClipboardTransferDirectory( + tempRoot: string, + createdAtMs: number, + transferId: string +): Promise { + const stagingRoot = await ensureRemoteClipboardStagingRoot(tempRoot) + const transferDirectory = join(stagingRoot, `${createdAtMs}-${transferId}`) + if ( + !isTransferDirectoryName(basename(transferDirectory)) || + !isDirectChild(stagingRoot, transferDirectory) + ) { + throw new Error('Remote clipboard transfer path escapes its staging root') + } + await mkdir(transferDirectory, { mode: 0o700 }) + const transferStats = await lstat(transferDirectory) + if (!isSafeOwnedDirectory(transferStats)) { + throw new Error('Remote clipboard transfer directory is unsafe') + } + return transferDirectory +} + +export async function cleanupExpiredRemoteClipboardStaging( + tempRoot: string, + nowMs = Date.now() +): Promise { + let stagingRoot: string + try { + stagingRoot = await ensureRemoteClipboardStagingRoot(tempRoot) + } catch { + return + } + await sweepDirectories(stagingRoot, nowMs, isTransferDirectoryName) +} + +export async function cleanupLegacyRemoteClipboardStaging( + tempRoot: string, + nowMs = Date.now() +): Promise { + let stagingRoot: string + try { + stagingRoot = await ensureRemoteClipboardStagingRoot(tempRoot) + } catch { + return + } + + const markerPath = join(stagingRoot, REMOTE_CLIPBOARD_MIGRATION_MARKER) + try { + await access(markerPath) + return + } catch (error) { + if (!isMissingPathError(error)) { + return + } + } + + const result = await sweepDirectories( + tempRoot, + nowMs, + isLegacyTransferDirectoryName, + REMOTE_CLIPBOARD_LEGACY_ENTRY_LIMIT + ) + if (result.complete && !result.hasFreshDirectories && !result.hasFailures) { + await writeFile(markerPath, '', { flag: 'wx', mode: 0o600 }).catch(() => undefined) + } +} + +export async function removeRemoteClipboardTransferDirectory( + tempRoot: string, + transferDirectory: string +): Promise { + const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) + if ( + !isDirectChild(stagingRoot, transferDirectory) || + !isTransferDirectoryName(basename(resolve(transferDirectory))) + ) { + return false + } + try { + await ensureRemoteClipboardStagingRoot(tempRoot) + const transferStats = await lstat(transferDirectory) + if (!isSafeOwnedDirectory(transferStats)) { + return false + } + await rm(transferDirectory, REMOVE_OPTIONS) + return true + } catch (error) { + return isMissingPathError(error) + } +} + +export function scheduleRemoteClipboardTransferCleanup( + tempRoot: string, + transferDirectory: string +): void { + scheduleCleanupAttempt( + tempRoot, + transferDirectory, + REMOTE_CLIPBOARD_FILE_TTL_MS, + REMOTE_CLIPBOARD_CLEANUP_RETRY_LIMIT + ) +} + +function scheduleCleanupAttempt( + tempRoot: string, + transferDirectory: string, + delayMs: number, + retriesRemaining: number +): void { + const timer = setTimeout(() => { + void removeRemoteClipboardTransferDirectory(tempRoot, transferDirectory).then((removed) => { + if (!removed && retriesRemaining > 0) { + scheduleCleanupAttempt( + tempRoot, + transferDirectory, + REMOTE_CLIPBOARD_CLEANUP_RETRY_MS, + retriesRemaining - 1 + ) + } + }) + }, delayMs) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } +} + +async function ensureRemoteClipboardStagingRoot(tempRoot: string): Promise { + const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) + await mkdir(stagingRoot, { recursive: true, mode: 0o700 }) + const rootStats = await lstat(stagingRoot) + if (!isSafeOwnedDirectory(rootStats)) { + throw new RemoteClipboardStagingRootUnsafeError() + } + return stagingRoot +} + +async function sweepDirectories( + root: string, + nowMs: number, + ownsEntry: (name: string) => boolean, + entryLimit = Number.POSITIVE_INFINITY +): Promise<{ complete: boolean; hasFailures: boolean; hasFreshDirectories: boolean }> { + let rootDir: Dir + try { + rootDir = await opendir(root) + } catch { + return { complete: false, hasFailures: false, hasFreshDirectories: false } + } + + let complete = true + let entriesVisited = 0 + let hasFailures = false + let hasFreshDirectories = false + const pending = new Set>() + try { + for await (const entry of rootDir) { + entriesVisited += 1 + if (entry.isDirectory() && ownsEntry(entry.name)) { + const candidate = join(root, entry.name) + if (isDirectChild(root, candidate)) { + const cleanup = cleanupDirectory(candidate, nowMs).then((result) => { + hasFailures ||= result === 'failed' + hasFreshDirectories ||= result === 'fresh' + }) + pending.add(cleanup) + void cleanup.finally(() => pending.delete(cleanup)) + if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { + await Promise.race(pending) + } + } + } + if (entriesVisited >= entryLimit) { + break + } + } + } catch { + complete = false + } finally { + await rootDir.close().catch(() => undefined) + } + await Promise.all(pending) + return { complete, hasFailures, hasFreshDirectories } +} + +async function cleanupDirectory(directory: string, nowMs: number): Promise { + try { + const directoryStats = await lstat(directory) + if (!isSafeOwnedDirectory(directoryStats)) { + return 'ignored' + } + if (nowMs - directoryStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { + return 'fresh' + } + await rm(directory, REMOVE_OPTIONS) + return 'removed' + } catch (error) { + return isMissingPathError(error) ? 'ignored' : 'failed' + } +} + +function isSafeOwnedDirectory(stats: Stats): boolean { + if (!stats.isDirectory() || stats.isSymbolicLink()) { + return false + } + if (typeof process.getuid !== 'function') { + return true + } + return stats.uid === process.getuid() && (stats.mode & 0o777) === 0o700 +} + +function isDirectChild(parent: string, candidate: string): boolean { + return dirname(resolve(candidate)) === resolve(parent) +} + +function isTransferDirectoryName(name: string): boolean { + return TRANSFER_DIRECTORY_PATTERN.test(name) +} + +function isLegacyTransferDirectoryName(name: string): boolean { + return LEGACY_DIRECTORY_PATTERN.test(name) +} + +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ) +} diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index b6d1ff70035..3f02ba389d2 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -157,6 +157,9 @@ describe('createMainWindow', () => { on: vi.fn((event, handler) => { windowHandlers[event] = handler }), + once: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), invalidate: vi.fn(), @@ -699,6 +702,9 @@ describe('createMainWindow', () => { on: vi.fn((event, handler) => { windowHandlers[event] = handler }), + once: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), invalidate: vi.fn(), @@ -3431,6 +3437,9 @@ describe('createMainWindow', () => { on: vi.fn((event, handler) => { windowHandlers[event] = handler }), + once: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), invalidate: vi.fn(), @@ -3490,6 +3499,21 @@ describe('createMainWindow', () => { expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) }) + it('can reveal the startup window after renderer load before ready-to-show', () => { + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + + createMainWindow(null, { revealOnDidFinishLoad: true }) + const revealAfterLoad = browserWindowInstance.webContents.on.mock.calls.find( + ([event]) => event === 'did-finish-load' + )?.[1] + expect(revealAfterLoad).toBeTypeOf('function') + revealAfterLoad?.() + + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + windowHandlers['ready-to-show']() + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + }) + it('reveals the startup window on Windows when ready-to-show never fires', () => { vi.useFakeTimers() const { browserWindowInstance } = createStartupRevealWindowFixture() diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 17bcd061e81..0661b6c3d06 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -59,6 +59,7 @@ import { closeDashboardPopout } from './dashboard-popout-window' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' import { isMacosTahoeOrNewer } from './macos-tahoe-release' import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' +import { installWindowsPathRegistryChangeListener } from '../pty/windows-path-registry-change' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. const activeRepaintJiggles = new WeakSet() @@ -206,6 +207,8 @@ type CreateMainWindowOptions = { }) => void /** Defer renderer load until IPC handlers are registered, or eager renderer calls race into missing channels. */ deferLoad?: boolean + /** Reveal after load instead of first paint when startup must show the shell before slower renderer work. */ + revealOnDidFinishLoad?: boolean title?: string getKeybindings?: () => KeybindingOverrides | undefined onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void @@ -302,6 +305,7 @@ export function createMainWindow( } }) const rendererWebContentsId = mainWindow.webContents.id + installWindowsPathRegistryChangeListener(mainWindow) // Why: native paste fallback is privileged IPC; only the top-level renderer may request it. setTrustedUIRendererWebContentsId(rendererWebContentsId) @@ -332,7 +336,7 @@ export function createMainWindow( mainWindow.webContents.setZoomLevel(level) // Why: native traffic lights don't scale with CSS zoom; reposition on startup to stay aligned with the zoomed titlebar. if (process.platform === 'darwin') { - syncTrafficLightPosition(mainWindow, Math.pow(1.2, level)) + syncTrafficLightPosition(mainWindow, 1.2 ** level) } }) @@ -377,6 +381,9 @@ export function createMainWindow( mainWindow.show() } mainWindow.on('ready-to-show', revealInitialWindow) + if (opts?.revealOnDidFinishLoad === true) { + mainWindow.webContents.on('did-finish-load', revealInitialWindow) + } // Why: persist window bounds to restore last position/size; debounce to avoid hammering persistence during resize drags. let boundsTimer: ReturnType | null = null diff --git a/src/main/windows-native-registry.ts b/src/main/windows-native-registry.ts new file mode 100644 index 00000000000..e3a2d77889d --- /dev/null +++ b/src/main/windows-native-registry.ts @@ -0,0 +1,25 @@ +import { createRequire } from 'node:module' + +export type WindowsNativeRegistryValue = { + name?: unknown + type?: unknown + value?: unknown +} + +export type WindowsNativeRegistryModule = { + HK: { CU: number; LM: number } + getRegistryKey: ( + root: number, + path: string + ) => Record | null | undefined +} + +export const WINDOWS_REG_SZ = 1 +export const WINDOWS_REG_EXPAND_SZ = 2 + +const requireFromMain = createRequire(__filename) + +export function loadWindowsNativeRegistry(): WindowsNativeRegistryModule { + // Why: non-Windows installs omit this optional dependency, so never resolve it at module load. + return requireFromMain('windows-native-registry') as WindowsNativeRegistryModule +} diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index 5aa8d619d92..ef303c18220 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -23,7 +23,8 @@ const ORCA_OWNED_PROVENANCE_META_KEYS = [ 'orcaCreationSource', 'orcaCreationWorkspaceLayout', 'automationProvenance', - 'cliProvenance' + 'cliProvenance', + 'creatorProvenance' ] as const type UnregisteredOrcaCleanupMeta = Pick< WorktreeMeta, diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index cd77d761caf..719368e3af4 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -29,7 +29,8 @@ import { parseWslPath, toLinuxPath, toWindowsWslPath, - wslUncDirectoryExists + wslUncDirectoryExists, + wslUncDirectoryExistsAsync } from './wsl' function withPlatform(value: NodeJS.Platform, fn: () => T): T { @@ -805,36 +806,39 @@ describe('wslUncDirectoryExists', () => { }) it('returns true when the distro reports the directory exists', () => { - execFileSyncMock.mockReturnValue('') + execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_EXISTS__') const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') ) expect(result).toBe(true) expect(execFileSyncMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'test', '-d', '/home/jin/repo'], + [ + '-d', + 'Ubuntu', + '--', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ], expect.objectContaining({ timeout: 5000 }) ) }) - it('returns false when test -d exits non-zero (directory missing)', () => { - execFileSyncMock.mockImplementation(() => { - // Why: child_process surfaces a non-zero exit as an Error with `status`. - const error = new Error('Command failed') as Error & { status: number } - error.status = 1 - throw error - }) + it('returns false when the guest reports the directory missing', () => { + execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_MISSING__') const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') ) expect(result).toBe(false) }) - it('returns null when wsl.exe is unavailable (inconclusive)', () => { + it('returns null when wsl.exe or the distro is unavailable', () => { execFileSyncMock.mockImplementation(() => { - // No numeric `status` -> spawn failure (ENOENT), not a missing directory. - const error = new Error('spawn wsl.exe ENOENT') as Error & { code: string } - error.code = 'ENOENT' + const error = new Error('distro unavailable') as Error & { status: number } + error.status = 4294967295 throw error }) const result = withPlatform('win32', () => @@ -851,3 +855,67 @@ describe('wslUncDirectoryExists', () => { expect(execFileSyncMock).not.toHaveBeenCalled() }) }) + +describe('wslUncDirectoryExistsAsync', () => { + afterEach(() => { + execFileMock.mockReset() + }) + + it('returns true when the distro reports the directory exists', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => + callback(null, '__ORCA_DIRECTORY_EXISTS__') + ) + + await expect( + withPlatformAsync('win32', () => + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ) + ).resolves.toBe(true) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ], + expect.objectContaining({ timeout: 5000 }), + expect.any(Function) + ) + }) + + it('distinguishes a missing directory from an inconclusive probe', async () => { + execFileMock + .mockImplementationOnce((_command, _args, _options, callback) => + callback(null, '__ORCA_DIRECTORY_MISSING__') + ) + .mockImplementationOnce((_command, _args, _options, callback) => + callback(Object.assign(new Error('distro unavailable'), { code: 4294967295 }), '') + ) + + await withPlatformAsync('win32', async () => { + await expect( + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') + ).resolves.toBe(false) + await expect( + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ).resolves.toBeNull() + }) + }) + + it('returns null without spawning for paths outside WSL or off Windows', async () => { + await expect( + withPlatformAsync('win32', () => wslUncDirectoryExistsAsync('C:\\Users\\jin\\repo')) + ).resolves.toBeNull() + await expect( + withPlatformAsync('linux', () => + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin') + ) + ).resolves.toBeNull() + expect(execFileMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl.ts b/src/main/wsl.ts index e415a3c937f..8311170faeb 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -21,6 +21,33 @@ export type WslPathInfo = { linuxPath: string } +const WSL_DIRECTORY_EXISTS_MARKER = '__ORCA_DIRECTORY_EXISTS__' +const WSL_DIRECTORY_MISSING_MARKER = '__ORCA_DIRECTORY_MISSING__' + +function getWslDirectoryProbeArgs(info: WslPathInfo): string[] { + return [ + '-d', + info.distro, + '--', + 'sh', + '-c', + `if [ -d "$1" ]; then printf ${WSL_DIRECTORY_EXISTS_MARKER}; else printf ${WSL_DIRECTORY_MISSING_MARKER}; fi`, + 'sh', + info.linuxPath + ] +} + +function parseWslDirectoryProbeOutput(stdout: unknown): boolean | null { + const output = String(stdout) + if (output.includes(WSL_DIRECTORY_EXISTS_MARKER)) { + return true + } + if (output.includes(WSL_DIRECTORY_MISSING_MARKER)) { + return false + } + return null +} + /** * Detect if a Windows path is a WSL UNC path and extract the distro name * and equivalent Linux path. @@ -49,8 +76,8 @@ export function isWslPath(path: string): boolean { * Why: Win32 fs.statSync against the WSL 9P filesystem (\\wsl.localhost\...) * is unreliable for repos that live on the WSL side — it can report ENOENT for * directories that exist, which made opening a WSL worktree fail with - * "Working directory ... does not exist". `wsl.exe -d test -d` asks - * the distro directly, which is the authoritative answer. Returns null (rather + * "Working directory ... does not exist". The guest marker probe asks the + * distro directly, which is the authoritative answer. Returns null (rather * than false) when wsl.exe is unavailable or errors so callers can fall back to * the fs check instead of falsely rejecting a valid directory. */ @@ -63,22 +90,33 @@ export function wslUncDirectoryExists(uncPath: string): boolean | null { return null } try { - execFileSync('wsl.exe', ['-d', info.distro, '--', 'test', '-d', info.linuxPath], { + const stdout = execFileSync('wsl.exe', getWslDirectoryProbeArgs(info), { stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 + timeout: 5000, + encoding: 'utf8' }) - return true - } catch (error) { - // A non-zero exit (directory missing) surfaces as an error with a numeric - // `status`; treat that as a definitive "does not exist". Any other failure - // (wsl.exe missing, distro not running, timeout) is inconclusive -> null. - if (typeof (error as { status?: unknown })?.status === 'number') { - return false - } + return parseWslDirectoryProbeOutput(stdout) + } catch { return null } } +export function wslUncDirectoryExistsAsync(uncPath: string): Promise { + if (process.platform !== 'win32') { + return Promise.resolve(null) + } + const info = parseWslUncPath(uncPath) + if (!info) { + return Promise.resolve(null) + } + return new Promise((resolve) => { + execFile('wsl.exe', getWslDirectoryProbeArgs(info), { timeout: 5000 }, (_error, stdout) => { + // Why: wsl.exe uses numeric exits for both guest results and host failures; only the guest marker is authoritative. + resolve(parseWslDirectoryProbeOutput(stdout)) + }) + }) +} + /** * Convert a Windows path to a Linux path for commands that will execute inside WSL. * Returns the path unchanged if it is already POSIX-style. diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index bc6abd8cc03..8c901236967 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2,13 +2,20 @@ import type { CreateHostedReviewArgs, CreateHostedReviewResult, + CreateStackedHostedReviewArgs, + CreateStackedHostedReviewResult, HostedReviewCreationEligibility, HostedReviewCreationEligibilityArgs, HostedReviewForBranchArgs, HostedReviewInfo, HostedReviewProvider } from '../shared/hosted-review' +import type { + BitbucketConnectArgs, + BitbucketConnectionStatus +} from '../shared/bitbucket-credentials' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { BrowserFindSource } from '../shared/browser-find-source' import type { DashboardRevealAgentArgs, @@ -226,6 +233,7 @@ import type { MarkdownDocument, FloatingTerminalCwdRequest, GitHubIssueUpdate, + GitHubReactionContent, GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshEvent, @@ -441,17 +449,7 @@ import type { ComputerUsePermissionSetupResult, ComputerUsePermissionStatusResult } from '../shared/computer-use-permissions-types' -import type { - ClaudeUsageBreakdownKind, - ClaudeUsageBreakdownRow, - ClaudeUsageDailyPoint, - ClaudeUsageRange, - ClaudeUsageScanState, - ClaudeUsageScope, - ClaudeUsageSessionRow, - ClaudeUsageSnapshot, - ClaudeUsageSummary -} from '../shared/claude-usage-types' +import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../shared/claude-usage-types' import type { CodexRateLimitResetResult, GrokAccountStatus, @@ -477,27 +475,10 @@ import type { WorkspacePortScanResult } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' -import type { - CodexUsageBreakdownKind, - CodexUsageBreakdownRow, - CodexUsageDailyPoint, - CodexUsageRange, - CodexUsageScanState, - CodexUsageScope, - CodexUsageSessionRow, - CodexUsageSnapshot, - CodexUsageSummary -} from '../shared/codex-usage-types' +import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../shared/codex-usage-types' import type { OpenCodeUsageBreakdownKind, - OpenCodeUsageBreakdownRow, - OpenCodeUsageDailyPoint, - OpenCodeUsageRange, - OpenCodeUsageScanState, - OpenCodeUsageScope, - OpenCodeUsageSessionRow, - OpenCodeUsageSnapshot, - OpenCodeUsageSummary + OpenCodeUsageSnapshot } from '../shared/opencode-usage-types' import type { AiVaultDeleteSessionArgs, @@ -834,92 +815,39 @@ export type MemoryApi = { getSnapshot: () => Promise } -export type ClaudeUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getDaily: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getBreakdown: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - kind: ClaudeUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - limit?: number - }) => Promise +type UsageProviderSnapshot = { + scanState: unknown + summary: { scope: string; range: string } + daily: unknown[] + modelBreakdown: unknown[] + recentSessions: unknown[] } -export type CodexUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: CodexUsageScope - range: CodexUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: CodexUsageScope - range: CodexUsageRange - }) => Promise - getDaily: (args: { - scope: CodexUsageScope - range: CodexUsageRange - }) => Promise - getBreakdown: (args: { - scope: CodexUsageScope - range: CodexUsageRange - kind: CodexUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: CodexUsageScope - range: CodexUsageRange - limit?: number - }) => Promise +type UsageQueryArgs = Pick< + Snapshot['summary'], + 'scope' | 'range' +> + +type UsageProviderApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSnapshot: (args: UsageQueryArgs & { limit?: number }) => Promise + getSummary: (args: UsageQueryArgs) => Promise + getDaily: (args: UsageQueryArgs) => Promise + getBreakdown: ( + args: UsageQueryArgs & { kind: BreakdownKind } + ) => Promise + getRecentSessions: ( + args: UsageQueryArgs & { limit?: number } + ) => Promise } -export type OpenCodeUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - }) => Promise - getDaily: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - }) => Promise - getBreakdown: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - kind: OpenCodeUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - limit?: number - }) => Promise -} +export type ClaudeUsageApi = UsageProviderApi + +export type CodexUsageApi = UsageProviderApi + +export type OpenCodeUsageApi = UsageProviderApi export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise @@ -1224,6 +1152,9 @@ export type PreloadApi = { get: () => { platform: NodeJS.Platform osRelease: string + arch: string + /** Login shell or ComSpec when available. */ + shell: string displayServer: 'wayland' | 'x11' | null } } @@ -1265,6 +1196,7 @@ export type PreloadApi = { | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' | 'projectGroupId' | 'projectGroupOrder' | 'forkSyncMode' @@ -1401,6 +1333,7 @@ export type PreloadApi = { | 'pendingFirstAgentMessageRename' | 'firstAgentMessageRenameError' | 'lastActivityAt' + | 'diffComments' > > }) => Promise @@ -1480,6 +1413,7 @@ export type PreloadApi = { worktreeId: string branchName: string expectedHead: string + hostId?: ExecutionHostId }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise listLineage: () => Promise<{ @@ -1564,6 +1498,11 @@ export type PreloadApi = { snapshot?: string snapshotCols?: number snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotSeq?: number isReattach?: boolean isAlternateScreen?: boolean replay?: string @@ -1630,6 +1569,7 @@ export type PreloadApi = { opts?: { scrollbackRows?: number } ) => Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -1645,6 +1585,9 @@ export type PreloadApi = { /** Trailing incomplete escape the emulator ingested; the restorer must * write it after its post-replay resets, last before live chunks. */ pendingEscapeTailAnsi?: string + /** Effective kitty flags the snapshot owner proved at `seq`. Absent means + * unknown; consumers must not turn that into a known `0`. */ + kittyKeyboardFlags?: number } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ pendingPtyCount: number @@ -1715,6 +1658,7 @@ export type PreloadApi = { rows: number seq?: number lastTitle?: string + kittyKeyboardFlags?: number } | null ) => void declarePendingPaneSerializer: (paneKey: string) => Promise @@ -1882,6 +1826,15 @@ export type PreloadApi = { prRepo?: GitHubOwnerRepo | null noCache?: boolean }) => Promise + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }) => Promise resolveReviewThread: (args: { repoPath: string repoId?: string @@ -2043,6 +1996,7 @@ export type PreloadApi = { args: HostedReviewCreationEligibilityArgs ) => Promise create: (args: CreateHostedReviewArgs) => Promise + createStacked: (args: CreateStackedHostedReviewArgs) => Promise } // ── GitLab — parallel to gh, MR/issue surface only in v1 ──────── // Shapes mirror gh.* except where GitLab's API differs (MR states, host-qualified project path, `glab api -i` paging). @@ -2188,6 +2142,13 @@ export type PreloadApi = { } ) => Promise | null> } + bitbucket: { + connect: ( + args: BitbucketConnectArgs + ) => Promise<{ ok: true; account: string | null } | { ok: false; error: string }> + disconnect: () => Promise + status: () => Promise + } linear: { connect: (args: { apiKey: string @@ -2415,6 +2376,10 @@ export type PreloadApi = { /** Subscribe to out-of-band settings updates (e.g. View > Appearance toggles) to stay in sync with main. */ onChanged: (callback: (updates: Partial) => void) => () => void } + agentAwake: { + getStatus: () => Promise + onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void + } localhostWorktreeLabels: { register: (args: LocalhostWorktreeLabelRoute) => Promise } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b192819496..7a1d4030a32 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' +import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { DashboardRevealAgentArgs, DashboardSleepWorkspaceArgs, @@ -75,6 +76,7 @@ import type { GitHubCommentResult, GitHubCreateIssueResult, GitHubOwnerRepo, + GitHubReactionContent, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -224,6 +226,7 @@ import type { } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import { createBrowserFindSubscriptions } from './browser-find-subscriptions' +import { createUsageProviderApi } from './usage-provider-api' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' import type { @@ -566,9 +569,15 @@ const api = { platform: { get: () => ({ platform: process.platform, + // Why: sandboxed preload cannot require node:os; Electron exposes the OS + // version on process.getSystemVersion when available. osRelease: (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ?? '', + arch: process.arch, + // Why: these identify the default shell without probing user config files. + // process.env is available in the sandboxed preload; node:os is not. + shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', displayServer: getLinuxDisplayServer() }) } satisfies PreloadApi['platform'], @@ -961,6 +970,11 @@ const api = { snapshot?: string snapshotCols?: number snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotSeq?: number isReattach?: boolean isAlternateScreen?: boolean replay?: string @@ -1071,6 +1085,7 @@ const api = { opts?: { scrollbackRows?: number } ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -1080,6 +1095,7 @@ const api = { alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), getRendererDeliveryDebugSnapshot: (): Promise<{ @@ -1242,6 +1258,7 @@ const api = { rows: number seq?: number lastTitle?: string + kittyKeyboardFlags?: number } | null ): void => { ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) @@ -1448,7 +1465,7 @@ const api = { checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), + }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), rerunPRChecks: (args: { repoPath: string @@ -1470,6 +1487,16 @@ const api = { noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prComments', args), + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), + resolveReviewThread: (args: { repoPath: string repoId?: string @@ -1692,12 +1719,29 @@ const api = { ipcRenderer.invoke('hostedReview:forBranch', args), getCreationEligibility: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:getCreationEligibility', args), - create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args) + create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), + createStacked: (args: unknown): Promise => + ipcRenderer.invoke('hostedReview:createStacked', args) }, // Why: GitLab bindings live in `./gitlab` so `gl.*` changes don't conflict on every upstream sync of this central file. gl: glApi, + bitbucket: { + connect: (args: { + authMode: 'token' | 'basic' + accessToken?: string | null + email?: string | null + apiToken?: string | null + baseUrl?: string | null + }): Promise<{ ok: true; account: string | null } | { ok: false; error: string }> => + ipcRenderer.invoke('bitbucket:connect', args), + + disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), + + status: (): Promise => ipcRenderer.invoke('bitbucket:status') + }, + linear: { connect: (args: { apiKey: string @@ -2036,6 +2080,16 @@ const api = { } }, + agentAwake: { + getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), + onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => + callback(status) + ipcRenderer.on('agentAwake:changed', listener) + return () => ipcRenderer.removeListener('agentAwake:changed', listener) + } + } satisfies PreloadApi['agentAwake'], + localhostWorktreeLabels: { register: (args: LocalhostWorktreeLabelRoute): Promise => ipcRenderer.invoke('localhostWorktreeLabels:register', args) @@ -4191,59 +4245,9 @@ const api = { getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') }, - claudeUsage: { - getScanState: (): Promise => ipcRenderer.invoke('claudeUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('claudeUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('claudeUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('claudeUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('claudeUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('claudeUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('claudeUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('claudeUsage:getRecentSessions', args) - }, - - codexUsage: { - getScanState: (): Promise => ipcRenderer.invoke('codexUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('codexUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('codexUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('codexUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('codexUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('codexUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('codexUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('codexUsage:getRecentSessions', args) - }, - - openCodeUsage: { - getScanState: (): Promise => ipcRenderer.invoke('openCodeUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('openCodeUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('openCodeUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('openCodeUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('openCodeUsage:getRecentSessions', args) - }, + claudeUsage: createUsageProviderApi(ipcRenderer, 'claudeUsage'), + codexUsage: createUsageProviderApi(ipcRenderer, 'codexUsage'), + openCodeUsage: createUsageProviderApi(ipcRenderer, 'openCodeUsage'), aiVault: { listSessions: (args?: AiVaultListArgs): Promise => @@ -4933,8 +4937,7 @@ if (process.contextIsolated) { console.error(error) } } else { - // @ts-ignore (define in dts) window.electron = electronAPI - // @ts-ignore (define in dts) + // @ts-expect-error (define in dts) window.api = api } diff --git a/src/preload/usage-provider-api.test.ts b/src/preload/usage-provider-api.test.ts new file mode 100644 index 00000000000..0ce594cbb9f --- /dev/null +++ b/src/preload/usage-provider-api.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { createUsageProviderApi } from './usage-provider-api' + +describe('usage provider preload API', () => { + it('keeps every provider on its existing IPC channels', async () => { + const invoke = vi.fn().mockResolvedValue(undefined) + const query = { scope: 'orca' as const, range: '30d' as const } + + for (const prefix of ['claudeUsage', 'codexUsage', 'openCodeUsage'] as const) { + const api = createUsageProviderApi({ invoke } as never, prefix) + await api.getScanState() + await api.setEnabled({ enabled: true }) + await api.refresh() + await api.getSnapshot({ ...query, limit: 7 }) + await api.getSummary(query) + await api.getDaily(query) + await api.getBreakdown({ ...query, kind: 'project' }) + await api.getRecentSessions({ ...query, limit: 4 }) + + expect(invoke.mock.calls).toEqual([ + [`${prefix}:getScanState`], + [`${prefix}:setEnabled`, { enabled: true }], + [`${prefix}:refresh`, undefined], + [`${prefix}:getSnapshot`, { ...query, limit: 7 }], + [`${prefix}:getSummary`, query], + [`${prefix}:getDaily`, query], + [`${prefix}:getBreakdown`, { ...query, kind: 'project' }], + [`${prefix}:getRecentSessions`, { ...query, limit: 4 }] + ]) + invoke.mockClear() + } + }) +}) diff --git a/src/preload/usage-provider-api.ts b/src/preload/usage-provider-api.ts new file mode 100644 index 00000000000..b80d72850c0 --- /dev/null +++ b/src/preload/usage-provider-api.ts @@ -0,0 +1,29 @@ +import type { IpcRenderer } from 'electron' +import type { PreloadApi } from './api-types' + +type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'openCodeUsage' +type UsageProviderApi = PreloadApi[UsageProviderApiKey] +type UsageRangeArgs = { scope: string; range: string } + +export function createUsageProviderApi( + ipc: Pick, + prefix: Key +): PreloadApi[Key] +export function createUsageProviderApi( + ipc: Pick, + prefix: UsageProviderApiKey +): UsageProviderApi { + return { + getScanState: () => ipc.invoke(`${prefix}:getScanState`), + setEnabled: (args: { enabled: boolean }) => ipc.invoke(`${prefix}:setEnabled`, args), + refresh: (args?: { force?: boolean }) => ipc.invoke(`${prefix}:refresh`, args), + getSnapshot: (args: UsageRangeArgs & { limit?: number }) => + ipc.invoke(`${prefix}:getSnapshot`, args), + getSummary: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getSummary`, args), + getDaily: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getDaily`, args), + getBreakdown: (args: UsageRangeArgs & { kind: string }) => + ipc.invoke(`${prefix}:getBreakdown`, args), + getRecentSessions: (args: UsageRangeArgs & { limit?: number }) => + ipc.invoke(`${prefix}:getRecentSessions`, args) + } +} diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 3166c70a2f5..6a281a7ed83 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,8 +1,9 @@ /* eslint-disable max-lines -- Why: parsing, replay cache, endpoint writing, and retry state are one lifecycle unit; splitting obscures cleanup ordering across reconnects. */ // Relay-side adapter for the shared agent-hook listener: hosts a loopback HTTP server and // forwards each parsed payload via a callback so `relay.ts` re-emits it as an `agent.hook` -// JSON-RPC notification over the SSH channel. Replay cache is bounded one-entry-per-paneKey — -// see docs/design/agent-status-over-ssh.md §5 (Path 3, request-driven replay) for the rationale. +// JSON-RPC notification over the SSH channel. Replay cache is bounded one-entry-per-paneKey: a +// reattaching Orca only needs each pane's current status, never its history, and the bound keeps a +// long-lived relay from growing with every event. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { basename, dirname, join } from 'node:path' @@ -99,10 +100,10 @@ export class RelayAgentHookServer { private state: HookListenerState = createHookListenerState() // Why: retain envelope metadata so replays match live POSTs. // Invariant: keys mirror state.lastStatusByPaneKey, populated/cleared in lockstep. - private lastEnvelopeMetaByPaneKey: Map< + private lastEnvelopeMetaByPaneKey = new Map< string, { source: AgentHookSource; env?: string; version?: string } - > = new Map() + >() private assistantMessageRetryTimers = new Map>() private codexSubagentPollTimers = new Map>() private forward: RelayHookForward diff --git a/src/relay/ai-vault-handler.test.ts b/src/relay/ai-vault-handler.test.ts index efc9a207a73..eb90958d0a2 100644 --- a/src/relay/ai-vault-handler.test.ts +++ b/src/relay/ai-vault-handler.test.ts @@ -8,8 +8,13 @@ import { SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD } from '../shared/ssh-ai-vault-relay' import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' +import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' import type { RelayDispatcher, RequestContext } from './dispatcher' import { AiVaultHandler } from './ai-vault-handler' +import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem' +import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state' type RequestHandler = (params: Record, context: RequestContext) => Promise @@ -49,7 +54,7 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome, hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions + service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'), scanRemoteSessions) }) await expect( @@ -99,7 +104,8 @@ describe('AiVaultHandler', () => { const dispatcher = createMockDispatcher() new AiVaultHandler(dispatcher.value, { remoteHome, - hostPlatform: getRemoteHostPlatform('linux-x64') + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64')) }) const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { @@ -123,7 +129,11 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions + service: createTestService( + '/home/ada', + getRemoteHostPlatform('linux-x64'), + scanRemoteSessions + ) }) const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) @@ -156,7 +166,11 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions + service: createTestService( + '/home/ada', + getRemoteHostPlatform('linux-x64'), + scanRemoteSessions + ) }) await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { @@ -183,7 +197,11 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions: scanRemoteSessions as never + service: createTestService( + '/home/ada', + getRemoteHostPlatform('linux-x64'), + scanRemoteSessions as never + ) }) const firstController = new AbortController() const first = dispatcher.call( @@ -219,7 +237,11 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions: scanRemoteSessions as never + service: createTestService( + '/home/ada', + getRemoteHostPlatform('linux-x64'), + scanRemoteSessions as never + ) }) const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 }) await vi.waitFor(() => expect(signals).toHaveLength(1)) @@ -254,11 +276,31 @@ describe('AiVaultHandler', () => { } }) + it('soft-disables the method instead of aborting relay startup when the service is missing', () => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const dispatcher = createMockDispatcher() + + expect( + () => + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + ).not.toThrow() + + expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/) + } finally { + stderr.mockRestore() + } + }) + it('stops a relay-local scan when the owning request is cancelled', async () => { const dispatcher = createMockDispatcher() new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: createTestService('/home/ada', getRemoteHostPlatform('linux-x64')) }) const controller = new AbortController() controller.abort() @@ -267,6 +309,70 @@ describe('AiVaultHandler', () => { dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal) ).rejects.toMatchObject({ name: 'AbortError' }) }) + + it('returns a host issue when the sidecar is unavailable', async () => { + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: { + listSessions: () => Promise.reject(new Error('sidecar crashed')), + resolveSessionTitles: () => Promise.resolve({ titles: [] }) + } + }) + + await expect(dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).resolves.toMatchObject({ + sessions: [], + issues: [ + expect.objectContaining({ kind: 'host', message: expect.stringContaining('sidecar') }) + ] + }) + }) + + it('returns no titles instead of an RPC error when the sidecar is unavailable', async () => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: { + listSessions: () => Promise.resolve(emptyResult()), + resolveSessionTitles: () => Promise.reject(new Error('sidecar crashed')) + } + }) + + await expect( + dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { + requests: [ + { agent: 'codex', sessionId: 'ssh-session', transcriptPath: '/home/ada/s.jsonl' } + ] + }) + ).resolves.toEqual({ titles: [] }) + } finally { + stderr.mockRestore() + } + }) + + it('propagates title cancellation instead of degrading it', async () => { + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: { + listSessions: () => Promise.resolve(emptyResult()), + resolveSessionTitles: () => { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return Promise.reject(error) + } + } + }) + + await expect( + dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { requests: [] }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) }) async function makeTemporaryHome(): Promise { @@ -279,6 +385,28 @@ function emptyResult(): AiVaultListResult { return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' } } +function createTestService( + remoteHome: string, + hostPlatform: RemoteHostPlatform, + scan: typeof scanRemoteAiVaultSessions = scanRemoteAiVaultSessions +): RelayAiVaultServiceApi { + return { + listSessions: (params, signal) => + scan({ + provider: createRelayAiVaultFilesystemProvider(), + executionHostId: 'local', + remoteHome, + hostPlatform, + limit: params.limit, + unlimited: params.unlimited, + scopePaths: params.scopePaths, + signal + }), + resolveSessionTitles: (requests, signal) => + readAiVaultSessionTitlesFromFiles(requests, { signal }) + } +} + function createMockDispatcher(): { value: RelayDispatcher call: (method: string, params: Record, signal?: AbortSignal) => Promise diff --git a/src/relay/ai-vault-handler.ts b/src/relay/ai-vault-handler.ts index a5f26fff7eb..d3e83448ce8 100644 --- a/src/relay/ai-vault-handler.ts +++ b/src/relay/ai-vault-handler.ts @@ -1,4 +1,3 @@ -import { lstat, readdir } from 'node:fs/promises' import { homedir } from 'node:os' import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' @@ -14,34 +13,25 @@ import { SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH, type SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' -import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' -import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' -import { readRelayFileContent } from './fs-handler-file-read' import { relayLogLine } from './relay-diagnostic-log' import type { RelayDispatcher } from './dispatcher' import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator' -import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' - -type ScanRemoteSessions = typeof scanRemoteAiVaultSessions +import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state' type AiVaultHandlerOptions = { remoteHome?: string hostPlatform?: RemoteHostPlatform - scanRemoteSessions?: ScanRemoteSessions + service?: RelayAiVaultServiceApi } export class AiVaultHandler { private readonly remoteHome: string - private readonly scanRemoteSessions: ScanRemoteSessions - private readonly provider: RemoteSessionFilesystemProvider private readonly scanCoordinator = new AiVaultScanCoordinator() constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) { this.remoteHome = options.remoteHome ?? homedir() - this.scanRemoteSessions = options.scanRemoteSessions ?? scanRemoteAiVaultSessions - this.provider = createRelayAiVaultFilesystemProvider() const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform() // Why: an OS/arch this build has no path flavor for must not abort relay // startup — leaving the method unregistered soft-disables the feature and @@ -52,48 +42,78 @@ export class AiVaultHandler { ) return } + // Why: same reasoning as an unsupported platform. Throwing here would take + // relay startup — and every PTY on the host — down over a Vault wiring bug. + const service = options.service + if (!service) { + relayLogLine('[relay] Agent Session History disabled: service unavailable') + return + } dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) => - this.listSessions(hostPlatform, params, context.signal) + this.listSessions(service, params, context.signal) ) dispatcher.onRequest(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, (params, context) => - this.resolveSessionTitles(params, context.signal) + this.resolveSessionTitles(service, params, context.signal) ) } - private resolveSessionTitles( + private async resolveSessionTitles( + service: RelayAiVaultServiceApi, rawParams: Record, signal?: AbortSignal ): Promise { - return readAiVaultSessionTitlesFromFiles(normalizeTitleRequests(rawParams.requests), { signal }) + try { + return await service.resolveSessionTitles(normalizeTitleRequests(rawParams.requests), signal) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + // Why: titles are decoration. Degrade like an unresolvable title instead of + // failing the RPC, which would surface a raw error on every list row. + relayLogLine( + `[relay-ai-vault-service] title resolution unavailable: ${error instanceof Error ? error.message : String(error)}` + ) + return { titles: [] } + } } private async listSessions( - hostPlatform: RemoteHostPlatform, + service: RelayAiVaultServiceApi, rawParams: Record, signal?: AbortSignal ): Promise { const params = normalizeSshAiVaultRelayListParams(rawParams) - const result = await this.scanCoordinator.run({ - key: JSON.stringify({ - limit: params.limit, - unlimited: params.unlimited, - scopePaths: params.scopePaths, - scopePathsTruncated: params.scopePathsTruncated - }), - force: params.force, - signal, - start: (scanSignal) => - this.scanRemoteSessions({ - provider: this.provider, - executionHostId: LOCAL_EXECUTION_HOST_ID, - remoteHome: this.remoteHome, - hostPlatform, + let result: AiVaultListResult + try { + result = await this.scanCoordinator.run({ + key: JSON.stringify({ limit: params.limit, unlimited: params.unlimited, scopePaths: params.scopePaths, - signal: scanSignal - }) - }) + scopePathsTruncated: params.scopePathsTruncated + }), + force: params.force, + signal, + start: (scanSignal) => service.listSessions(params, scanSignal) + }) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + result = { + sessions: [], + scannedAt: new Date().toISOString(), + issues: [ + { + executionHostId: LOCAL_EXECUTION_HOST_ID, + agent: 'codex', + kind: 'host', + path: this.remoteHome, + message: `Agent Session History service unavailable: ${error instanceof Error ? error.message : String(error)}` + } + ] + } + } if (!params.scopePathsTruncated) { return result } @@ -176,29 +196,3 @@ function currentRelayHostPlatform(): RemoteHostPlatform | null { const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null } - -function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider { - return { - async readDir(dirPath) { - const entries = await readdir(dirPath, { withFileTypes: true }) - return entries.map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - })) - }, - readFile: readRelayFileContent, - async stat(filePath) { - const stats = await lstat(filePath) - return { - size: stats.size, - type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file', - mtime: stats.mtimeMs, - mtimeMs: stats.mtimeMs, - dev: stats.dev, - ino: stats.ino, - nlink: stats.nlink - } - } - } -} diff --git a/src/relay/ai-vault-service-client-state.ts b/src/relay/ai-vault-service-client-state.ts new file mode 100644 index 00000000000..de623d19b07 --- /dev/null +++ b/src/relay/ai-vault-service-client-state.ts @@ -0,0 +1,176 @@ +import type { ChildProcess } from 'node:child_process' +import type { AiVaultListResult } from '../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../shared/ai-vault-session-title' +import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' +import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { + relayAiVaultServiceLane, + type RelayAiVaultServiceLane, + type RelayAiVaultServiceRequest +} from './ai-vault-service-protocol' + +export const RELAY_AI_VAULT_READY_TIMEOUT_MS = 5_000 +export const RELAY_AI_VAULT_SCAN_TIMEOUT_MS = 130_000 +export const RELAY_AI_VAULT_TITLE_TIMEOUT_MS = 15_000 +export const RELAY_AI_VAULT_MAX_CALLS = 16 +export const RELAY_AI_VAULT_IDLE_TIMEOUT_MS = 10 * 60_000 + +export function relayAiVaultAbortError(): Error { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return error +} + +export function relayAiVaultError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +export function armRelayAiVaultCancellationTimeout( + call: RelayAiVaultServiceCall, + onExpired: () => void +): void { + call.timer = setTimeout(onExpired, 2_000) + call.timer.unref?.() +} + +export function createRelayAiVaultServiceCall(args: { + request: RelayAiVaultServiceRequest + signal?: AbortSignal + resolve: RelayAiVaultServiceCall['resolve'] + reject: RelayAiVaultServiceCall['reject'] +}): RelayAiVaultServiceCall { + return { + request: args.request, + lane: relayAiVaultServiceLane(args.request.operation), + signal: args.signal, + forceStart: args.request.operation === 'list' && args.request.params.force === true, + resolve: args.resolve, + reject: args.reject, + timer: null, + onAbort: null, + settled: false, + sent: false, + startRetried: false + } +} + +/** + * A cold start that faults before the request reached the sidecar self-heals on + * the scheduled respawn. Requeue once; the caller settles when this returns false. + */ +export function requeueRelayAiVaultServiceStart( + call: RelayAiVaultServiceCall, + queue: RelayAiVaultServiceCall[] +): boolean { + if (call.sent || call.settled || call.startRetried) { + return false + } + call.startRetried = true + queue.unshift(call) + return true +} + +export function settleRelayAiVaultServiceCall( + call: RelayAiVaultServiceCall, + value: Error | AiVaultListResult | AiVaultSessionTitlesResult +): void { + // A cancelled call is settled before its cancel watchdog is armed, so the + // timer has to be cleared even when the reject/resolve is already done. + if (call.timer) { + clearTimeout(call.timer) + call.timer = null + } + if (call.settled) { + return + } + call.settled = true + if (call.signal && call.onAbort) { + call.signal.removeEventListener('abort', call.onAbort) + } + if (value instanceof Error) { + call.reject(value) + } else { + call.resolve(value) + } +} + +export type RelayAiVaultServiceCall = { + request: RelayAiVaultServiceRequest + lane: RelayAiVaultServiceLane + signal?: AbortSignal + forceStart: boolean + resolve: (value: AiVaultListResult | AiVaultSessionTitlesResult) => void + reject: (error: Error) => void + timer: NodeJS.Timeout | null + onAbort: (() => void) | null + settled: boolean + /** Whether the sidecar received the request; an unsent call gets no reply. */ + sent: boolean + startRetried: boolean +} + +export type RelayAiVaultServiceApi = { + listSessions(params: SshAiVaultRelayListParams, signal?: AbortSignal): Promise + resolveSessionTitles( + requests: AiVaultSessionTitleRequest[], + signal?: AbortSignal + ): Promise +} + +export type RelayAiVaultServiceClientOptions = { + processFactory: () => ChildProcess + init: { + remoteHome: string + hostPlatform: RemoteHostPlatform + } + now?: () => number + idleTimeoutMs?: number +} + +export class RelayAiVaultIdleRetirement { + private timer: NodeJS.Timeout | null = null + + clear(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } + + schedule(busy: boolean, timeoutMs: number, retire: () => void): void { + if (busy || this.timer) { + return + } + this.timer = setTimeout(() => { + this.timer = null + retire() + }, timeoutMs) + this.timer.unref?.() + } +} + +export function retireRelayAiVaultServiceChild(child: ChildProcess): void { + const timer = setTimeout(() => child.kill(), 2_000) + timer.unref?.() + child.once('exit', () => clearTimeout(timer)) + child.send({ type: 'shutdown' }, () => undefined) +} + +/** Orderly shutdown that never holds relay teardown past the kill deadline. */ +export function shutdownRelayAiVaultServiceChild(child: ChildProcess): Promise { + child.send({ type: 'shutdown' }, () => undefined) + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill() + resolve() + }, 2_000) + timer.unref?.() + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + }) +} diff --git a/src/relay/ai-vault-service-client.test.ts b/src/relay/ai-vault-service-client.test.ts new file mode 100644 index 00000000000..10eaeb1a660 --- /dev/null +++ b/src/relay/ai-vault-service-client.test.ts @@ -0,0 +1,299 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { + AiVaultServiceTestChild, + readyAiVaultServiceChild +} from '../main/ai-vault/session-scanner-service-test-child' +import { RelayAiVaultServiceClient } from './ai-vault-service-client' +import { RELAY_AI_VAULT_READY_TIMEOUT_MS } from './ai-vault-service-client-state' +import { relayAiVaultServiceEntryPath } from './ai-vault-service-spawn' + +function createClient( + children: AiVaultServiceTestChild[], + idleTimeoutMs?: number +): RelayAiVaultServiceClient { + return new RelayAiVaultServiceClient({ + init: { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }, + processFactory: () => { + const child = new AiVaultServiceTestChild(20_000 + children.length) + children.push(child) + return child.asChildProcess() + }, + idleTimeoutMs + }) +} + +function relayRequests(child: AiVaultServiceTestChild, operation: string): { id: number }[] { + return child.sent.filter( + (message) => (message as { operation?: string }).operation === operation + ) as { id: number }[] +} + +function relayRequestCount(child: AiVaultServiceTestChild, operation: string): number { + return relayRequests(child, operation).length +} + +function relayRequestId(child: AiVaultServiceTestChild, operation: string): number { + const request = relayRequests(child, operation).at(-1) + if (!request) { + throw new Error(`No ${operation} request was sent.`) + } + return request.id +} + +afterEach(() => vi.useRealTimers()) + +describe('RelayAiVaultServiceClient', () => { + it('holds list and title calls behind the ready handshake', async () => { + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const list = client.listSessions({ limit: 20 }) + const titles = client.resolveSessionTitles([]) + const child = children[0]! + + expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })]) + readyAiVaultServiceChild(child) + await Promise.resolve() + const listRequest = child.sent.find( + (message) => (message as { operation?: string }).operation === 'list' + ) as { id: number } + child.emit('message', { + type: 'result', + id: listRequest.id, + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(list).resolves.toMatchObject({ sessions: [] }) + const titleRequest = child.sent.find( + (message) => (message as { operation?: string }).operation === 'titles' + ) as { id: number } + child.emit('message', { + type: 'result', + id: titleRequest.id, + operation: 'titles', + value: { titles: [] } + }) + await expect(titles).resolves.toEqual({ titles: [] }) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('resolves titles while a scan still occupies the cache lane', async () => { + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const list = client.listSessions({}) + const child = children[0]! + readyAiVaultServiceChild(child) + await Promise.resolve() + + const titles = client.resolveSessionTitles([ + { agent: 'claude', sessionId: 'session-1', transcriptPath: '/home/ada/session-1.jsonl' } + ]) + await Promise.resolve() + child.emit('message', { + type: 'result', + id: relayRequestId(child, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + + await expect(titles).resolves.toEqual({ titles: [] }) + child.emit('message', { + type: 'result', + id: relayRequestId(child, 'list'), + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(list).resolves.toMatchObject({ sessions: [] }) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('does not start queued cache work until cancelled work acknowledges', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const controller = new AbortController() + const first = client.listSessions({}, controller.signal) + const second = client.listSessions({ limit: 5 }) + const child = children[0]! + readyAiVaultServiceChild(child) + await Promise.resolve() + const firstRequest = relayRequestId(child, 'list') + + controller.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(relayRequestCount(child, 'list')).toBe(1) + child.emit('message', { + type: 'error', + id: firstRequest, + message: 'aborted' + }) + await Promise.resolve() + expect(relayRequestCount(child, 'list')).toBe(2) + void second.catch(() => undefined) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('drops a call cancelled before the sidecar received it', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const controller = new AbortController() + const cancelled = client.listSessions({}, controller.signal) + const child = children[0]! + + controller.abort() + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' })) + + readyAiVaultServiceChild(child) + const next = client.listSessions({ limit: 5 }) + await Promise.resolve() + child.emit('message', { + type: 'result', + id: relayRequestId(child, 'list'), + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(next).resolves.toMatchObject({ sessions: [] }) + vi.advanceTimersByTime(2_000) + + expect(child.killed).toBe(false) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('retries a cold-start crash once without faulting the replacement sidecar', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const list = client.listSessions({}) + + children[0]!.emit('exit', 1) + await Promise.resolve() + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + vi.advanceTimersByTime(RELAY_AI_VAULT_READY_TIMEOUT_MS) + + expect(children[1]!.killed).toBe(false) + children[1]!.emit('message', { + type: 'result', + id: relayRequestId(children[1]!, 'list'), + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(list).resolves.toMatchObject({ sessions: [] }) + const disposing = client.dispose() + children[1]!.emit('exit', 0) + await disposing + }) + + it('keeps the sidecar alive once a cancelled call acknowledges', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const controller = new AbortController() + const first = client.listSessions({}, controller.signal) + const second = client.resolveSessionTitles([]) + const child = children[0]! + readyAiVaultServiceChild(child) + await Promise.resolve() + const firstRequest = child.sent.find( + (message) => (message as { operation?: string }).operation === 'list' + ) as { id: number } + + controller.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + child.emit('message', { type: 'error', id: firstRequest.id, message: 'aborted' }) + await Promise.resolve() + vi.advanceTimersByTime(2_000) + + expect(child.killed).toBe(false) + const titleRequest = child.sent.find( + (message) => (message as { operation?: string }).operation === 'titles' + ) as { id: number } + child.emit('message', { + type: 'result', + id: titleRequest.id, + operation: 'titles', + value: { titles: [] } + }) + await expect(second).resolves.toEqual({ titles: [] }) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('restarts queued work after a sidecar crash with bounded backoff', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const first = client.listSessions({}) + const second = client.listSessions({ limit: 5 }) + readyAiVaultServiceChild(children[0]!) + await Promise.resolve() + + children[0]!.emit('exit', 1) + await expect(first).rejects.toThrow('exited') + expect(children).toHaveLength(1) + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + children[1]!.emit('message', { + type: 'result', + id: relayRequestId(children[1]!, 'list'), + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(second).resolves.toMatchObject({ sessions: [] }) + const disposing = client.dispose() + children[1]!.emit('exit', 0) + await disposing + }) + + it('retires the sidecar after the idle bound', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children, 100) + const list = client.listSessions({}) + const child = children[0]! + readyAiVaultServiceChild(child) + await Promise.resolve() + const request = child.sent.find( + (message) => (message as { operation?: string }).operation === 'list' + ) as { id: number } + child.emit('message', { + type: 'result', + id: request.id, + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await list + + vi.advanceTimersByTime(99) + expect(child.sent).not.toContainEqual({ type: 'shutdown' }) + vi.advanceTimersByTime(1) + expect(child.sent).toContainEqual({ type: 'shutdown' }) + vi.advanceTimersByTime(2_000) + expect(child.killed).toBe(true) + await client.dispose() + }) + + it('resolves the sidecar beside each bundled relay', () => { + expect(relayAiVaultServiceEntryPath('/opt/orca/relay')).toBe( + '/opt/orca/relay/relay-ai-vault-service.js' + ) + }) +}) diff --git a/src/relay/ai-vault-service-client.ts b/src/relay/ai-vault-service-client.ts new file mode 100644 index 00000000000..c873c380506 --- /dev/null +++ b/src/relay/ai-vault-service-client.ts @@ -0,0 +1,314 @@ +import type { ChildProcess } from 'node:child_process' +import type { AiVaultListResult } from '../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../shared/ai-vault-session-title' +import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' +import { + RELAY_AI_VAULT_MAX_CALLS, + RELAY_AI_VAULT_IDLE_TIMEOUT_MS, + RELAY_AI_VAULT_READY_TIMEOUT_MS, + RELAY_AI_VAULT_SCAN_TIMEOUT_MS, + RELAY_AI_VAULT_TITLE_TIMEOUT_MS, + armRelayAiVaultCancellationTimeout, + createRelayAiVaultServiceCall, + relayAiVaultAbortError, + relayAiVaultError, + RelayAiVaultIdleRetirement, + requeueRelayAiVaultServiceStart, + retireRelayAiVaultServiceChild, + settleRelayAiVaultServiceCall, + shutdownRelayAiVaultServiceChild, + type RelayAiVaultServiceApi, + type RelayAiVaultServiceCall, + type RelayAiVaultServiceClientOptions +} from './ai-vault-service-client-state' +import { + RELAY_AI_VAULT_SERVICE_PROTOCOL, + isRelayAiVaultServiceChildMessage, + type RelayAiVaultServiceChildMessage, + type RelayAiVaultServiceInit, + type RelayAiVaultServiceLane, + type RelayAiVaultServiceRequest +} from './ai-vault-service-protocol' +import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy' +import { relayLogLine } from './relay-diagnostic-log' + +export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { + private child: ChildProcess | null = null + private ready: Promise | null = null + private readyReject: ((error: Error) => void) | null = null + private readyTimer: NodeJS.Timeout | null = null + private readonly active = new Map() + private readonly queue: RelayAiVaultServiceCall[] = [] + private nextId = 1 + private readonly restartPolicy: RelayAiVaultRestartPolicy + private readonly idleRetirement = new RelayAiVaultIdleRetirement() + private disposed = false + + constructor(private readonly options: RelayAiVaultServiceClientOptions) { + this.restartPolicy = new RelayAiVaultRestartPolicy(options.now) + } + + listSessions( + params: SshAiVaultRelayListParams, + signal?: AbortSignal + ): Promise { + return this.request({ type: 'request', id: this.nextId++, operation: 'list', params }, signal) + } + + resolveSessionTitles( + requests: AiVaultSessionTitleRequest[], + signal?: AbortSignal + ): Promise { + return this.request( + { type: 'request', id: this.nextId++, operation: 'titles', requests }, + signal + ) + } + + async dispose(): Promise { + this.disposed = true + this.idleRetirement.clear() + this.clearReadyTimer() + this.restartPolicy.dispose() + const error = new Error('Relay AI Vault service was disposed.') + for (const call of [...this.active.values(), ...this.queue.splice(0)]) { + settleRelayAiVaultServiceCall(call, error) + } + this.active.clear() + const child = this.detachChild() + if (child) { + await shutdownRelayAiVaultServiceChild(child) + } + } + + private request( + request: RelayAiVaultServiceRequest, + signal?: AbortSignal + ): Promise { + if (this.disposed) { + return Promise.reject(new Error('Relay AI Vault service was disposed.')) + } + if (signal?.aborted) { + return Promise.reject(relayAiVaultAbortError()) + } + if (this.queue.length + this.active.size >= RELAY_AI_VAULT_MAX_CALLS) { + return Promise.reject(new Error('Relay AI Vault service queue is full.')) + } + return new Promise((resolve, reject) => { + const call = createRelayAiVaultServiceCall({ + request, + signal, + resolve: resolve as RelayAiVaultServiceCall['resolve'], + reject + }) + if (signal) { + call.onAbort = () => this.cancel(call) + signal.addEventListener('abort', call.onAbort, { once: true }) + } + this.queue.push(call) + this.idleRetirement.clear() + this.pump() + }) + } + + private pump(): void { + if (this.disposed || this.restartPolicy.restartScheduled) { + return + } + for (const lane of ['cache', 'interactive'] as const) { + if (this.active.has(lane)) { + continue + } + const index = this.queue.findIndex((call) => call.lane === lane) + if (index === -1) { + continue + } + const call = this.queue.splice(index, 1)[0]! + this.active.set(lane, call) + void this.ensureChild(call.forceStart).then( + (child) => this.sendCall(child, call), + (error: Error) => { + if (this.active.get(lane) !== call) { + return + } + this.active.delete(lane) + this.retryStartOrSettle(call, error) + this.pump() + } + ) + } + this.scheduleIdleIfNeeded() + } + + private sendCall(child: ChildProcess, call: RelayAiVaultServiceCall): void { + if (this.active.get(call.lane) !== call || call.settled) { + return + } + const timeout = + call.request.operation === 'list' + ? RELAY_AI_VAULT_SCAN_TIMEOUT_MS + : RELAY_AI_VAULT_TITLE_TIMEOUT_MS + call.timer = setTimeout( + () => this.onFault(new Error(`Relay AI Vault service timed out after ${timeout}ms.`)), + timeout + ) + call.timer.unref?.() + call.sent = true + child.send(call.request) + } + + private retryStartOrSettle(call: RelayAiVaultServiceCall, error: Error): void { + if (this.disposed || !requeueRelayAiVaultServiceStart(call, this.queue)) { + settleRelayAiVaultServiceCall(call, error) + } + } + + private ensureChild(forceStart: boolean): Promise { + if (this.child && !this.ready) { + return Promise.resolve(this.child) + } + if (this.ready) { + return this.ready + } + const startError = this.restartPolicy.startError(forceStart) + if (startError) { + return Promise.reject(startError) + } + let child: ChildProcess + try { + child = this.options.processFactory() + } catch (error) { + return Promise.reject(relayAiVaultError(error)) + } + this.child = child + this.ready = new Promise((resolve, reject) => { + this.readyReject = reject + // Why: held on the instance so a crash before ready cannot leave the deadline + // armed, where it would later fault the healthy replacement sidecar. + this.readyTimer = setTimeout( + () => this.onFault(new Error('Relay AI Vault service did not become ready.')), + RELAY_AI_VAULT_READY_TIMEOUT_MS + ) + this.readyTimer.unref?.() + child.on('message', (message) => { + if (isRelayAiVaultServiceChildMessage(message) && message.type === 'ready') { + this.clearReadyTimer() + this.ready = null + this.readyReject = null + resolve(child) + return + } + this.onMessage(message) + }) + }) + child.on('error', (error) => this.onFault(error)) + child.on('disconnect', () => this.onFault(new Error('Relay AI Vault service disconnected.'))) + child.on('exit', (code) => this.onFault(new Error(`Relay AI Vault service exited (${code}).`))) + child.stderr?.on('data', (chunk: Buffer) => + relayLogLine(`[relay-ai-vault-service] ${String(chunk).trimEnd()}`) + ) + child.send({ + type: 'init', + protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, + ...this.options.init + } satisfies RelayAiVaultServiceInit) + return this.ready + } + + private onMessage(raw: unknown): void { + if (!isRelayAiVaultServiceChildMessage(raw)) { + this.onFault(new Error('Relay AI Vault service sent a malformed message.')) + return + } + const message = raw as RelayAiVaultServiceChildMessage + if (message.type === 'ready') { + return + } + const call = [...this.active.values()].find((entry) => entry.request.id === message.id) + if (!call) { + return + } + this.active.delete(call.lane) + settleRelayAiVaultServiceCall( + call, + message.type === 'error' ? new Error(message.message) : message.value + ) + this.pump() + } + + private cancel(call: RelayAiVaultServiceCall): void { + const index = this.queue.indexOf(call) + if (index !== -1) { + this.queue.splice(index, 1) + settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) + this.pump() + return + } + if (this.active.get(call.lane) === call) { + // Why: a call cancelled before it reached the sidecar gets no acknowledgement, + // so waiting on one would kill a healthy sidecar and stall the lane. + if (!call.sent) { + this.active.delete(call.lane) + settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) + this.pump() + return + } + this.child?.send({ type: 'cancel', id: call.request.id }) + settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) + armRelayAiVaultCancellationTimeout(call, () => + this.onFault(new Error('Relay AI Vault service did not cancel within 2000ms.')) + ) + } + } + + private onFault(error: Error): void { + if (!this.child) { + return + } + this.idleRetirement.clear() + this.detachChild()?.kill() + this.clearReadyTimer() + this.readyReject?.(error) + this.readyReject = null + this.ready = null + const active = [...this.active.values()] + this.active.clear() + for (const call of active) { + this.retryStartOrSettle(call, error) + } + this.restartPolicy.recordFault() + if (this.queue.length > 0 && !this.disposed) { + this.restartPolicy.scheduleRestart(() => this.pump()) + } + } + + private clearReadyTimer(): void { + if (this.readyTimer) { + clearTimeout(this.readyTimer) + this.readyTimer = null + } + } + + private detachChild(): ChildProcess | null { + const child = this.child + this.child = null + child?.removeAllListeners() + return child + } + + private scheduleIdleIfNeeded(): void { + this.idleRetirement.schedule( + this.active.size > 0 || this.queue.length > 0 || !this.child, + this.options.idleTimeoutMs ?? RELAY_AI_VAULT_IDLE_TIMEOUT_MS, + () => { + const child = this.detachChild() + if (child) { + retireRelayAiVaultServiceChild(child) + } + } + ) + } +} diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts new file mode 100644 index 00000000000..9bd3ea35786 --- /dev/null +++ b/src/relay/ai-vault-service-entry.ts @@ -0,0 +1,122 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' +import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' +import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem' +import { + RELAY_AI_VAULT_SERVICE_PROTOCOL, + isRelayAiVaultServiceRequest, + relayAiVaultServiceLane, + type RelayAiVaultServiceChildMessage, + type RelayAiVaultServiceInit, + type RelayAiVaultServiceParentMessage, + type RelayAiVaultServiceRequest +} from './ai-vault-service-protocol' + +if (!process.send) { + throw new Error('Relay AI Vault service requires a parent IPC channel.') +} + +const controllers = new Map() +const cancelled = new Set() +const pending = new Set() +const provider = createRelayAiVaultFilesystemProvider() +let init: RelayAiVaultServiceInit | null = null +let cacheLane = Promise.resolve() +let interactiveLane = Promise.resolve() +let shuttingDown = false + +function send(message: RelayAiVaultServiceChildMessage): void { + process.send?.(message) +} + +async function execute(request: RelayAiVaultServiceRequest): Promise { + const controller = new AbortController() + controllers.set(request.id, controller) + if (cancelled.delete(request.id)) { + controller.abort() + } + try { + if (!init) { + throw new Error('Relay AI Vault service is not initialized.') + } + if (request.operation === 'titles') { + const value = await readAiVaultSessionTitlesFromFiles(request.requests, { + signal: controller.signal + }) + send({ type: 'result', id: request.id, operation: 'titles', value }) + return + } + const value = await scanRemoteAiVaultSessions({ + provider, + executionHostId: LOCAL_EXECUTION_HOST_ID, + remoteHome: init.remoteHome, + hostPlatform: init.hostPlatform, + limit: request.params.limit, + unlimited: request.params.unlimited, + scopePaths: request.params.scopePaths, + signal: controller.signal + }) + send({ type: 'result', id: request.id, operation: 'list', value }) + } catch (error) { + send({ + type: 'error', + id: request.id, + message: error instanceof Error ? error.message : String(error) + }) + } finally { + controllers.delete(request.id) + cancelled.delete(request.id) + pending.delete(request.id) + } +} + +async function shutdown(): Promise { + if (shuttingDown) { + return + } + shuttingDown = true + for (const controller of controllers.values()) { + controller.abort() + } + await Promise.allSettled([cacheLane, interactiveLane]) + process.disconnect?.() +} + +process.on('message', (raw: RelayAiVaultServiceParentMessage) => { + if (raw?.type === 'init') { + if (init || raw.protocol !== RELAY_AI_VAULT_SERVICE_PROTOCOL) { + void shutdown() + return + } + init = raw + send({ type: 'ready', protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, pid: process.pid }) + return + } + if (!init || shuttingDown) { + return + } + if (raw?.type === 'cancel') { + cancelled.add(raw.id) + controllers.get(raw.id)?.abort() + return + } + if (raw?.type === 'shutdown') { + void shutdown() + return + } + if (!isRelayAiVaultServiceRequest(raw)) { + return + } + if (pending.size >= 16) { + send({ type: 'error', id: raw.id, message: 'Relay AI Vault service queue is full.' }) + return + } + pending.add(raw.id) + if (relayAiVaultServiceLane(raw.operation) === 'interactive') { + interactiveLane = interactiveLane.then(() => execute(raw)) + return + } + cacheLane = cacheLane.then(() => execute(raw)) +}) + +process.on('disconnect', () => void shutdown()) diff --git a/src/relay/ai-vault-service-factory.ts b/src/relay/ai-vault-service-factory.ts new file mode 100644 index 00000000000..c6a20640662 --- /dev/null +++ b/src/relay/ai-vault-service-factory.ts @@ -0,0 +1,13 @@ +import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { RelayAiVaultServiceClient } from './ai-vault-service-client' +import { spawnRelayAiVaultService } from './ai-vault-service-spawn' + +export function createRelayAiVaultService( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RelayAiVaultServiceClient { + return new RelayAiVaultServiceClient({ + init: { remoteHome, hostPlatform }, + processFactory: spawnRelayAiVaultService + }) +} diff --git a/src/relay/ai-vault-service-filesystem.ts b/src/relay/ai-vault-service-filesystem.ts new file mode 100644 index 00000000000..9315b2e4b2f --- /dev/null +++ b/src/relay/ai-vault-service-filesystem.ts @@ -0,0 +1,29 @@ +import { lstat, readdir } from 'node:fs/promises' +import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' +import { readRelayFileContent } from './fs-handler-file-read' + +export function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider { + return { + async readDir(dirPath) { + const entries = await readdir(dirPath, { withFileTypes: true }) + return entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) + }, + readFile: readRelayFileContent, + async stat(filePath) { + const stats = await lstat(filePath) + return { + size: stats.size, + type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file', + mtime: stats.mtimeMs, + mtimeMs: stats.mtimeMs, + dev: stats.dev, + ino: stats.ino, + nlink: stats.nlink + } + } + } +} diff --git a/src/relay/ai-vault-service-protocol.ts b/src/relay/ai-vault-service-protocol.ts new file mode 100644 index 00000000000..dbbef94f2f9 --- /dev/null +++ b/src/relay/ai-vault-service-protocol.ts @@ -0,0 +1,86 @@ +import type { AiVaultListResult } from '../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../shared/ai-vault-session-title' +import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' +import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' + +export const RELAY_AI_VAULT_SERVICE_PROTOCOL = 1 + +export type RelayAiVaultServiceInit = { + type: 'init' + protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL + remoteHome: string + hostPlatform: RemoteHostPlatform +} + +export type RelayAiVaultServiceRequest = + | { + type: 'request' + id: number + operation: 'list' + params: SshAiVaultRelayListParams + } + | { + type: 'request' + id: number + operation: 'titles' + requests: AiVaultSessionTitleRequest[] + } + +export type RelayAiVaultServiceLane = 'cache' | 'interactive' +export type RelayAiVaultServiceOperation = RelayAiVaultServiceRequest['operation'] + +/** Title reads must not queue behind a full scan; they back interactive UI. */ +export function relayAiVaultServiceLane( + operation: RelayAiVaultServiceOperation +): RelayAiVaultServiceLane { + return operation === 'titles' ? 'interactive' : 'cache' +} + +export type RelayAiVaultServiceParentMessage = + | RelayAiVaultServiceInit + | RelayAiVaultServiceRequest + | { type: 'cancel'; id: number } + | { type: 'shutdown' } + +export type RelayAiVaultServiceChildMessage = + | { + type: 'ready' + protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL + pid: number + } + | { type: 'result'; id: number; operation: 'list'; value: AiVaultListResult } + | { + type: 'result' + id: number + operation: 'titles' + value: AiVaultSessionTitlesResult + } + | { type: 'error'; id: number; message: string } + +export function isRelayAiVaultServiceRequest(value: unknown): value is RelayAiVaultServiceRequest { + if (!value || typeof value !== 'object') { + return false + } + const message = value as Record + return ( + message.type === 'request' && + Number.isSafeInteger(message.id) && + (message.operation === 'list' || message.operation === 'titles') + ) +} + +export function isRelayAiVaultServiceChildMessage( + value: unknown +): value is RelayAiVaultServiceChildMessage { + if (!value || typeof value !== 'object') { + return false + } + const message = value as Record + if (message.type === 'ready') { + return message.protocol === RELAY_AI_VAULT_SERVICE_PROTOCOL && Number.isSafeInteger(message.pid) + } + return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id) +} diff --git a/src/relay/ai-vault-service-restart-policy.test.ts b/src/relay/ai-vault-service-restart-policy.test.ts new file mode 100644 index 00000000000..8b9608be216 --- /dev/null +++ b/src/relay/ai-vault-service-restart-policy.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy' + +afterEach(() => vi.useRealTimers()) + +describe('RelayAiVaultRestartPolicy', () => { + it('opens the circuit after three faults inside the window and closes it after', () => { + let now = 0 + const policy = new RelayAiVaultRestartPolicy(() => now) + + expect(policy.startError(false)).toBeNull() + for (let index = 0; index < 3; index += 1) { + policy.recordFault() + now += 1_000 + } + + expect(policy.startError(false)?.message).toContain('circuit is open') + now += 60_000 + expect(policy.startError(false)).toBeNull() + }) + + it('keeps the circuit closed when the faults age out of the window', () => { + let now = 0 + const policy = new RelayAiVaultRestartPolicy(() => now) + + for (let index = 0; index < 3; index += 1) { + policy.recordFault() + now += 30_000 + } + + expect(policy.startError(false)).toBeNull() + }) + + it('lets a forced refresh reopen an open circuit', () => { + let now = 0 + const policy = new RelayAiVaultRestartPolicy(() => now) + + for (let index = 0; index < 3; index += 1) { + policy.recordFault() + } + expect(policy.startError(false)?.message).toContain('circuit is open') + + expect(policy.startError(true)).toBeNull() + // The forced start clears the circuit outright, so the next background + // start is no longer refused either. + expect(policy.startError(false)).toBeNull() + }) + + it('backs off further with each fault and keeps one pending restart', () => { + vi.useFakeTimers() + const policy = new RelayAiVaultRestartPolicy() + const restart = vi.fn() + + policy.recordFault() + policy.scheduleRestart(restart) + vi.advanceTimersByTime(250) + expect(restart).toHaveBeenCalledTimes(1) + + policy.recordFault() + policy.scheduleRestart(restart) + policy.recordFault() + policy.scheduleRestart(restart) + vi.advanceTimersByTime(250) + expect(restart).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(5_000) + expect(restart).toHaveBeenCalledTimes(2) + }) + + it('cancels the pending restart on dispose', () => { + vi.useFakeTimers() + const policy = new RelayAiVaultRestartPolicy() + const restart = vi.fn() + + policy.recordFault() + policy.scheduleRestart(restart) + policy.dispose() + vi.advanceTimersByTime(10_000) + + expect(restart).not.toHaveBeenCalled() + expect(policy.restartScheduled).toBe(false) + }) +}) diff --git a/src/relay/ai-vault-service-restart-policy.ts b/src/relay/ai-vault-service-restart-policy.ts new file mode 100644 index 00000000000..e764ea66f6e --- /dev/null +++ b/src/relay/ai-vault-service-restart-policy.ts @@ -0,0 +1,55 @@ +const RELAY_AI_VAULT_FAULT_WINDOW_MS = 60_000 +const RELAY_AI_VAULT_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const + +export class RelayAiVaultRestartPolicy { + private faults: number[] = [] + private circuitUntil = 0 + private timer: NodeJS.Timeout | null = null + + constructor(private readonly now: () => number = Date.now) {} + + get restartScheduled(): boolean { + return this.timer !== null + } + + /** A forced refresh is a deliberate user action, so it reopens the circuit. */ + startError(forceStart: boolean): Error | null { + if (forceStart) { + this.circuitUntil = 0 + return null + } + return this.now() < this.circuitUntil + ? new Error('Relay AI Vault service restart circuit is open.') + : null + } + + recordFault(): void { + const now = this.now() + this.faults = [ + ...this.faults.filter((time) => now - time < RELAY_AI_VAULT_FAULT_WINDOW_MS), + now + ] + if (this.faults.length >= 3) { + this.circuitUntil = now + RELAY_AI_VAULT_FAULT_WINDOW_MS + } + } + + scheduleRestart(restart: () => void): void { + if (this.timer) { + clearTimeout(this.timer) + } + const delay = RELAY_AI_VAULT_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)] + this.timer = setTimeout(() => { + this.timer = null + restart() + }, delay) + this.timer.unref?.() + } + + dispose(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } +} diff --git a/src/relay/ai-vault-service-spawn.test.ts b/src/relay/ai-vault-service-spawn.test.ts new file mode 100644 index 00000000000..596c25afc2e --- /dev/null +++ b/src/relay/ai-vault-service-spawn.test.ts @@ -0,0 +1,42 @@ +import type { ChildProcess } from 'node:child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const forkMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ fork: forkMock })) + +const { spawnRelayAiVaultService } = await import('./ai-vault-service-spawn') + +function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } { + return forkMock.mock.calls.at(-1)?.[2] ?? {} +} + +describe('spawnRelayAiVaultService', () => { + beforeEach(() => { + forkMock.mockReset() + forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess) + }) + + it('keeps NODE_OPTIONS out of the sidecar so the heap cap and loader stand', () => { + vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js') + spawnRelayAiVaultService() + const options = forkOptions() + + // Asserted first: omitting `env` entirely inherits everything, and would + // leave the NODE_OPTIONS assertion below passing for the wrong reason. + expect(options.env).toBeDefined() + expect(options.env?.NODE_OPTIONS).toBeUndefined() + expect(options.execArgv).toEqual(['--max-old-space-size=384']) + vi.unstubAllEnvs() + }) + + it('does not hand the sidecar the rest of the remote login environment', () => { + vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'shhh') + spawnRelayAiVaultService() + const options = forkOptions() + + expect(options.env).toBeDefined() + expect(options.env?.AWS_SECRET_ACCESS_KEY).toBeUndefined() + vi.unstubAllEnvs() + }) +}) diff --git a/src/relay/ai-vault-service-spawn.ts b/src/relay/ai-vault-service-spawn.ts new file mode 100644 index 00000000000..90c5dbae67a --- /dev/null +++ b/src/relay/ai-vault-service-spawn.ts @@ -0,0 +1,20 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { join } from 'node:path' +import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env' +import { lowerAiVaultServicePriority } from '../main/ai-vault/session-scanner-service-priority' + +export function relayAiVaultServiceEntryPath(baseDir = __dirname): string { + return join(baseDir, 'relay-ai-vault-service.js') +} + +export function spawnRelayAiVaultService(): ChildProcess { + const child = fork(relayAiVaultServiceEntryPath(), [], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + execArgv: ['--max-old-space-size=384'], + env: buildRelayAiVaultServiceEnv(), + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + lowerAiVaultServicePriority(child.pid) + child.unref() + return child +} diff --git a/src/relay/dispatcher-capacity-degradation.test.ts b/src/relay/dispatcher-capacity-degradation.test.ts index a546989d5df..7bcf81674dc 100644 --- a/src/relay/dispatcher-capacity-degradation.test.ts +++ b/src/relay/dispatcher-capacity-degradation.test.ts @@ -9,16 +9,15 @@ import { LEGACY_CLIENT_RETAINED_BYTES_LOW } from './legacy-relay-publication-led import type * as ProtocolModule from './protocol' import { encodeJsonRpcFrame, RelayErrorCode } from './protocol' -// Counts every frame encode (including the estimate-only ones) so a redundant re-encode is observable. -const encodeCalls = vi.hoisted(() => ({ count: 0 })) +const preparationCalls = vi.hoisted(() => ({ count: 0 })) vi.mock('./protocol', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - encodeJsonRpcFrame: (...args: Parameters) => { - encodeCalls.count++ - return actual.encodeJsonRpcFrame(...args) + prepareJsonRpcPayload: (...args: Parameters) => { + preparationCalls.count++ + return actual.prepareJsonRpcPayload(...args) } } }) @@ -344,7 +343,7 @@ describe('RelayDispatcher bounded-capacity degradation', () => { } }) - it('does not re-encode a dropped frame when its log line is suppressed', () => { + it('prepares a dropped frame once when its log line is suppressed', () => { const primary = makeBoundedClient(16384) const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) const bounded = new RelayDispatcher(primary.write, primary.options) @@ -353,11 +352,10 @@ describe('RelayDispatcher bounded-capacity degradation', () => { bounded.notify('fs.changed', flood) expect(stderr).toHaveBeenCalledTimes(1) - // The suppressed drop must size the frame once, not once to publish and again to log. - encodeCalls.count = 0 + preparationCalls.count = 0 bounded.notify('fs.changed', flood) expect(stderr).toHaveBeenCalledTimes(1) - expect(encodeCalls.count).toBe(1) + expect(preparationCalls.count).toBe(1) } finally { stderr.mockRestore() bounded.dispose() diff --git a/src/relay/dispatcher-client-writer.test.ts b/src/relay/dispatcher-client-writer.test.ts index 13e49b59e8e..83b5e7960b7 100644 --- a/src/relay/dispatcher-client-writer.test.ts +++ b/src/relay/dispatcher-client-writer.test.ts @@ -51,7 +51,7 @@ function createWriter( sink.drainWaiters.push(callback) return () => { const index = sink.drainWaiters.indexOf(callback) - if (index >= 0) { + if (index !== -1) { sink.drainWaiters.splice(index, 1) } } diff --git a/src/relay/dispatcher-frame-guard-regressions.test.ts b/src/relay/dispatcher-frame-guard-regressions.test.ts index 71859e05c2f..eb39e90bbdb 100644 --- a/src/relay/dispatcher-frame-guard-regressions.test.ts +++ b/src/relay/dispatcher-frame-guard-regressions.test.ts @@ -44,4 +44,42 @@ describe('RelayDispatcher frame guards', () => { expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) expect(spy).not.toHaveBeenCalled() }) + + it('does not serialize notifications without an active client', () => { + const dispatcher = new RelayDispatcher(() => true) + try { + dispatcher.invalidateClient() + + expect(() => + dispatcher.notify('test.event', { + data: { + toJSON: () => { + throw new Error('must not serialize') + } + } + }) + ).not.toThrow() + } finally { + dispatcher.dispose() + } + }) + + it('does not serialize pty.data rejected by every active client', () => { + const dispatcher = new RelayDispatcher(() => true) + const unregister = dispatcher.registerPtyDataPublicationAdmission(() => false) + try { + expect(() => + dispatcher.notify('pty.data', { + data: { + toJSON: () => { + throw new Error('must not serialize') + } + } + }) + ).not.toThrow() + } finally { + unregister() + dispatcher.dispose() + } + }) }) diff --git a/src/relay/dispatcher-json-payload.test.ts b/src/relay/dispatcher-json-payload.test.ts new file mode 100644 index 00000000000..4965fe6898a --- /dev/null +++ b/src/relay/dispatcher-json-payload.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as ProtocolModule from './protocol' +import { + RelayDispatcher, + type RelayClientSinkOptions, + type SinkWriteSettlement +} from './dispatcher' +import { encodeKeepAliveFrame, type JsonRpcNotification } from './protocol' + +const protocolCalls = vi.hoisted(() => ({ preparations: 0, encodes: 0 })) + +vi.mock('./protocol', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + prepareJsonRpcPayload: (...args: Parameters) => { + protocolCalls.preparations++ + return actual.prepareJsonRpcPayload(...args) + }, + encodePreparedJsonRpcFrame: (...args: Parameters) => { + protocolCalls.encodes++ + return actual.encodePreparedJsonRpcFrame(...args) + } + } +}) + +type DecodedFrame = { + id: number + ack: number + message: JsonRpcNotification + payload: Buffer +} + +function decodeFrame(frame: Buffer): DecodedFrame { + const length = frame.readUInt32BE(9) + const payload = frame.subarray(13, 13 + length) + return { + id: frame.readUInt32BE(1), + ack: frame.readUInt32BE(5), + message: JSON.parse(payload.toString('utf8')), + payload + } +} + +class DrainSink { + readonly frames: Buffer[] = [] + private readonly drainWaiters = new Set<() => void>() + private writableBytes = 0 + private blocked = true + + constructor( + highWaterMark = 4 * 1024 * 1024, + private readonly mutateFrames = false + ) { + this.options = { + writableLength: () => this.writableBytes, + writableHighWaterMark: () => highWaterMark, + waitWriteDrain: (callback) => { + this.drainWaiters.add(callback) + return () => this.drainWaiters.delete(callback) + } + } + } + + readonly options: RelayClientSinkOptions + + write = (data: Buffer): boolean => { + this.frames.push(Buffer.from(data)) + if (this.mutateFrames) { + data.fill(0x78, 13) + } + if (!this.blocked) { + return true + } + this.writableBytes = data.length + return false + } + + drain(): void { + this.blocked = false + this.writableBytes = 0 + for (const callback of Array.from(this.drainWaiters)) { + callback() + } + } +} + +describe('RelayDispatcher prepared JSON payloads', () => { + afterEach(() => { + protocolCalls.preparations = 0 + protocolCalls.encodes = 0 + }) + + it('shares one payload while allocating independent drain-time sequence and ACK headers', () => { + const primary = new DrainSink(undefined, true) + const secondary = new DrainSink() + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const secondaryId = dispatcher.attachClient(secondary.write, secondary.options) + try { + dispatcher.notify('test.blocker') + dispatcher.notifyClient(1, 'test.primary-only') + protocolCalls.preparations = 0 + protocolCalls.encodes = 0 + + dispatcher.notify('workspace.changed', { revision: 7 }) + expect(protocolCalls).toEqual({ preparations: 1, encodes: 0 }) + dispatcher.feed(encodeKeepAliveFrame(41, 0)) + dispatcher.feedClient(secondaryId, encodeKeepAliveFrame(73, 0)) + + primary.drain() + secondary.drain() + + const primaryShared = primary.frames + .map(decodeFrame) + .find((frame) => frame.message.method === 'workspace.changed') + const secondaryShared = secondary.frames + .map(decodeFrame) + .find((frame) => frame.message.method === 'workspace.changed') + expect(primaryShared).toMatchObject({ id: 3, ack: 41 }) + expect(secondaryShared).toMatchObject({ id: 2, ack: 73 }) + expect(primaryShared?.payload.equals(secondaryShared!.payload)).toBe(true) + expect(protocolCalls).toEqual({ preparations: 1, encodes: 3 }) + } finally { + dispatcher.dispose() + } + }) + + it('prepares a rejected producer frame once without allocating a header', () => { + const frames: Buffer[] = [] + const dispatcher = new RelayDispatcher( + (frame) => { + frames.push(Buffer.from(frame)) + return true + }, + { writableLength: () => 0, writableHighWaterMark: () => 1024 } + ) + try { + expect( + dispatcher.publishProducerNotification( + 1, + 'fs.changed', + { events: [{ path: '/repo/a' }] }, + { + logDrop: false + } + ) + ).toBe(false) + expect(frames).toEqual([]) + expect(protocolCalls).toEqual({ preparations: 1, encodes: 0 }) + } finally { + dispatcher.dispose() + } + }) + + it('snapshots wire data and retains only PTY admission identity while queued', () => { + const sink = new DrainSink() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const params: Record = { + id: 'pty-1', + data: 'before'.repeat(4096), + deliveryToken: 'token-before', + clientGeneration: 3, + ownerGeneration: 5, + ptyIncarnation: 'incarnation-before' + } + const internals = dispatcher as unknown as { + prepareFrame: (message: JsonRpcNotification) => { + ptyDataAdmissionParams: Readonly> | null + } + } + const prepared = internals.prepareFrame({ jsonrpc: '2.0', method: 'pty.data', params }) + expect(prepared.ptyDataAdmissionParams).toEqual({ + id: 'pty-1', + deliveryToken: 'token-before', + clientGeneration: 3, + ownerGeneration: 5, + ptyIncarnation: 'incarnation-before' + }) + expect(prepared.ptyDataAdmissionParams).not.toHaveProperty('data') + + const admissions: Readonly>[] = [] + dispatcher.registerPtyDataPublicationAdmission((_clientId, admissionParams) => { + admissions.push(admissionParams) + return admissionParams.deliveryToken === 'token-before' + }) + dispatcher.notify('test.blocker') + const settled = vi.fn<(result: SinkWriteSettlement) => void>() + expect(dispatcher.tryNotifyPtyDataToClient(1, params, settled)).toBe(true) + admissions.length = 0 + params.data = 'after' + params.deliveryToken = 'token-after' + params.clientGeneration = 99 + params.ownerGeneration = 100 + params.ptyIncarnation = 'incarnation-after' + + sink.drain() + + const publication = sink.frames + .map(decodeFrame) + .find((frame) => frame.message.method === 'pty.data') + expect(publication?.message.params).toMatchObject({ + data: 'before'.repeat(4096), + deliveryToken: 'token-before', + clientGeneration: 3, + ownerGeneration: 5, + ptyIncarnation: 'incarnation-before' + }) + expect(admissions).toHaveLength(1) + expect(admissions[0]).not.toHaveProperty('data') + expect(admissions[0]).toMatchObject({ deliveryToken: 'token-before', clientGeneration: 3 }) + expect(settled).toHaveBeenCalledExactlyOnceWith({ ok: true }) + dispatcher.dispose() + }) + + it('retires queued PTY data without consuming sequence or losing settlement', () => { + const sink = new DrainSink() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + let admitted = true + dispatcher.registerPtyDataPublicationAdmission(() => admitted) + try { + dispatcher.notify('test.blocker') + const retired = vi.fn<(result: SinkWriteSettlement) => void>() + expect(dispatcher.tryNotifyPtyDataToClient(1, { id: 'pty-1', data: 'retire' }, retired)).toBe( + true + ) + admitted = false + sink.drain() + expect(retired).toHaveBeenCalledExactlyOnceWith({ + ok: false, + error: expect.objectContaining({ message: 'PTY publication retired' }) + }) + admitted = true + expect(dispatcher.tryNotifyPtyDataToClient(1, { id: 'pty-1', data: 'next' }, vi.fn())).toBe( + true + ) + const published = sink.frames + .map(decodeFrame) + .find((frame) => frame.message.method === 'pty.data') + expect(published).toMatchObject({ id: 2 }) + expect(published?.message.params?.data).toBe('next') + } finally { + dispatcher.dispose() + } + }) + + it('reuses one snapshot when a 256 KiB file frame retries after producer drain', async () => { + const sink = new DrainSink() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + try { + dispatcher.notify('test.blocker', { data: 'x'.repeat(16 * 1024) }) + protocolCalls.preparations = 0 + protocolCalls.encodes = 0 + const originalData = Buffer.alloc(256 * 1024, 0x61).toString('base64') + const params = { streamId: 7, seq: 2, data: originalData } + const pending = dispatcher.notifyBulk('fs.streamChunk', params) + params.data = 'mutated' + await Promise.resolve() + + expect(protocolCalls).toEqual({ preparations: 1, encodes: 0 }) + sink.drain() + await pending + + const chunk = sink.frames + .map(decodeFrame) + .find((frame) => frame.message.method === 'fs.streamChunk') + expect(chunk?.message.params).toEqual({ streamId: 7, seq: 2, data: originalData }) + expect(protocolCalls).toEqual({ preparations: 1, encodes: 1 }) + } finally { + dispatcher.dispose() + } + }) + + it('prepares queued 256 KiB bulk payloads only when their chain step becomes active', async () => { + const sink = new DrainSink() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + try { + dispatcher.notify('test.blocker', { data: 'x'.repeat(16 * 1024) }) + protocolCalls.preparations = 0 + protocolCalls.encodes = 0 + const originalData = Buffer.alloc(256 * 1024, 0x61).toString('base64') + const pending = Array.from({ length: 16 }, (_, index) => { + const params = { streamId: index + 1, seq: 1, data: originalData } + const publication = dispatcher.notifyBulk('fs.streamChunk', params) + params.data = 'mutated' + return publication + }) + + expect(protocolCalls).toEqual({ preparations: 0, encodes: 0 }) + await Promise.resolve() + expect(protocolCalls).toEqual({ preparations: 1, encodes: 0 }) + + sink.drain() + await Promise.all(pending) + + const chunks = sink.frames + .map(decodeFrame) + .filter((frame) => frame.message.method === 'fs.streamChunk') + expect(chunks).toHaveLength(16) + expect(chunks.every((frame) => frame.message.params?.data === originalData)).toBe(true) + expect(protocolCalls).toEqual({ preparations: 16, encodes: 16 }) + } finally { + dispatcher.dispose() + } + }) + + it('lazily shares one bulk payload across broadcast clients', async () => { + const primary = new DrainSink() + const secondary = new DrainSink() + primary.drain() + secondary.drain() + const dispatcher = new RelayDispatcher(primary.write, primary.options) + dispatcher.attachClient(secondary.write, secondary.options) + try { + const params = { streamId: 4, seq: 8, data: 'before' } + const pending = dispatcher.notifyBulk('git.responseChunk', params) + params.data = 'after' + + expect(protocolCalls).toEqual({ preparations: 0, encodes: 0 }) + await pending + + expect(decodeFrame(primary.frames[0]).message.params?.data).toBe('before') + expect(decodeFrame(secondary.frames[0]).message.params?.data).toBe('before') + expect(protocolCalls).toEqual({ preparations: 1, encodes: 2 }) + } finally { + dispatcher.dispose() + } + }) +}) diff --git a/src/relay/dispatcher-writer-admission.test.ts b/src/relay/dispatcher-writer-admission.test.ts new file mode 100644 index 00000000000..c30fbb47d71 --- /dev/null +++ b/src/relay/dispatcher-writer-admission.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_PRODUCER_QUEUE_MAX_BYTES, + DISPATCHER_CONTROL_QUEUE_MAX_FRAMES, + DispatcherWriterAdmission, + type DispatcherWriterEntry, + type DispatcherWriterLane +} from './dispatcher-writer-admission' + +function createEntry( + lane: DispatcherWriterLane, + id: number, + estimatedBytes = 1 +): DispatcherWriterEntry { + return { + lane, + encode: () => Buffer.from(String(id)), + estimatedBytes, + onSettled: () => undefined, + settled: false + } +} + +function admitOrThrow(admission: DispatcherWriterAdmission, entry: DispatcherWriterEntry): void { + const result = admission.admit(entry, Number.MAX_SAFE_INTEGER) + if (!result.accepted) { + throw new Error(`entry rejected from ${entry.lane}`) + } +} + +function shiftOrThrow( + admission: DispatcherWriterAdmission, + lane: DispatcherWriterLane +): DispatcherWriterEntry { + const entry = admission.shift(lane) + if (!entry) { + throw new Error(`missing queued entry from ${lane}`) + } + return entry +} + +describe('DispatcherWriterAdmission', () => { + it('drains a release-scale lane in FIFO order across compactions', () => { + const entryCount = 30_000 + const admission = new DispatcherWriterAdmission(DEFAULT_PRODUCER_QUEUE_MAX_BYTES) + const entries = Array.from({ length: entryCount }, (_, index) => + createEntry('ordinary', index, 64) + ) + + for (const entry of entries) { + admitOrThrow(admission, entry) + } + expect(admission.queuedEntries).toBe(entryCount) + expect(admission.retainedProducerBytes).toBe(entryCount * 64) + expect(admission.peek('ordinary')).toBe(entries[0]) + + for (let index = 0; index < entries.length; index++) { + const shifted = shiftOrThrow(admission, 'ordinary') + if (shifted !== entries[index]) { + throw new Error(`FIFO mismatch at ${index}`) + } + admission.release(shifted) + if (index === 0 || index === Math.floor(entryCount / 2)) { + expect(admission.peek('ordinary')).toBe(entries[index + 1]) + } + } + + expect(admission.shift('ordinary')).toBeUndefined() + expect(admission.queuedEntries).toBe(0) + expect(admission.retainedProducerBytes).toBe(0) + }) + + it('keeps accounting retained until shifted entries are released', () => { + const producerAdmission = new DispatcherWriterAdmission(4) + const producerEntries = Array.from({ length: 4 }, (_, index) => createEntry('ordinary', index)) + for (const entry of producerEntries) { + admitOrThrow(producerAdmission, entry) + expect(shiftOrThrow(producerAdmission, 'ordinary')).toBe(entry) + } + expect(producerAdmission.queuedEntries).toBe(0) + expect(producerAdmission.retainedProducerBytes).toBe(4) + expect(producerAdmission.canAdmitProducer(1, 1)).toBe(false) + producerAdmission.release(producerEntries[0]) + expect(producerAdmission.canAdmitProducer(1, 1)).toBe(true) + for (const entry of producerEntries.slice(1)) { + producerAdmission.release(entry) + } + + const controlAdmission = new DispatcherWriterAdmission(1) + const controlEntries = Array.from({ length: DISPATCHER_CONTROL_QUEUE_MAX_FRAMES }, (_, index) => + createEntry('control', index) + ) + for (const entry of controlEntries) { + admitOrThrow(controlAdmission, entry) + expect(shiftOrThrow(controlAdmission, 'control')).toBe(entry) + } + expect(controlAdmission.queuedEntries).toBe(0) + expect(controlAdmission.canAdmitControl(1)).toBe(false) + controlAdmission.release(controlEntries[0]) + expect(controlAdmission.canAdmitControl(1)).toBe(true) + for (const entry of controlEntries.slice(1)) { + controlAdmission.release(entry) + } + }) + + it('replaces only the live liveness tail after a consumed prefix', () => { + const admission = new DispatcherWriterAdmission(1) + const first = createEntry('liveness', 1) + const queued = createEntry('liveness', 2) + const replacement = createEntry('liveness', 3) + + admitOrThrow(admission, first) + admitOrThrow(admission, queued) + expect(shiftOrThrow(admission, 'liveness')).toBe(first) + expect(admission.admit(replacement, Number.MAX_SAFE_INTEGER)).toEqual({ + accepted: true, + replaced: queued + }) + expect(shiftOrThrow(admission, 'liveness')).toBe(replacement) + expect(admission.queuedEntries).toBe(0) + expect(admission.admit(createEntry('liveness', 4), Number.MAX_SAFE_INTEGER)).toEqual({ + accepted: false + }) + + admission.release(first) + admission.release(replacement) + const afterRelease = createEntry('liveness', 5) + admitOrThrow(admission, afterRelease) + expect(shiftOrThrow(admission, 'liveness')).toBe(afterRelease) + admission.release(afterRelease) + }) + + it('takes only the live suffix after deep partial drains and refills', () => { + const admission = new DispatcherWriterAdmission(10_000) + const initial = Array.from({ length: 4_096 }, (_, index) => createEntry('ordinary', index)) + for (const entry of initial) { + admitOrThrow(admission, entry) + } + for (let index = 0; index < 3_000; index++) { + const shifted = shiftOrThrow(admission, 'ordinary') + expect(shifted).toBe(initial[index]) + admission.release(shifted) + } + const refill = Array.from({ length: 2_048 }, (_, index) => + createEntry('ordinary', initial.length + index) + ) + for (const entry of refill) { + admitOrThrow(admission, entry) + } + + const expected = [...initial.slice(3_000), ...refill] + const queued = admission.takeQueued() + expect(queued).toEqual(expected) + expect(admission.queuedEntries).toBe(0) + expect(admission.retainedProducerBytes).toBe(expected.length) + for (const entry of queued) { + admission.release(entry) + } + expect(admission.retainedProducerBytes).toBe(0) + }) + + it('preserves close-time lane and per-lane FIFO order', () => { + const admission = new DispatcherWriterAdmission(1_024) + const fixedBulk = createEntry('fixed-bulk', 1) + const liveness = createEntry('liveness', 2) + const control = createEntry('control', 3) + const legacy = createEntry('legacy-response', 4) + const interactive = createEntry('interactive', 5) + const ordinaryOne = createEntry('ordinary', 6) + const ordinaryTwo = createEntry('ordinary', 7) + const bulk = createEntry('bulk', 8) + for (const entry of [ + fixedBulk, + liveness, + control, + legacy, + interactive, + ordinaryOne, + ordinaryTwo, + bulk + ]) { + admitOrThrow(admission, entry) + } + + const queued = admission.takeQueued() + expect(queued).toEqual([ + liveness, + control, + legacy, + interactive, + ordinaryOne, + ordinaryTwo, + fixedBulk, + bulk + ]) + for (const entry of queued) { + admission.release(entry) + } + expect(admission.queuedEntries).toBe(0) + expect(admission.retainedProducerBytes).toBe(0) + expect(admission.canAdmitControl(1)).toBe(true) + }) +}) diff --git a/src/relay/dispatcher-writer-admission.ts b/src/relay/dispatcher-writer-admission.ts index 71865b3c1f0..70b86f0e97b 100644 --- a/src/relay/dispatcher-writer-admission.ts +++ b/src/relay/dispatcher-writer-admission.ts @@ -50,15 +50,84 @@ export function relayWriterControlReserve(highWaterMark: number): number { return Math.min(64 * 1024, Math.max(1024, Math.floor(highWaterMark / 4))) } +const LANE_QUEUE_COMPACTION_HEAD_THRESHOLD = 64 + +class DispatcherWriterLaneQueue { + private entries: (DispatcherWriterEntry | undefined)[] = [] + private head = 0 + + get length(): number { + return this.entries.length - this.head + } + + push(entry: DispatcherWriterEntry): void { + this.entries.push(entry) + } + + peek(): DispatcherWriterEntry | undefined { + return this.entries[this.head] + } + + shift(): DispatcherWriterEntry | undefined { + const entry = this.entries[this.head] + if (!entry) { + return undefined + } + this.entries[this.head] = undefined + this.head++ + this.compact() + return entry + } + + pop(): DispatcherWriterEntry | undefined { + if (this.length === 0) { + return undefined + } + const entry = this.entries.pop() + if (this.entries.length === this.head) { + this.reset() + } + return entry + } + + takeAll(): DispatcherWriterEntry[] { + const queued: DispatcherWriterEntry[] = [] + for (let index = this.head; index < this.entries.length; index++) { + const entry = this.entries[index] + if (entry) { + queued.push(entry) + } + } + this.reset() + return queued + } + + private compact(): void { + if (this.head === this.entries.length) { + this.reset() + return + } + if (this.head >= LANE_QUEUE_COMPACTION_HEAD_THRESHOLD && this.head * 2 >= this.entries.length) { + this.entries = this.entries.slice(this.head) + this.head = 0 + } + } + + private reset(): void { + this.entries.length = 0 + this.head = 0 + } +} + export class DispatcherWriterAdmission { - private readonly queues: Record = { - liveness: [], - control: [], - 'legacy-response': [], - interactive: [], - ordinary: [], - 'fixed-bulk': [], - bulk: [] + private readonly queues: Record = { + liveness: new DispatcherWriterLaneQueue(), + control: new DispatcherWriterLaneQueue(), + 'legacy-response': new DispatcherWriterLaneQueue(), + interactive: new DispatcherWriterLaneQueue(), + ordinary: new DispatcherWriterLaneQueue(), + 'fixed-bulk': new DispatcherWriterLaneQueue(), + bulk: new DispatcherWriterLaneQueue() } private controlBytes = 0 private controlFrames = 0 @@ -115,7 +184,7 @@ export class DispatcherWriterAdmission { } peek(lane: DispatcherWriterLane): DispatcherWriterEntry | undefined { - return this.queues[lane][0] + return this.queues[lane].peek() } shift(lane: DispatcherWriterLane): DispatcherWriterEntry | undefined { @@ -123,7 +192,7 @@ export class DispatcherWriterAdmission { } takeQueued(): DispatcherWriterEntry[] { - return Object.values(this.queues).flatMap((queue) => queue.splice(0)) + return Object.values(this.queues).flatMap((queue) => queue.takeAll()) } release(entry: DispatcherWriterEntry): void { diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index f38d3833dca..280f9351200 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -725,12 +725,18 @@ describe('RelayDispatcher', () => { type DispatcherInternals = { primaryClient: object estimateFrameBytes: (msg: JsonRpcNotification) => number + prepareFrame: (msg: JsonRpcNotification) => object enqueueFrame: ( client: object, msg: JsonRpcNotification, lane: string, - onSettled?: (result: SinkWriteSettlement) => void, - estimatedBytes?: number + onSettled?: (result: SinkWriteSettlement) => void + ) => boolean + enqueuePreparedFrame: ( + client: object, + frame: object, + lane: string, + onSettled?: (result: SinkWriteSettlement) => void ) => boolean } @@ -840,14 +846,14 @@ describe('RelayDispatcher', () => { } }) - it('publishes PTY data with a single frame estimate', () => { + it('publishes PTY data with a single frame preparation', () => { 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') + const spy = vi.spyOn(publisher as unknown as DispatcherInternals, 'prepareFrame') expect(publisher.tryNotifyPtyData({ id: 'pty-1', data: 'hello' })).toBe(true) expect(frames).toHaveLength(1) expect(spy).toHaveBeenCalledTimes(1) @@ -856,7 +862,7 @@ describe('RelayDispatcher', () => { } }) - it('enqueueFrame with a caller-supplied estimate matches the computed default', () => { + it('a prepared enqueue matches the composition wrapper', () => { const frames: Buffer[] = [] const publisher = new RelayDispatcher((data) => { frames.push(Buffer.from(data)) @@ -871,12 +877,10 @@ describe('RelayDispatcher', () => { } expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(true) expect( - internals.enqueueFrame( + internals.enqueuePreparedFrame( internals.primaryClient, - msg, - 'ordinary', - undefined, - internals.estimateFrameBytes(msg) + internals.prepareFrame(msg), + 'ordinary' ) ).toBe(true) expect(frames).toHaveLength(2) @@ -888,7 +892,7 @@ describe('RelayDispatcher', () => { } }) - it('enqueueFrame rejects identically with and without a caller-supplied estimate', () => { + it('a prepared enqueue rejects identically to the composition wrapper', () => { const { sized } = makeDispatcher([1030]) try { const internals = sized as unknown as DispatcherInternals @@ -899,12 +903,10 @@ describe('RelayDispatcher', () => { } expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) expect( - internals.enqueueFrame( + internals.enqueuePreparedFrame( internals.primaryClient, - msg, - 'ordinary', - undefined, - internals.estimateFrameBytes(msg) + internals.prepareFrame(msg), + 'ordinary' ) ).toBe(false) } finally { diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 5620b44f0d7..dab306c000e 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -1,16 +1,19 @@ /* eslint-disable max-lines -- dispatcher keeps client routing, cancellation, and framing state together */ import { FrameDecoder, + HEADER_LENGTH, MessageType, - encodeJsonRpcFrame, + encodePreparedJsonRpcFrame, encodeKeepAliveFrame, parseJsonRpcMessage, + prepareJsonRpcPayload, KEEPALIVE_SEND_MS, RelayErrorCode, type DecodedFrame, type JsonRpcRequest, type JsonRpcNotification, - type JsonRpcResponse + type JsonRpcResponse, + type PreparedJsonRpcPayload } from './protocol' import { ClientRequestAborts } from './client-request-aborts' import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../shared/timer-delay' @@ -80,6 +83,14 @@ type RelayClient = { sessionIdentity: RelayClientSessionIdentity } +type OutgoingJsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification + +type PreparedRelayFrame = Readonly<{ + payload: PreparedJsonRpcPayload + frameBytes: number + ptyDataAdmissionParams: Readonly> | null +}> + // Why: the log key set is rebuilt per generation, but a producer minting synthetic method names would still // grow it inside one generation — cap it well above the fixed relay method vocabulary. const DROPPED_NOTIFICATION_LOG_KEY_LIMIT = 64 @@ -571,8 +582,8 @@ export class RelayDispatcher { method, ...(params !== undefined ? { params } : {}) } - const frameBytes = this.estimateFrameBytes(msg) this.runPublicationTransaction(() => { + let frame: PreparedRelayFrame | undefined for (const client of this.clients.values()) { if (client.closed) { continue @@ -580,16 +591,17 @@ export class RelayDispatcher { if (method === 'pty.data' && !this.admitsPtyDataPublication(client.id, params ?? {})) { continue } + frame ??= this.prepareFrame(msg) if (method === 'pty.replay') { // Why: replay is never re-sent, so it takes the control lane where overflow is fatal — the // writer closes the client and reconnect reloads history rather than stranding a short buffer. - this.enqueueFrame(client, msg, 'control', undefined, frameBytes) + this.enqueuePreparedFrame(client, frame, 'control') continue } // Why: closing can never make an oversized frame sendable — the producer regenerates it after // reattach and re-kills the link, turning a recoverable drop into an endless reconnect loop. - if (!this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) { - this.logDroppedProducerNotification(client, method, frameBytes) + if (!this.publishPreparedToClient(client, frame, 'ordinary')) { + this.logDroppedProducerNotification(client, method, frame.frameBytes) } } }) @@ -620,13 +632,13 @@ export class RelayDispatcher { if (method === 'pty.data' && !this.admitsPtyDataPublication(client.id, params ?? {})) { return false } - const frameBytes = this.estimateFrameBytes(msg) - if (this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) { + const frame = this.prepareFrame(msg) + if (this.publishPreparedToClient(client, frame, 'ordinary')) { return true } // Why: same diagnostics as notify() — a producer that drops here must not do so silently. if (options?.logDrop !== false) { - this.logDroppedProducerNotification(client, method, frameBytes) + this.logDroppedProducerNotification(client, method, frame.frameBytes) } return false } @@ -664,7 +676,6 @@ export class RelayDispatcher { onSettled: (result: SinkWriteSettlement) => void = () => {}, options: { controlOverflow?: 'close-client' | 'reject' - estimatedBytes?: number } = {} ): boolean { if (this.disposed) { @@ -685,7 +696,6 @@ export class RelayDispatcher { }, 'control', onSettled, - options.estimatedBytes, options.controlOverflow ) } @@ -699,8 +709,13 @@ export class RelayDispatcher { method, ...(params !== undefined ? { params } : {}) } - for (const client of this.activeClients()) { - if (!this.enqueueFrame(client, msg, 'control')) { + const clients = this.activeClients() + if (clients.length === 0) { + return + } + const frame = this.prepareFrame(msg) + for (const client of clients) { + if (!this.enqueuePreparedFrame(client, frame, 'control')) { this.closeClient( client, new Error('Relay control publication capacity exceeded'), @@ -724,27 +739,48 @@ export class RelayDispatcher { if (this.disposed) { return Promise.resolve() } - const msg: JsonRpcNotification = { - jsonrpc: '2.0', - method, - ...(params !== undefined ? { params } : {}) - } const targets = opts?.clientId !== undefined ? [this.clients.get(opts.clientId)].filter((c): c is RelayClient => c !== undefined) : Array.from(this.clients.values()) - const waits: Promise[] = [] - for (const client of targets) { - if (client.closed) { - continue + const activeTargets = targets.filter((client) => !client.closed) + if (activeTargets.length === 0) { + return Promise.resolve() + } + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params: { ...params } } : {}) + } + let prepared: + | { ok: true; frame: PreparedRelayFrame } + | { ok: false; error: unknown } + | undefined + const prepareOnce = (): PreparedRelayFrame => { + if (!prepared) { + try { + prepared = { ok: true, frame: this.prepareFrame(msg) } + } catch (error) { + prepared = { ok: false, error } + } } - const step = client.bulkChain.then(() => this.publishBulkWhenAvailable(client, msg)) + if (!prepared.ok) { + throw prepared.error + } + return prepared.frame + } + const lane = method === 'fs.streamChunk' ? 'fixed-bulk' : 'bulk' + const waits: Promise[] = [] + for (const client of activeTargets) { + const step = client.bulkChain.then(() => { + if (this.disposed || client.closed) { + return + } + return this.publishBulkWhenAvailable(client, prepareOnce(), lane) + }) client.bulkChain = step.catch(() => {}) waits.push(step) } - if (waits.length === 0) { - return Promise.resolve() - } return Promise.all(waits).then(() => {}) } @@ -801,7 +837,7 @@ export class RelayDispatcher { reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`)) }, timeoutMs) this.pendingRelayRequests.set(id, { resolve, reject, timer }) - if (!this.enqueueFrame(client, msg, 'control', () => {}, undefined, 'reject')) { + if (!this.enqueueFrame(client, msg, 'control', () => {}, 'reject')) { clearTimeout(timer) this.pendingRelayRequests.delete(id) reject(new Error(`Request "${method}" exceeded the relay control transport capacity`)) @@ -1048,24 +1084,25 @@ export class RelayDispatcher { id, ...(error ? { error } : { result: result ?? null }) } - const estimatedBytes = this.estimateFrameBytes(msg) - const lane = estimatedBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES ? 'legacy-response' : 'control' - const accepted = this.enqueueFrame(client, msg, lane, onSettled) + const frame = this.prepareFrame(msg) + const lane = + frame.frameBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES ? 'legacy-response' : 'control' + const accepted = this.enqueuePreparedFrame(client, frame, lane, onSettled) if (accepted) { return true } // Why: an oversized response must fail its own request; closing would kill every pane on the host. // A rejected first enqueue either left onSettled untouched or closed the client, so exactly one settlement happens. - return this.enqueueFrame( + return this.enqueuePreparedFrame( client, - { + this.prepareFrame({ jsonrpc: '2.0', id, error: { code: RelayErrorCode.ResponseOverCapacity, message: RESPONSE_OVER_CAPACITY_MESSAGE } - }, + }), 'control', // Why: writing the substitute is not delivering the result — a settlement fence must never read // the capacity error's successful write as "the peer received your result". @@ -1080,29 +1117,45 @@ export class RelayDispatcher { private enqueueFrame( client: RelayClient, - msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification, + msg: OutgoingJsonRpcMessage, + lane: DispatcherWriterLane, + onSettled: (result: SinkWriteSettlement) => void = () => {}, + controlOverflow: 'close-client' | 'reject' = 'close-client' + ): boolean { + if (this.disposed || client.closed) { + return false + } + return this.enqueuePreparedFrame( + client, + this.prepareFrame(msg), + lane, + onSettled, + controlOverflow + ) + } + + private enqueuePreparedFrame( + client: RelayClient, + frame: PreparedRelayFrame, lane: DispatcherWriterLane, onSettled: (result: SinkWriteSettlement) => void = () => {}, - // Why: publish paths already sized the frame; avoid a redundant encode. - estimatedBytes?: number, controlOverflow: 'close-client' | 'reject' = 'close-client' ): boolean { if (this.disposed || client.closed) { return false } - const frameBytes = estimatedBytes ?? this.estimateFrameBytes(msg) const encode = (): Buffer => { const seq = client.nextOutgoingSeq++ - return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) + return encodePreparedJsonRpcFrame(frame.payload, seq, client.highestReceivedSeq) } - const isStillAdmitted = - 'method' in msg && msg.method === 'pty.data' - ? () => this.admitsPtyDataPublication(client.id, msg.params ?? {}) - : undefined + const admissionParams = frame.ptyDataAdmissionParams + const isStillAdmitted = admissionParams + ? () => this.admitsPtyDataPublication(client.id, admissionParams) + : undefined return client.writer.enqueue( lane, encode, - frameBytes, + frame.frameBytes, onSettled, lane === 'control' && controlOverflow === 'reject', isStillAdmitted @@ -1151,8 +1204,27 @@ export class RelayDispatcher { return `${client.id}:${client.generation}` } - private estimateFrameBytes(msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification): number { - return encodeJsonRpcFrame(msg, 0, 0).length + private prepareFrame(msg: OutgoingJsonRpcMessage): PreparedRelayFrame { + const payload = prepareJsonRpcPayload(msg) + const params = 'method' in msg && msg.method === 'pty.data' ? (msg.params ?? {}) : null + return Object.freeze({ + payload, + frameBytes: HEADER_LENGTH + payload.byteLength, + ptyDataAdmissionParams: + params === null + ? null + : Object.freeze({ + id: params.id, + deliveryToken: params.deliveryToken, + clientGeneration: params.clientGeneration, + ownerGeneration: params.ownerGeneration, + ptyIncarnation: params.ptyIncarnation + }) + }) + } + + private estimateFrameBytes(msg: OutgoingJsonRpcMessage): number { + return HEADER_LENGTH + prepareJsonRpcPayload(msg).byteLength } private tryPublishToClients( @@ -1164,7 +1236,8 @@ export class RelayDispatcher { if (clients.length === 0) { return true } - const bytes = this.estimateFrameBytes(msg) + const frame = this.prepareFrame(msg) + const bytes = frame.frameBytes if (clients.some((client) => !client.writer.canEnqueueProducer(bytes))) { return false } @@ -1175,7 +1248,7 @@ export class RelayDispatcher { return false } for (let index = 0; index < clients.length; index++) { - if (!this.enqueueLeasedFrame(clients[index], msg, lane, leases[index], bytes)) { + if (!this.enqueueLeasedFrame(clients[index], frame, lane, leases[index])) { if (this.disposed || clients[index].closed) { continue } @@ -1195,8 +1268,12 @@ export class RelayDispatcher { lane: 'interactive' | 'ordinary' ): boolean { return this.runPublicationTransaction(() => { + if (clients.length === 0) { + return true + } + const frame = this.prepareFrame(msg) for (const client of clients) { - if (client.closed || this.publishToClient(client, msg, lane)) { + if (client.closed || this.publishPreparedToClient(client, frame, lane)) { continue } this.closeClient( @@ -1213,11 +1290,21 @@ export class RelayDispatcher { client: RelayClient, msg: JsonRpcNotification, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', - onSettled: (result: SinkWriteSettlement) => void = () => {}, - // Why: broadcast callers size the frame once for every client; avoid a redundant encode. - estimatedBytes?: number + onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { - const bytes = estimatedBytes ?? this.estimateFrameBytes(msg) + if (this.disposed || client.closed) { + return false + } + return this.publishPreparedToClient(client, this.prepareFrame(msg), lane, onSettled) + } + + private publishPreparedToClient( + client: RelayClient, + frame: PreparedRelayFrame, + lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', + onSettled: (result: SinkWriteSettlement) => void = () => {} + ): boolean { + const bytes = frame.frameBytes const fixedBlocked = lane === 'fixed-bulk' && (client.writer.retainedProducerBytes > 0 || bytes > client.writer.fixedFrameCapacity) @@ -1228,12 +1315,15 @@ export class RelayDispatcher { if (!leases) { return false } - return this.enqueueLeasedFrame(client, msg, lane, leases[0], bytes, onSettled) + return this.enqueueLeasedFrame(client, frame, lane, leases[0], onSettled) } - private publishBulkWhenAvailable(client: RelayClient, msg: JsonRpcNotification): Promise { - const bytes = this.estimateFrameBytes(msg) - const lane = msg.method === 'fs.streamChunk' ? 'fixed-bulk' : 'bulk' + private publishBulkWhenAvailable( + client: RelayClient, + frame: PreparedRelayFrame, + lane: 'fixed-bulk' | 'bulk' + ): Promise { + const bytes = frame.frameBytes if (bytes > DEFAULT_PRODUCER_QUEUE_MAX_BYTES) { return Promise.reject(new Error('Relay bulk frame exceeds sink producer capacity')) } @@ -1253,7 +1343,7 @@ export class RelayDispatcher { return } if ( - this.publishToClient(client, msg, lane, (result) => { + this.publishPreparedToClient(client, frame, lane, (result) => { finish() if (result.ok || this.disposed || client.closed) { resolve() @@ -1274,23 +1364,16 @@ export class RelayDispatcher { private enqueueLeasedFrame( client: RelayClient, - msg: JsonRpcNotification, + frame: PreparedRelayFrame, 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() - }, - estimatedBytes - ) + const accepted = this.enqueuePreparedFrame(client, frame, lane, (result) => { + lease.release() + onSettled(result) + this.notifyLegacyCapacityIfLow() + }) if (!accepted) { lease.release() this.notifyLegacyCapacityIfLow() diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index baaaa99481e..03bb0b9beba 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -382,7 +382,7 @@ export class ExternalAutomationsHandler { (sessionRun, index) => !usedSessionRunIndexes.has(index) && this.getRunKey(sessionRun) === outputRunKey ) - if (exactMatchIndex >= 0) { + if (exactMatchIndex !== -1) { return exactMatchIndex } @@ -755,7 +755,7 @@ export class ExternalAutomationsHandler { } } - private readHermesSessionDbRunById(jobId: string, runId: string): unknown | null { + private readHermesSessionDbRunById(jobId: string, runId: string): unknown { if (!existsSync(HERMES_STATE_DB)) { return null } diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index 02cd8da6afc..baf6101600c 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -31,6 +31,7 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 1ca5c2f5327..b819d3ac85c 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -16,6 +16,11 @@ import { tmpdir } from 'node:os' import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' import { searchWithRg } from './fs-handler-utils' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { + ListFilesScanCoordinator, + LIST_FILES_SUPERSEDED_MESSAGE +} from './fs-list-files-scan-coordinator' const tempDirs: string[] = [] const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -36,6 +41,7 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } @@ -72,6 +78,7 @@ describe('relay quick open ignored file listing', () => { }) const promise = listFilesWithRg('/remote/root', ['packages/other']) + expect(spawnMock).toHaveBeenCalledTimes(2) setTimeout(() => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') @@ -115,6 +122,82 @@ describe('relay quick open ignored file listing', () => { expect(callIndex).toBe(1) }) + it.each(['error-first', 'close-first'] as const)( + 'tags a %s pre-spawn listing failure without starting the ignored pass', + async (order) => { + const root = await makeTempRoot() + const missing = createMockProcess() + Object.defineProperty(missing, 'pid', { value: undefined }) + spawnMock.mockReturnValue(missing) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + + const promise = listFilesWithRg(root) + if (order === 'error-first') { + expect(() => missing.emit('error', error)).not.toThrow() + } else { + missing.emit('close', -2, null) + } + + await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) + if (order === 'error-first') { + missing.emit('close', -2, null) + } else { + expect(() => missing.emit('error', error)).not.toThrow() + } + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(missing.listenerCount('error')).toBe(0) + expect(missing.listenerCount('close')).toBe(0) + } + ) + + it('kills only the admitted pass when ignored rg fails before spawn', async () => { + const root = await makeTempRoot() + const primary = createMockProcess() + const missingIgnored = createMockProcess() + Object.defineProperty(missingIgnored, 'pid', { value: undefined }) + spawnMock.mockImplementation((_cmd: string, args: string[]) => + args.includes('--no-ignore-vcs') ? missingIgnored : primary + ) + + const promise = listFilesWithRg(root) + expect(spawnMock).toHaveBeenCalledTimes(2) + missingIgnored.emit('close', -2, null) + + await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(primary.kill).toHaveBeenCalled() + expect(missingIgnored.kill).not.toHaveBeenCalled() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => missingIgnored.emit('error', error)).not.toThrow() + expect(missingIgnored.listenerCount('error')).toBe(0) + }) + + it('does not signal failed-spawn passes when a same-client scan is superseded', async () => { + const root = await makeTempRoot() + const firstChild = createMockProcess() + Object.defineProperty(firstChild, 'pid', { value: undefined }) + spawnMock.mockReturnValue(firstChild) + const coordinator = new ListFilesScanCoordinator() + const first = coordinator.run({ + clientId: 1, + key: 'first', + start: (signal) => listFilesWithRg(root, [], { signal }) + }) + const firstOutcome = first.catch((error: unknown) => error) + + const second = coordinator.run({ + clientId: 1, + key: 'second', + start: async () => ['second.ts'] + }) + + await expect(firstOutcome).resolves.toMatchObject({ message: LIST_FILES_SUPERSEDED_MESSAGE }) + await expect(second).resolves.toEqual(['second.ts']) + expect(firstChild.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => firstChild.emit('error', error)).not.toThrow() + }) + it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -430,4 +513,101 @@ describe('relay quick open ignored file listing', () => { vi.useRealTimers() } }) + + it.each(['error-first', 'close-first'] as const)( + 'tags only a %s pre-spawn rg search failure as unavailable', + async (order) => { + const root = await makeTempRoot() + const missing = createMockProcess() + Object.defineProperty(missing, 'pid', { value: undefined }) + spawnMock.mockReturnValueOnce(missing) + const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => missing.emit('error', error)).not.toThrow() + missing.emit('close', -2, null) + } else { + missing.emit('close', -2, null) + expect(() => missing.emit('error', error)).not.toThrow() + } + + await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(missing.listenerCount('error')).toBe(0) + expect(missing.listenerCount('close')).toBe(0) + } + ) + + it('tags unsupported native launcher exits as unavailable', async () => { + const root = await makeTempRoot() + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(child) + const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) + + child.emit('close', 127, null) + + await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) + }) + + it('keeps missing-root launch errors on their prior non-fallback paths', async () => { + const missingRoot = await makeTempRoot() + await rm(missingRoot, { recursive: true, force: true }) + const listFirst = createMockProcess() + const listProbe = createMockProcess() + Object.defineProperty(listFirst, 'pid', { value: undefined }) + Object.defineProperty(listProbe, 'pid', { value: 1 }) + let callIndex = 0 + spawnMock.mockImplementation(() => [listFirst, listProbe][callIndex++]) + const listError = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + const listing = listFilesWithRg(missingRoot) + listFirst.emit('error', listError) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + expect(spawnMock.mock.calls[1]).toEqual(['rg', ['--version'], { stdio: 'ignore' }]) + listProbe.emit('close', 0, null) + await expect(listing).rejects.toBe(listError) + + spawnMock.mockReset() + const searchChild = createMockProcess() + const searchProbe = createMockProcess() + Object.defineProperty(searchChild, 'pid', { value: undefined }) + Object.defineProperty(searchProbe, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(searchChild).mockReturnValueOnce(searchProbe) + const search = searchWithRg(missingRoot, 'ok', { maxResults: 100 }) + searchChild.emit('error', listError) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + searchProbe.emit('close', 0, null) + await expect(search).resolves.toMatchObject({ files: [], totalMatches: 0 }) + }) + + it('keeps missing-rg precedence when the root also disappeared', async () => { + const missingRoot = await makeTempRoot() + await rm(missingRoot, { recursive: true, force: true }) + const first = createMockProcess() + const probe = createMockProcess() + for (const child of [first, probe]) { + Object.defineProperty(child, 'pid', { value: undefined }) + } + let callIndex = 0 + spawnMock.mockImplementation(() => [first, probe][callIndex++]) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + const listing = listFilesWithRg(missingRoot) + first.emit('error', error) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + probe.emit('close', -2, null) + + await expect(listing).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(() => probe.emit('error', error)).not.toThrow() + }) + + it('keeps post-spawn rg search errors on the existing empty-result path', async () => { + const started = createMockProcess() + Object.defineProperty(started, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(started) + const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) + started.emit('error', new Error('post-spawn failure')) + await expect(ordinaryFailure).resolves.toMatchObject({ files: [], totalMatches: 0 }) + }) }) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index c63a352786e..3ec7b99026d 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -1,7 +1,5 @@ /** * Ripgrep-based file listing for Quick Open. - * Extracted from fs-handler-utils.ts to keep it under 300 lines (oxlint max-lines). - * * Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir * worktree over SSH, rg descended into every dotfile cache, hit the timeout, * and silently resolved with a partial list — Quick Open then showed "No @@ -23,6 +21,13 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../shared/quick-open-filter' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableAfterLaunchFailure, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../shared/ripgrep-process-availability' export const LIST_FILES_TIMEOUT_MS = 25_000 @@ -78,6 +83,9 @@ export function listFilesWithRg( let passBuf = '' let passDone = false let passFileCount = 0 + let processErrorObserved = false + let unavailableExitObserved = false + let launchFailureCheck: Promise | null = null // --no-messages: permission-denied noise on the remote (e.g. .ssh, // root-owned mounts) would otherwise flood stderr. // cwd: rootPath — root-relative exclude globs like `!packages/app/**` @@ -98,6 +106,10 @@ export function listFilesWithRg( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) } const rejectPass = (error: Error): void => { if (passDone) { @@ -116,6 +128,16 @@ export function listFilesWithRg( cleanup() passResolve() } + const rejectLaunchFailure = (error: Error): void => { + if (launchFailureCheck) { + return + } + launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then( + (unavailable) => { + rejectPass(unavailable ? new RipgrepUnavailableError() : error) + } + ) + } children.push({ child, isDone: () => passDone, @@ -125,7 +147,7 @@ export function listFilesWithRg( timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte // sequence could look like a valid path. - child.kill() + killSpawnedRipgrepProcess(child) rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) @@ -149,12 +171,28 @@ export function listFilesWithRg( /* drain to prevent backpressure stalls */ } function handleError(err: Error): void { + processErrorObserved = true + if (isRipgrepUnavailableExit(child, null, null)) { + passBuf = '' + rejectLaunchFailure(err) + return + } rejectPass(err) } function handleClose(code: number | null, signal: NodeJS.Signals | null): void { if (passDone) { return } + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: true + }) + ) { + unavailableExitObserved = true + passBuf = '' + rejectLaunchFailure(new Error(`rg exited with code ${code}`)) + return + } // Why signal != null is a failure: the only way spawn gets a signal // is if the process was killed (timeout, OOM, external SIGKILL). // Trusting its stdout could surface a truncated list as a success. @@ -199,7 +237,7 @@ export function listFilesWithRg( continue } if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } entry.reject(new Error(reason)) } @@ -228,12 +266,15 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) + const primaryPass = runPass(primary) const passes = maxResults === undefined - ? Promise.all([runPass(primary), runPass(ignoredPass)]) + ? children[0]?.child.pid === undefined + ? primaryPass + : Promise.all([primaryPass, runPass(ignoredPass)]) : // Why: deterministic primary-first budgeting prevents a large ignored // tree from starving ordinary source paths on a remote host. - runPass(primary).then(() => + primaryPass.then(() => files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() ) diff --git a/src/relay/fs-handler-rg-availability.test.ts b/src/relay/fs-handler-rg-availability.test.ts deleted file mode 100644 index 8b14a3d61af..00000000000 --- a/src/relay/fs-handler-rg-availability.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' - -const { execFileMock, spawnMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - spawnMock: vi.fn() -})) - -vi.mock('child_process', () => ({ - execFile: execFileMock, - spawn: spawnMock -})) - -import { checkRgAvailable } from './fs-handler-utils' - -class FakeChildProcess extends EventEmitter { - kill = vi.fn() -} - -describe('relay rg availability', () => { - it('removes listeners after a successful probe', async () => { - const child = new FakeChildProcess() - execFileMock.mockReturnValueOnce(child) - - const result = checkRgAvailable() - child.emit('close', 0) - - await expect(result).resolves.toBe(true) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - }) - - it('settles and detaches when a wedged probe ignores timeout kill', async () => { - vi.useFakeTimers() - try { - const child = new FakeChildProcess() - execFileMock.mockReturnValueOnce(child) - - const result = checkRgAvailable() - await vi.advanceTimersByTimeAsync(5000) - - await expect(result).resolves.toBe(false) - expect(child.kill).toHaveBeenCalledTimes(1) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/relay/fs-handler-ripgrep-fallback.test.ts b/src/relay/fs-handler-ripgrep-fallback.test.ts new file mode 100644 index 00000000000..93d89fd9008 --- /dev/null +++ b/src/relay/fs-handler-ripgrep-fallback.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ChildProcessModule from 'node:child_process' +import type * as FsHandlerGitFallback from './fs-handler-git-fallback' +import type * as FsHandlerUtils from './fs-handler-utils' + +const { + execFileMock, + listFilesWithGitMock, + listFilesWithRgMock, + searchWithGitGrepMock, + searchWithRgMock +} = vi.hoisted(() => ({ + execFileMock: vi.fn(), + listFilesWithGitMock: vi.fn(), + listFilesWithRgMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), + searchWithRgMock: vi.fn() +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) + +vi.mock('./fs-handler-utils', async (importOriginal) => ({ + ...(await importOriginal()), + listFilesWithRg: listFilesWithRgMock, + searchWithRg: searchWithRgMock +})) + +vi.mock('./fs-handler-git-fallback', async (importOriginal) => ({ + ...(await importOriginal()), + listFilesWithGit: listFilesWithGitMock, + searchWithGitGrep: searchWithGitGrepMock +})) + +import { FileListingCancelledError } from '../shared/file-listing-cancellation' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' + +type FsHandlerInternals = { + search(params: Record): Promise + runListFilesScan( + rootPath: string, + excludePathPrefixes: string[], + signal: AbortSignal, + maxResults?: number + ): Promise +} + +function createHandler(): FsHandlerInternals { + const dispatcher = { + onRequest: vi.fn(), + onNotification: vi.fn(), + onClientDetached: vi.fn(() => () => undefined) + } + const watcherPool = { + dispose: vi.fn(), + forgetRoot: vi.fn(), + subscribe: vi.fn() + } + return new FsHandler(dispatcher as never, new RelayContext(), watcherPool as never) as never +} + +describe('relay direct ripgrep admission', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('falls back only for a tagged search launch failure', async () => { + const handler = createHandler() + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) + searchWithGitGrepMock.mockResolvedValueOnce(fallback) + + await expect(handler.search({ rootPath: '/repo', query: 'needle' })).resolves.toBe(fallback) + expect(searchWithRgMock).toHaveBeenCalledTimes(1) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + + const ordinaryFailure = new Error('rg failed after spawn') + searchWithRgMock.mockRejectedValueOnce(ordinaryFailure) + await expect(handler.search({ rootPath: '/repo', query: 'needle' })).rejects.toBe( + ordinaryFailure + ) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + }) + + it('falls back only for a tagged listing launch failure', async () => { + const handler = createHandler() + const controller = new AbortController() + listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null) + return undefined + }) + listFilesWithGitMock.mockResolvedValueOnce(['src/index.ts']) + + await expect(handler.runListFilesScan('/repo', [], controller.signal)).resolves.toEqual([ + 'src/index.ts' + ]) + expect(listFilesWithRgMock).toHaveBeenCalledTimes(1) + expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) + }) + + it('lets cancellation win an unavailable-listing race before Git starts', async () => { + const handler = createHandler() + const controller = new AbortController() + const cancellation = new FileListingCancelledError('superseded') + listFilesWithRgMock.mockImplementationOnce(async () => { + controller.abort(cancellation) + throw new RipgrepUnavailableError() + }) + + await expect(handler.runListFilesScan('/repo', [], controller.signal)).rejects.toBe( + cancellation + ) + expect(execFileMock).not.toHaveBeenCalled() + expect(listFilesWithGitMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 3fcf50a6eb2..83726c686bf 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -5,7 +5,7 @@ * These functions depend only on their arguments (plus `rg` being on PATH), * so they are straightforward to test independently. */ -import { spawn, execFile } from 'node:child_process' +import { spawn } from 'node:child_process' import { open } from 'node:fs/promises' import { buildRgArgs, @@ -16,6 +16,13 @@ import { } from '../shared/text-search' import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions' import type { SearchResult as SharedSearchResult } from '../shared/types' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableAfterLaunchFailure, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../shared/ripgrep-process-availability' // ─── Constants ─────────────────────────────────────────────────────── @@ -89,11 +96,14 @@ export function searchWithRg( query: string, opts: SearchOptions ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const rgArgs = buildRgArgs(query, rootPath, opts) const acc = createAccumulator() let buffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false + let launchFailureCheck: Promise | null = null // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), // which would leak out of the `new Promise` executor and leave the @@ -124,13 +134,47 @@ export function searchWithRg( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) resolve(finalize(acc)) } + function rejectUnavailable(): void { + if (resolved) { + return + } + resolved = true + clearTimeout(killTimeout) + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + reject(new RipgrepUnavailableError()) + } + + function settleLaunchFailure(): void { + if (launchFailureCheck) { + return + } + launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then((unavailable) => { + if (unavailable) { + rejectUnavailable() + } else { + resolveOnce() + } + }) + } + function processLine(line: string): void { const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults) if (verdict === 'stop') { - child.kill() + killSpawnedRipgrepProcess(child) } } @@ -148,10 +192,24 @@ export function searchWithRg( } function handleError(): void { + processErrorObserved = true + if (isRipgrepUnavailableExit(child, null, null)) { + settleLaunchFailure() + return + } resolveOnce() } - function handleClose(): void { + function handleClose(code: number | null, signal: NodeJS.Signals | null): void { + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: true + }) + ) { + unavailableExitObserved = true + settleLaunchFailure() + return + } if (buffer) { processLine(buffer) } @@ -166,57 +224,11 @@ export function searchWithRg( killTimeout = setTimeout(() => { acc.truncated = true - child.kill() + killSpawnedRipgrepProcess(child) resolveOnce() }, SEARCH_TIMEOUT_MS) }) } -// ─── rg availability check ────────────────────────────────────────── - -// Why no cache: `rg --version` is a sub-10ms local spawn, and caching the -// result caused a footgun — a negative cache persisted across rg installs -// (forcing a relay restart), while a positive cache could mask an rg that -// was uninstalled or broken mid-session. The `settled` flag below closes -// the original race between 'error' and 'close' that the cache was added -// to paper over, so re-checking per call is both simpler and safer. -const RG_AVAILABILITY_TIMEOUT_MS = 5000 - -export function checkRgAvailable(): Promise { - return new Promise((resolve) => { - let settled = false - const child = execFile('rg', ['--version']) - let timeout: ReturnType | null = null - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - child.off('error', onError) - child.off('close', onClose) - } - const settle = (available: boolean, options?: { kill?: boolean }): void => { - if (settled) { - return - } - settled = true - cleanup() - if (options?.kill) { - child.kill() - } - resolve(available) - } - const onError = (): void => settle(false) - const onClose = (code: number | null): void => settle(code === 0) - - child.once('error', onError) - child.once('close', onClose) - timeout = setTimeout(() => settle(false, { kill: true }), RG_AVAILABILITY_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) -} - // Moved to fs-handler-list-files.ts to keep this file under 300 lines (oxlint) export { listFilesWithRg, LIST_FILES_TIMEOUT_MS } from './fs-handler-list-files' diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index d07e2bf397f..b7dd2dfa6b5 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -10,12 +10,7 @@ import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat // (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. import { expandTilde } from './context' -import { - DEFAULT_MAX_RESULTS, - searchWithRg, - listFilesWithRg, - checkRgAvailable -} from './fs-handler-utils' +import { DEFAULT_MAX_RESULTS, searchWithRg, listFilesWithRg } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator' @@ -35,6 +30,7 @@ import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' @@ -328,26 +324,22 @@ export class FsHandler { DEFAULT_MAX_RESULTS ) - const rgAvailable = await checkRgAvailable() - if (!rgAvailable) { - return searchWithGitGrep(rootPath, query, { - caseSensitive, - wholeWord, - useRegex, - includePattern, - excludePattern, - maxResults - }) - } - - return searchWithRg(rootPath, query, { + const options = { caseSensitive, wholeWord, useRegex, includePattern, excludePattern, maxResults - }) + } + try { + return await searchWithRg(rootPath, query, options) + } catch (error) { + if (!(error instanceof RipgrepUnavailableError)) { + throw error + } + return searchWithGitGrep(rootPath, query, options) + } } private listFiles(params: Record, context?: RequestContext): Promise { @@ -380,10 +372,14 @@ export class FsHandler { signal: AbortSignal, maxResults?: number ): Promise { - const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) - if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) + try { + return await listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) + } catch (error) { + throwIfFileListingCancelled(signal) + if (!(error instanceof RipgrepUnavailableError)) { + throw error + } } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts index 588ddb5fe51..f54ebaea369 100644 --- a/src/relay/fs-list-files-cancel.integration.test.ts +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -54,7 +54,6 @@ vi.mock('./fs-handler-utils', async (importOriginal) => { const original = (await importOriginal()) as Record return { ...original, - checkRgAvailable: () => Promise.resolve(true), listFilesWithRg: fakeListFiles } }) diff --git a/src/relay/git-handler-branch-diff-equivalence.test.ts b/src/relay/git-handler-branch-diff-equivalence.test.ts new file mode 100644 index 00000000000..d33886b4f5a --- /dev/null +++ b/src/relay/git-handler-branch-diff-equivalence.test.ts @@ -0,0 +1,207 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { + createMockDispatcher, + gitCommit, + gitInit, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +// Why this file exists: the pinned route replaced the legacy route's own +// `diff --name-status -M -C` rediscovery with values the caller already holds. +// These tests drive the real product contract against real Git and assert the +// two routes agree entry-for-entry, so "SSH still renders what it used to" is +// evidence rather than an argument about call sites. + +type BranchCompareResult = { + summary: { mergeBase: string; headOid: string; status: string; changedFiles: number } + entries: { path: string; oldPath?: string; status: string }[] +} + +type DiffEntry = Record + +function git(repoPath: string, args: string[]): string { + return execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) +} + +/** + * Builds one repo whose base..head range exercises every shape the review + * panel can hand to a single-file branch diff. + */ +function buildScenarioRepo(): { repoPath: string; baseOid: string } { + const repoPath = mkdtempSync(path.join(tmpdir(), 'relay-branch-diff-equivalence-')) + gitInit(repoPath) + + const write = (relativePath: string, contents: string | Buffer): void => { + const target = path.join(repoPath, relativePath) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, contents) + } + + write('modified.txt', 'before\n') + write('deleted.txt', 'doomed\n') + write('renamed-from.txt', 'stable rename payload\n') + write('renamed-and-edited-from.txt', 'rename plus edit, line one\nline two\nline three\n') + write('copied-source.txt', 'copy me, line one\nline two\nline three\nline four\n') + write('nested/deep/inner.txt', 'nested before\n') + write('spaced name.txt', 'spaced before\n') + write('ünïcode-ページ.txt', 'unicode before\n') + write('binary-modified.bin', Buffer.from([0, 1, 2, 3, 0, 255])) + write('binary-deleted.bin', Buffer.from([9, 8, 7, 0])) + write('emptied.txt', 'about to be emptied\n') + write('crlf.txt', 'crlf before\r\nsecond line\r\n') + write('mode-changed.sh', '#!/bin/sh\necho hi\n') + gitCommit(repoPath, 'base') + const baseOid = git(repoPath, ['rev-parse', 'HEAD']).trim() + + write('modified.txt', 'after\n') + rmSync(path.join(repoPath, 'deleted.txt')) + rmSync(path.join(repoPath, 'binary-deleted.bin')) + git(repoPath, ['mv', 'renamed-from.txt', 'renamed-to.txt']) + git(repoPath, ['mv', 'renamed-and-edited-from.txt', 'renamed-and-edited-to.txt']) + write('renamed-and-edited-to.txt', 'rename plus edit, line one\nline two CHANGED\nline three\n') + write('copied-target.txt', 'copy me, line one\nline two\nline three\nline four\n') + write('added.txt', 'brand new\n') + write('binary-added.bin', Buffer.from([4, 4, 0, 4])) + write('binary-modified.bin', Buffer.from([0, 1, 2, 3, 0, 254])) + write('nested/deep/inner.txt', 'nested after\n') + write('spaced name.txt', 'spaced after\n') + write('ünïcode-ページ.txt', 'unicode after\n') + write('emptied.txt', '') + write('crlf.txt', 'crlf after\r\nsecond line\r\n') + git(repoPath, ['update-index', '--chmod=+x', 'mode-changed.sh']) + gitCommit(repoPath, 'head') + + return { repoPath, baseOid } +} + +describe('pinned and legacy branch diff equivalence against real Git', () => { + let dispatcher: MockDispatcher + let handler: GitHandler + let repoPath = '' + let baseOid = '' + + beforeEach(() => { + dispatcher = createMockDispatcher() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + const built = buildScenarioRepo() + repoPath = built.repoPath + baseOid = built.baseOid + }) + + afterEach(() => { + handler.dispose() + if (repoPath) { + rmSync(repoPath, { recursive: true, force: true }) + } + }) + + async function branchCompare(): Promise { + return (await dispatcher.callRequest('git.branchCompare', { + worktreePath: repoPath, + baseRef: baseOid + })) as BranchCompareResult + } + + async function branchDiff(params: Record): Promise { + return (await dispatcher.callRequest('git.branchDiff', { + worktreePath: repoPath, + baseRef: baseOid, + includePatch: true, + ...params + })) as DiffEntry[] + } + + it('produces identical results for every changed file the review panel can open', async () => { + const compare = await branchCompare() + expect(compare.summary.status).toBe('ready') + // Guard the guard: a truncated scenario set would make this test vacuously pass. + expect(compare.entries.length).toBeGreaterThanOrEqual(14) + + const divergences: string[] = [] + for (const entry of compare.entries) { + // Exactly what the renderer sends: paths from the compare entry list, + // OIDs from the compare summary that produced that same list. + const callerShape = { filePath: entry.path, oldPath: entry.oldPath } + const legacy = await branchDiff(callerShape) + const pinned = await branchDiff({ + ...callerShape, + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid + }) + + if (JSON.stringify(legacy) !== JSON.stringify(pinned)) { + divergences.push( + `${entry.status} ${entry.path}${entry.oldPath ? ` (from ${entry.oldPath})` : ''}\n` + + ` legacy: ${JSON.stringify(legacy)}\n pinned: ${JSON.stringify(pinned)}` + ) + } + } + + expect(divergences.join('\n')).toBe('') + }) + + it('agrees on content for renames, additions, deletions and binaries specifically', async () => { + const compare = await branchCompare() + const byPath = new Map(compare.entries.map((entry) => [entry.path, entry])) + + // Why assert content and not just equality: two identically-empty results + // would satisfy the equivalence test above while rendering nothing. + const rename = byPath.get('renamed-and-edited-to.txt') + expect(rename?.oldPath).toBe('renamed-and-edited-from.txt') + const [renamePinned] = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: rename!.path, + oldPath: rename!.oldPath + }) + expect(renamePinned).toMatchObject({ + originalContent: 'rename plus edit, line one\nline two\nline three\n', + modifiedContent: 'rename plus edit, line one\nline two CHANGED\nline three\n' + }) + + const [addedPinned] = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: 'added.txt' + }) + expect(addedPinned).toMatchObject({ originalContent: '', modifiedContent: 'brand new\n' }) + + const [deletedPinned] = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: 'deleted.txt' + }) + expect(deletedPinned).toMatchObject({ originalContent: 'doomed\n', modifiedContent: '' }) + + const [binaryPinned] = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: 'binary-modified.bin' + }) + expect(binaryPinned).toMatchObject({ kind: 'binary' }) + }) + + it('holds the pinned revision when HEAD moves mid-review, where legacy drifts', async () => { + const compare = await branchCompare() + writeFileSync(path.join(repoPath, 'modified.txt'), 'drifted after the snapshot\n') + gitCommit(repoPath, 'drift') + + const pinned = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: 'modified.txt' + }) + const legacy = await branchDiff({ filePath: 'modified.txt' }) + + expect(pinned[0]).toMatchObject({ modifiedContent: 'after\n' }) + // The divergence is the fix: legacy silently re-resolves live HEAD. + expect(legacy[0]).toMatchObject({ modifiedContent: 'drifted after the snapshot\n' }) + }) +}) diff --git a/src/relay/git-handler-branch-diff-ops.ts b/src/relay/git-handler-branch-diff-ops.ts new file mode 100644 index 00000000000..1da0de82be8 --- /dev/null +++ b/src/relay/git-handler-branch-diff-ops.ts @@ -0,0 +1,57 @@ +import { buildDiffResult } from './git-diff-result' +import { readBlobAtOid, type GitBufferExec } from './git-handler-ops' + +const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + +function assertFullGitObjectId(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || !FULL_GIT_OBJECT_ID_PATTERN.test(value)) { + throw new Error(`${label} must be a full git object id`) + } +} + +export function isFullGitObjectId(value: unknown): value is string { + return typeof value === 'string' && FULL_GIT_OBJECT_ID_PATTERN.test(value) +} + +export function parseOptionalBranchDiffHeadOid( + params: Record +): string | undefined { + const { headOid } = params + // Why: GitBranchCompareSummary.headOid is `string | null`, so a mixed-version + // client can put an explicit null on the wire. Treat it as unpinned rather + // than rejecting a request the legacy path would have served. + if (headOid == null) { + return undefined + } + assertFullGitObjectId(headOid, 'headOid') + return headOid +} + +export async function branchDiffEntryAtPinnedOids( + gitBuffer: GitBufferExec, + worktreePath: string, + baseOid: string, + headOid: string, + filePath: string, + oldPath?: string +) { + assertFullGitObjectId(baseOid, 'baseRef') + assertFullGitObjectId(headOid, 'headOid') + try { + const [left, right] = await Promise.all([ + readBlobAtOid(gitBuffer, worktreePath, baseOid, oldPath ?? filePath), + readBlobAtOid(gitBuffer, worktreePath, headOid, filePath) + ]) + return [buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, filePath)] + } catch { + return [ + { + kind: 'text' as const, + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ] + } +} diff --git a/src/relay/git-handler-branch-diff.test.ts b/src/relay/git-handler-branch-diff.test.ts new file mode 100644 index 00000000000..ce60c8cd439 --- /dev/null +++ b/src/relay/git-handler-branch-diff.test.ts @@ -0,0 +1,392 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayContext } from './context' +import { + branchDiffEntryAtPinnedOids, + isFullGitObjectId, + parseOptionalBranchDiffHeadOid +} from './git-handler-branch-diff-ops' +import { GitHandler } from './git-handler' +import { branchDiffEntries, type GitBufferExec, type GitExec } from './git-handler-ops' +import { + createMockDispatcher, + gitCommit, + gitInit, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +const BASE_OID = 'a'.repeat(40) +const HEAD_OID = 'b'.repeat(40) +const OTHER_HEAD_OID = 'c'.repeat(40) +const MERGE_BASE_OID = 'd'.repeat(40) +const FILE_PATH = 'src/file.ts' + +type GitBufferTarget = { + gitBuffer(args: string[], cwd: string): Promise +} + +type GitTarget = { + git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> +} + +function deferredBuffer(): { promise: Promise; resolve: (content: string) => void } { + let resolve!: (value: Buffer) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve: (content) => resolve(Buffer.from(content)) } +} + +describe('pinned relay branch diff operation', () => { + it.each([ + { + name: 'addition', + status: `A\t${FILE_PATH}\n`, + left: new Error('missing'), + right: Buffer.from('added\n') + }, + { + name: 'deletion', + status: `D\t${FILE_PATH}\n`, + left: Buffer.from('deleted\n'), + right: new Error('missing') + }, + { + name: 'binary content', + status: `M\t${FILE_PATH}\n`, + left: Buffer.from([0, 1]), + right: Buffer.from([0, 2]) + }, + { + name: 'blob read failure', + status: `M\t${FILE_PATH}\n`, + left: new Error('left failed'), + right: new Error('right failed') + } + ])('matches the legacy $name result', async ({ status, left, right }) => { + const createGitBuffer = (): GitBufferExec => + vi.fn(async (args) => { + const value = args[2].startsWith(`${BASE_OID}:`) ? left : right + if (value instanceof Error) { + throw value + } + return value + }) + const git = vi.fn(async (args) => { + if (args[0] === 'rev-parse' && args.includes('HEAD')) { + return { stdout: `${HEAD_OID}\n`, stderr: '' } + } + if (args[0] === 'rev-parse' || args[0] === 'merge-base') { + return { stdout: `${BASE_OID}\n`, stderr: '' } + } + return { stdout: status, stderr: '' } + }) + + const pinned = await branchDiffEntryAtPinnedOids( + createGitBuffer(), + '/repo', + BASE_OID, + HEAD_OID, + FILE_PATH + ) + const legacy = await branchDiffEntries(git, createGitBuffer(), '/repo', BASE_OID, { + includePatch: true, + filePath: FILE_PATH + }) + + expect(pinned).toEqual(legacy) + }) + + // Why: GitBranchCompareSummary.headOid is `string | null`, so an unpinned + // snapshot arrives as an explicit null and must stay servable. + it.each([ + { name: 'absent', params: {} }, + { name: 'null', params: { headOid: null } }, + { name: 'undefined', params: { headOid: undefined } } + ])('treats a $name head OID as unpinned', ({ params }) => { + expect(parseOptionalBranchDiffHeadOid(params)).toBeUndefined() + }) + + it.each([ + { name: 'SHA-1', headOid: HEAD_OID }, + { name: 'SHA-256', headOid: 'b'.repeat(64) } + ])('returns a supplied $name head OID', ({ headOid }) => { + expect(parseOptionalBranchDiffHeadOid({ headOid })).toBe(headOid) + }) + + it.each([{ headOid: 'abc123' }, { headOid: '' }, { headOid: 'main' }, { headOid: 123 }])( + 'rejects the supplied malformed head OID $headOid', + ({ headOid }) => { + expect(() => parseOptionalBranchDiffHeadOid({ headOid })).toThrow( + 'headOid must be a full git object id' + ) + } + ) + + it('recognizes only full object ids', () => { + expect(isFullGitObjectId(BASE_OID)).toBe(true) + expect(isFullGitObjectId('A'.repeat(64))).toBe(true) + for (const value of ['origin/main', 'abc123', '', 'g'.repeat(40), null, undefined, 123, {}]) { + expect(isFullGitObjectId(value)).toBe(false) + } + }) + + // Why: the route guards this today, but the export is reachable on its own — + // a symbolic ref here would resolve `git show origin/main:` at live HEAD. + it('rejects a symbolic ref at the module boundary', async () => { + const gitBuffer = vi.fn(async () => Buffer.from('')) + await expect( + branchDiffEntryAtPinnedOids(gitBuffer, '/repo', 'origin/main', HEAD_OID, FILE_PATH) + ).rejects.toThrow('baseRef must be a full git object id') + await expect( + branchDiffEntryAtPinnedOids(gitBuffer, '/repo', BASE_OID, 'HEAD', FILE_PATH) + ).rejects.toThrow('headOid must be a full git object id') + expect(gitBuffer).not.toHaveBeenCalled() + }) +}) + +describe('GitHandler pinned branch diff route', () => { + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + dispatcher = createMockDispatcher() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + }) + + afterEach(() => { + handler.dispose() + vi.restoreAllMocks() + }) + + function request(overrides: Record = {}): Promise { + return dispatcher.callRequest('git.branchDiff', { + worktreePath: '/repo', + baseRef: BASE_OID, + headOid: HEAD_OID, + includePatch: true, + filePath: FILE_PATH, + ...overrides + }) + } + + function mockLegacyGit(nameStatus = '') { + return vi.spyOn(handler as unknown as GitTarget, 'git').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('HEAD')) { + return { stdout: `${HEAD_OID}\n`, stderr: '' } + } + if (args[0] === 'rev-parse') { + return { stdout: `${BASE_OID}\n`, stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: `${MERGE_BASE_OID}\n`, stderr: '' } + } + return { stdout: nameStatus, stderr: '' } + }) + } + + it('starts exactly two pinned blob reads concurrently without metadata commands', async () => { + const left = deferredBuffer() + const right = deferredBuffer() + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockImplementation((args) => { + if (args[2] === `${BASE_OID}:${FILE_PATH}`) { + return left.promise + } + if (args[2] === `${HEAD_OID}:${FILE_PATH}`) { + return right.promise + } + throw new Error(`unexpected blob spec: ${args[2]}`) + }) + const gitSpy = vi + .spyOn(handler as unknown as GitTarget, 'git') + .mockRejectedValue(new Error('metadata command should not run')) + + const resultPromise = request() + await vi.waitFor(() => expect(gitBufferSpy).toHaveBeenCalledTimes(2)) + + expect(gitSpy).not.toHaveBeenCalled() + expect(gitBufferSpy.mock.calls.map(([args]) => args)).toEqual([ + ['show', '--end-of-options', `${BASE_OID}:${FILE_PATH}`], + ['show', '--end-of-options', `${HEAD_OID}:${FILE_PATH}`] + ]) + + left.resolve('before\n') + right.resolve('after\n') + await expect(resultPromise).resolves.toEqual([ + { + kind: 'text', + originalContent: 'before\n', + modifiedContent: 'after\n', + originalIsBinary: false, + modifiedIsBinary: false + } + ]) + }) + + it('reads the old path only from the base OID', async () => { + const oldPath = 'src/old-file.ts' + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockImplementation(async (args) => Buffer.from(args[2].startsWith(BASE_OID) ? 'old' : 'new')) + + await request({ oldPath }) + + expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ + `${BASE_OID}:${oldPath}`, + `${HEAD_OID}:${FILE_PATH}` + ]) + }) + + it('coalesces the same head OID while keeping different heads independent', async () => { + const pending = deferredBuffer() + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockImplementation(() => pending.promise) + + const requests = [request(), request(), request({ headOid: OTHER_HEAD_OID })] + await vi.waitFor(() => expect(gitBufferSpy).toHaveBeenCalledTimes(4)) + + const specs = gitBufferSpy.mock.calls.map(([args]) => args[2]) + expect(specs.filter((spec) => spec === `${BASE_OID}:${FILE_PATH}`)).toHaveLength(2) + expect(specs.filter((spec) => spec === `${HEAD_OID}:${FILE_PATH}`)).toHaveLength(1) + expect(specs.filter((spec) => spec === `${OTHER_HEAD_OID}:${FILE_PATH}`)).toHaveLength(1) + + pending.resolve('content\n') + await Promise.all(requests) + }) + + it('rejects every supplied malformed head OID before running Git', async () => { + const gitBufferSpy = vi.spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + const invalidHeadOids = [7, {}, '', 'a'.repeat(39), 'g'.repeat(40)] + + for (const headOid of invalidHeadOids) { + await expect(request({ headOid })).rejects.toThrow('headOid must be a full git object id') + } + + expect(gitBufferSpy).not.toHaveBeenCalled() + }) + + it('accepts SHA-256 object IDs', async () => { + const baseOid = 'A'.repeat(64) + const headOid = 'b'.repeat(64) + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockResolvedValue(Buffer.from('content\n')) + + await request({ baseRef: baseOid, headOid }) + + expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ + `${baseOid}:${FILE_PATH}`, + `${headOid}:${FILE_PATH}` + ]) + }) + + // Why: a symbolic base ref used to select the pinned route and then throw. + it('selects the pinned route only when the base ref is a full object id', async () => { + const gitSpy = mockLegacyGit(`M\t${FILE_PATH}\n`) + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockResolvedValue(Buffer.from('content\n')) + + await expect(request({ baseRef: 'origin/main' })).resolves.toHaveLength(1) + + expect(gitSpy.mock.calls.map(([args]) => args.join(' '))).toEqual([ + 'rev-parse --verify HEAD', + 'rev-parse --verify origin/main', + `merge-base ${BASE_OID} ${HEAD_OID}`, + `-c core.quotePath=false diff --name-status -M -C ${MERGE_BASE_OID} ${HEAD_OID}` + ]) + expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ + `${MERGE_BASE_OID}:${FILE_PATH}`, + `${HEAD_OID}:${FILE_PATH}` + ]) + + gitSpy.mockClear() + gitBufferSpy.mockClear() + await request() + + expect(gitSpy).not.toHaveBeenCalled() + expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ + `${BASE_OID}:${FILE_PATH}`, + `${HEAD_OID}:${FILE_PATH}` + ]) + }) + + it('serves an explicitly null head OID through the legacy path', async () => { + const gitSpy = mockLegacyGit(`M\t${FILE_PATH}\n`) + const gitBufferSpy = vi.spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + + await expect(request({ headOid: null, includePatch: false })).resolves.toEqual([ + { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ]) + + expect(gitSpy).toHaveBeenCalledTimes(4) + expect(gitBufferSpy).not.toHaveBeenCalled() + }) + + it('uses the legacy path when patch or file-path conditions are not met', async () => { + const gitSpy = mockLegacyGit() + const gitBufferSpy = vi.spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + + await Promise.all([ + request({ baseRef: 'main', includePatch: false }), + request({ baseRef: 'main', filePath: '' }), + request({ baseRef: 'main', filePath: { malformed: true } }) + ]) + + expect(gitSpy).toHaveBeenCalledTimes(12) + expect(gitBufferSpy).not.toHaveBeenCalled() + }) + + it('keeps the requested head pinned after repository HEAD moves', async () => { + const repoPath = mkdtempSync(path.join(tmpdir(), 'relay-pinned-branch-diff-')) + try { + gitInit(repoPath) + writeFileSync(path.join(repoPath, 'file.txt'), 'base\n') + gitCommit(repoPath, 'base') + const baseOid = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoPath, + encoding: 'utf8' + }).trim() + + writeFileSync(path.join(repoPath, 'file.txt'), 'pinned\n') + gitCommit(repoPath, 'pinned') + const pinnedHeadOid = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoPath, + encoding: 'utf8' + }).trim() + + writeFileSync(path.join(repoPath, 'file.txt'), 'moved\n') + gitCommit(repoPath, 'moved') + + const result = (await request({ + worktreePath: repoPath, + baseRef: baseOid, + headOid: pinnedHeadOid, + filePath: 'file.txt' + })) as { originalContent: string; modifiedContent: string }[] + + expect(result[0]).toMatchObject({ + originalContent: 'base\n', + modifiedContent: 'pinned\n' + }) + expect( + execFileSync('git', ['show', 'HEAD:file.txt'], { cwd: repoPath, encoding: 'utf8' }) + ).toBe('moved\n') + } finally { + rmSync(repoPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 60ea5913139..a9d0b63ea8a 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -91,7 +91,36 @@ export async function getStatusOp( ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) - const conflictOperation = await detectConflictOperation(worktreePath) + const conflictPromise = detectConflictOperation(worktreePath) + // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. + const statusArgs = [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ] + if (includeIgnored) { + statusArgs.push('--ignored=matching') + } + // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + const statusSettlementPromise = Promise.allSettled([ + (async () => { + const parser = new StatusPorcelainParser() + const result = await streamGit(statusArgs, worktreePath, { + // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. + disableOptionalLocks: true, + signal: options.signal, + onStdout: (chunk) => parser.update(chunk, limit) + }) + if (!result.stoppedEarly) { + parser.finish() + } + return { parser, stoppedEarly: result.stoppedEarly } + })() + ]) + const conflictOperation = await conflictPromise const entries: Record[] = [] let head: string | undefined let branch: string | undefined @@ -103,28 +132,11 @@ export async function getStatusOp( let branchLineTotal: GitBranchLineTotal | undefined try { - // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. - const statusArgs = [ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ] - if (includeIgnored) { - statusArgs.push('--ignored=matching') - } - const parser = new StatusPorcelainParser() - const { stoppedEarly } = await streamGit(statusArgs, worktreePath, { - // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. - disableOptionalLocks: true, - signal: options.signal, - onStdout: (chunk) => parser.update(chunk, limit) - }) - if (!stoppedEarly) { - parser.finish() + const [statusResult] = await statusSettlementPromise + if (statusResult.status === 'rejected') { + throw statusResult.reason } + const { parser, stoppedEarly } = statusResult.value head = parser.branch.head branch = parser.branch.branch ignoredPaths = parser.ignoredPaths diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index e1c18a78d33..43f1d53d5d1 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { parseStatusOutput } from './git-status-output-parser' import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' describe('parseWorktreeList', () => { @@ -105,72 +104,3 @@ describe('isUnsupportedWorktreeListZError', () => { expect(isUnsupportedWorktreeListZError(error)).toBe(false) }) }) - -describe('parseStatusOutput', () => { - it('parses upstream ahead/behind from porcelain v2 branch headers', () => { - const result = parseStatusOutput( - [ - '# branch.oid abcdef1234567890', - '# branch.head feature/prompts', - '# branch.upstream origin/feature/prompts', - '# branch.ab +2 -3', - '' - ].join('\n') - ) - - expect(result.upstreamStatus).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature/prompts', - ahead: 2, - behind: 3 - }) - }) - - it('reports no upstream when porcelain v2 omits branch.upstream', () => { - const result = parseStatusOutput( - ['# branch.oid abcdef1234567890', '# branch.head feature/prompts', ''].join('\n') - ) - - expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) - }) - - it('parses ignored porcelain records separately from actionable entries', () => { - const result = parseStatusOutput(['! dist/', '! .env', '? scratch.txt', ''].join('\n')) - - expect(result.ignoredPaths).toEqual(['dist/', '.env']) - expect(result.entries).toEqual([ - { path: 'scratch.txt', status: 'untracked', area: 'untracked' } - ]) - }) - - it('parses rename records with spaces in the paths', () => { - const result = parseStatusOutput( - '2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n' - ) - - expect(result.entries).toEqual([ - { path: 'src/new name.ts', oldPath: 'src/old name.ts', status: 'renamed', area: 'staged' } - ]) - }) - - it('parses submodule dirtiness flags from porcelain records', () => { - const result = parseStatusOutput( - '1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n' - ) - - expect(result.entries).toEqual([ - { - path: 'nested-repo', - status: 'added', - area: 'staged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - }, - { - path: 'nested-repo', - status: 'modified', - area: 'unstaged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - } - ]) - }) -}) diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index ab3bb61faba..25885cfe4dc 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -106,7 +106,7 @@ export function parseUnmergedEntry( */ export function parseBranchDiff( stdout: string, - statsByPath: Map = new Map() + statsByPath = new Map() ): Record[] { const entries: Record[] = [] for (const line of stdout.split(/\r?\n/)) { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 0abc9f5d5be..3e8a20ea992 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -18,6 +18,11 @@ import { validateGitExecArgs, type GitExec } from './git-handler-ops' +import { + branchDiffEntryAtPinnedOids, + isFullGitObjectId, + parseOptionalBranchDiffHeadOid +} from './git-handler-branch-diff-ops' import { buildSubmoduleInnerCommitRangeDiff, computeSubmodulePointerDiff, @@ -1148,6 +1153,7 @@ export class GitHandler { if (baseRef.startsWith('-')) { throw new Error('Base ref must not start with "-"') } + const headOid = parseOptionalBranchDiffHeadOid(params) const options = { includePatch: params.includePatch as boolean | undefined, filePath: params.filePath as string | undefined, @@ -1158,18 +1164,36 @@ export class GitHandler { 'branchDiff', worktreePath, baseRef, + headOid ?? null, options.includePatch ?? null, options.filePath ?? null, options.oldPath ?? null ]), - () => - branchDiffEntries( + () => { + if ( + headOid && + isFullGitObjectId(baseRef) && + options.includePatch === true && + typeof options.filePath === 'string' && + options.filePath.length > 0 + ) { + return branchDiffEntryAtPinnedOids( + this.gitBuffer.bind(this), + worktreePath, + baseRef, + headOid, + options.filePath, + options.oldPath + ) + } + return branchDiffEntries( this.git.bind(this), this.gitBuffer.bind(this), worktreePath, baseRef, options ) + } ) return this.maybeStreamResponse(result, params, context) } diff --git a/src/relay/git-status-output-parser.test.ts b/src/relay/git-status-output-parser.test.ts deleted file mode 100644 index ec25e9b72ba..00000000000 --- a/src/relay/git-status-output-parser.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseStatusOutput } from './git-status-output-parser' - -describe('parseStatusOutput', () => { - it('marks staged S... submodule rows as commit-changed gitlinks', () => { - const parsed = parseStatusOutput( - '1 M. S... 160000 160000 160000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb flutter_mine\n' - ) - - expect(parsed.entries).toEqual([ - { - path: 'flutter_mine', - status: 'modified', - area: 'staged', - submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false } - } - ]) - }) -}) diff --git a/src/relay/git-status-output-parser.ts b/src/relay/git-status-output-parser.ts deleted file mode 100644 index 9bb1d344073..00000000000 --- a/src/relay/git-status-output-parser.ts +++ /dev/null @@ -1,177 +0,0 @@ -export function parseStatusChar(char: string): string { - switch (char) { - case 'M': - return 'modified' - case 'A': - return 'added' - case 'D': - return 'deleted' - case 'R': - return 'renamed' - case 'C': - return 'copied' - default: - return 'modified' - } -} - -/** - * Parse `git status --porcelain=v2` output into structured entries. - * Does NOT handle unmerged entries (those require worktree access). - */ -export function parseStatusOutput(stdout: string): { - entries: Record[] - unmergedLines: string[] - ignoredPaths: string[] - head?: string - branch?: string - upstreamStatus: { - hasUpstream: boolean - upstreamName?: string - ahead: number - behind: number - } -} { - const entries: Record[] = [] - const unmergedLines: string[] = [] - const ignoredPaths: string[] = [] - let head: string | undefined - let branch: string | undefined - let upstreamName: string | undefined - let upstreamAheadBehind: { ahead: number; behind: number } | null = null - - for (const line of stdout.split(/\r?\n/)) { - if (!line) { - continue - } - - if (line.startsWith('# branch.oid ')) { - head = line.slice('# branch.oid '.length).trim() - continue - } - - if (line.startsWith('# branch.head ')) { - const branchHead = line.slice('# branch.head '.length).trim() - branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : '' - continue - } - - if (line.startsWith('# branch.upstream ')) { - upstreamName = line.slice('# branch.upstream '.length).trim() || undefined - continue - } - - if (line.startsWith('# branch.ab ')) { - upstreamAheadBehind = parseBranchAheadBehind(line) - continue - } - - if (line.startsWith('1 ') || line.startsWith('2 ')) { - const parts = line.split(' ') - const xy = parts[1] - const indexStatus = xy[0] - const worktreeStatus = xy[1] - - if (line.startsWith('2 ')) { - // Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`. - // The new path starts after 9 fixed fields and can contain spaces; origPath follows the tab. - const tabParts = line.split('\t') - const filePath = tabParts[0].split(' ').slice(9).join(' ') - const oldPath = tabParts.slice(1).join('\t') - if (indexStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(indexStatus), - area: 'staged', - oldPath, - ...submoduleStatusField(parts[2], indexStatus) - }) - } - if (worktreeStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(worktreeStatus), - area: 'unstaged', - oldPath, - ...submoduleStatusField(parts[2], worktreeStatus) - }) - } - } else { - const filePath = parts.slice(8).join(' ') - if (indexStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(indexStatus), - area: 'staged', - ...submoduleStatusField(parts[2], indexStatus) - }) - } - if (worktreeStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(worktreeStatus), - area: 'unstaged', - ...submoduleStatusField(parts[2], worktreeStatus) - }) - } - } - } else if (line.startsWith('? ')) { - entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' }) - } else if (line.startsWith('! ')) { - ignoredPaths.push(line.slice(2)) - } else if (line.startsWith('u ')) { - unmergedLines.push(line) - } - } - - return { - entries, - unmergedLines, - ignoredPaths, - head, - branch, - upstreamStatus: upstreamName - ? { - hasUpstream: true, - upstreamName, - ahead: upstreamAheadBehind?.ahead ?? 0, - behind: upstreamAheadBehind?.behind ?? 0 - } - : { hasUpstream: false, ahead: 0, behind: 0 } - } -} - -function parseSubmoduleStatus( - submoduleField: string | undefined, - statusChar = '.' -): { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } | undefined { - if (!submoduleField?.startsWith('S')) { - return undefined - } - return { - commitChanged: submoduleField[1] === 'C' || (submoduleField === 'S...' && statusChar === 'M'), - trackedChanges: submoduleField[2] === 'M', - untrackedChanges: submoduleField[3] === 'U' - } -} - -function submoduleStatusField( - submoduleField: string | undefined, - statusChar: string -): - | { submodule: { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } } - | {} { - const submodule = parseSubmoduleStatus(submoduleField, statusChar) - return submodule ? { submodule } : {} -} - -function parseBranchAheadBehind(line: string): { ahead: number; behind: number } | null { - const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/) - if (!match) { - return null - } - return { - ahead: Number.parseInt(match[1], 10), - behind: Number.parseInt(match[2], 10) - } -} diff --git a/src/relay/plugin-overlay-env.test.ts b/src/relay/plugin-overlay-env.test.ts index 78e08918297..7746307cf47 100644 --- a/src/relay/plugin-overlay-env.test.ts +++ b/src/relay/plugin-overlay-env.test.ts @@ -104,4 +104,31 @@ describe('plugin overlay env source resolution', () => { ) ).toBe('/remote/original-prime') }) + + // Why: the session env is the only place a fish user's XDG_CONFIG_HOME shows up + // (config.fish exports it, so no GUI-launched process inherits it). Dropping it + // here would scan ~/.config and disagree with the same lookup on the main side. + it.skipIf(process.platform === 'win32')( + 'reads fish config under the session XDG_CONFIG_HOME', + () => { + const configHome = join(homeDir, 'xdg') + mkdirSync(join(configHome, 'fish'), { recursive: true }) + writeFileSync( + join(configHome, 'fish', 'config.fish'), + 'set -gx OPENCODE_CONFIG_DIR "$HOME/company-opencode"\n' + ) + mkdirSync(join(homeDir, '.config', 'fish'), { recursive: true }) + writeFileSync( + join(homeDir, '.config', 'fish', 'config.fish'), + 'set -gx OPENCODE_CONFIG_DIR /wrong-default-config-home\n' + ) + + expect( + resolveOpenCodeSourceConfigDir( + { HOME: homeDir, XDG_CONFIG_HOME: configHome }, + '/opt/homebrew/bin/fish' + ) + ).toBe(join(homeDir, 'company-opencode')) + } + ) }) diff --git a/src/relay/plugin-overlay-env.ts b/src/relay/plugin-overlay-env.ts index 4b91da8ff62..1db43f4717e 100644 --- a/src/relay/plugin-overlay-env.ts +++ b/src/relay/plugin-overlay-env.ts @@ -1,4 +1,4 @@ -import { readShellStartupEnvVar } from '../main/pty/shell-startup-env' +import { readSessionShellStartupEnvVar } from '../main/pty/shell-startup-env' import { PRIMARY_AGENT_DIR_ENV_BY_KIND, SOURCE_AGENT_DIR_ENV_BY_KIND, @@ -14,7 +14,9 @@ function readStartupEnv( env: Record, shell: string | undefined ): string | undefined { - return readShellStartupEnvVar(name, env.HOME ?? process.env.HOME, shell ?? env.SHELL) + // Why the session env first: it is closer to the user's shell than the relay + // process env, and fish config lives under its XDG_CONFIG_HOME. + return readSessionShellStartupEnvVar(name, env, shell) } export function resolveOpenCodeSourceConfigDir( diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 6ff480ef91a..ffb9116beae 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -4,11 +4,11 @@ // renderer are meaningless on SSH targets, so the relay performs the remote // filesystem work itself. // -// Plugin source strings ship over the JSON-RPC channel at session-ready -// (commit #7) — they are NOT bundled with the relay binary because the -// relay is versioned independently from Orca and the plugin source changes -// frequently as new agent events get added (see docs/design/agent-status- -// over-ssh.md §4 "Why ship the plugin source over the wire"). +// Plugin source strings ship over the JSON-RPC channel at session-ready — +// they are NOT bundled with the relay binary because the relay is versioned +// independently from Orca and the plugin source changes frequently as new +// agent events get added; bundling would make every such change a relay +// redeploy, and an old relay would silently serve stale plugin code. // // We deliberately do not reuse OpenCodeHookService / PiTitlebarExtensionService // directly: those modules import `electron` and ride on Orca's userData diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index 9df947b1aad..4ed3bce6ecd 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -1,7 +1,155 @@ -import { describe, expect, it, vi } from 'vitest' -import { parseHexAddress } from './port-scan-handler' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MethodHandler, RequestContext } from './dispatcher' + +const { readFileMock, readdirMock, readlinkMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + readdirMock: vi.fn(), + readlinkMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + readdir: readdirMock, + readlink: readlinkMock +})) + +import { parseHexAddress, PortScanHandler } from './port-scan-handler' import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan' +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) +}) + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +function capturePortDetectHandler(): MethodHandler { + let handler: MethodHandler | undefined + new PortScanHandler({ + onRequest: (method, nextHandler) => { + expect(method).toBe('ports.detect') + handler = nextHandler + } + }) + if (!handler) { + throw new Error('ports.detect handler was not registered') + } + return handler +} + +function requestContext(signal?: AbortSignal): RequestContext { + return { clientId: 1, isStale: () => signal?.aborted ?? false, signal } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve = (_value: T): void => { + throw new Error('deferred promise was not initialized') + } + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + +function mockLinuxProcScan({ + pidCount, + fdCount, + firstReadlink +}: { + pidCount: number + fdCount: number + firstReadlink?: Promise +}): void { + const tcpHeader = + 'sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode' + const tcpRow = + '0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 11111' + readFileMock.mockImplementation(async (path: string) => { + if (path === '/proc/net/tcp') { + return `${tcpHeader}\n${tcpRow}\n` + } + if (path === '/proc/net/tcp6') { + return `${tcpHeader}\n` + } + if (path.endsWith('/cmdline')) { + return '/usr/bin/node\0server.js' + } + throw new Error(`unexpected readFile: ${path}`) + }) + + const pids = Array.from({ length: pidCount }, (_, index) => String(1_000 + index)) + const fds = Array.from({ length: fdCount }, (_, index) => String(index)) + readdirMock.mockImplementation(async (path: string) => { + if (path === '/proc') { + return pids + } + if (path.endsWith('/fd')) { + return fds + } + throw new Error(`unexpected readdir: ${path}`) + }) + + let first = true + readlinkMock.mockImplementation(() => { + if (first && firstReadlink) { + first = false + return firstReadlink + } + first = false + return Promise.resolve('socket:[11111]') + }) +} + +describe('PortScanHandler Linux cancellation', () => { + it('does not touch procfs for an already-cancelled request', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + capturePortDetectHandler()({}, requestContext(controller.signal)) + ).rejects.toMatchObject({ name: 'AbortError' }) + + expect(readFileMock).not.toHaveBeenCalled() + expect(readdirMock).not.toHaveBeenCalled() + expect(readlinkMock).not.toHaveBeenCalled() + }) + + it('stops a large pid and fd walk at the filesystem operation already in flight', async () => { + const firstReadlink = createDeferred() + mockLinuxProcScan({ pidCount: 1_000, fdCount: 100, firstReadlink: firstReadlink.promise }) + const controller = new AbortController() + const scan = capturePortDetectHandler()({}, requestContext(controller.signal)) + + await vi.waitFor(() => expect(readlinkMock).toHaveBeenCalledTimes(1)) + controller.abort() + firstReadlink.resolve('socket:[11111]') + + await expect(scan).rejects.toMatchObject({ name: 'AbortError' }) + expect(readdirMock).toHaveBeenCalledTimes(2) + expect(readdirMock).toHaveBeenNthCalledWith(1, '/proc') + expect(readdirMock).toHaveBeenNthCalledWith(2, '/proc/1000/fd') + expect(readlinkMock).toHaveBeenCalledTimes(1) + expect(readlinkMock).toHaveBeenCalledWith('/proc/1000/fd/0') + }) + + it('preserves detected port results when the request stays live', async () => { + mockLinuxProcScan({ pidCount: 1, fdCount: 1 }) + + await expect( + capturePortDetectHandler()({}, requestContext(new AbortController().signal)) + ).resolves.toEqual({ + ports: [{ host: '127.0.0.1', port: 3000, pid: 1_000, processName: 'node' }], + platform: 'linux' + }) + }) +}) + describe('parseHexAddress', () => { it('parses IPv4 localhost (127.0.0.1)', () => { // 127.0.0.1 in little-endian hex: 0100007F diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts index a576d8921fa..40150133a04 100644 --- a/src/relay/port-scan-handler.ts +++ b/src/relay/port-scan-handler.ts @@ -16,11 +16,11 @@ const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) const MAX_DETECTED_PORTS = 50 export class PortScanHandler { - constructor(dispatcher: RelayDispatcher) { + constructor(dispatcher: Pick) { dispatcher.onRequest('ports.detect', async (_params, context: RequestContext) => { if (process.platform === 'linux') { return { - ports: await this.scanLinuxListeningPorts(), + ports: await this.scanLinuxListeningPorts(context.signal), platform: process.platform } } @@ -37,11 +37,13 @@ export class PortScanHandler { }) } - private async scanLinuxListeningPorts(): Promise { + private async scanLinuxListeningPorts(signal?: AbortSignal): Promise { + signal?.throwIfAborted() const [tcp4, tcp6] = await Promise.all([ - this.readProcNet('/proc/net/tcp'), - this.readProcNet('/proc/net/tcp6') + this.readProcNet('/proc/net/tcp', signal), + this.readProcNet('/proc/net/tcp6', signal) ]) + signal?.throwIfAborted() const listeningSockets = [...tcp4, ...tcp6] if (listeningSockets.length === 0) { @@ -49,7 +51,7 @@ export class PortScanHandler { } const inodeSet = new Set(listeningSockets.map((s) => s.inode)) - const inodeToPid = await this.mapInodesToPids(inodeSet) + const inodeToPid = await this.mapInodesToPids(inodeSet, signal) const seen = new Set() const results: DetectedPort[] = [] @@ -57,6 +59,7 @@ export class PortScanHandler { const relayParentPid = process.ppid for (const socket of listeningSockets) { + signal?.throwIfAborted() const key = `${socket.host}:${socket.port}` if (seen.has(key)) { continue @@ -72,7 +75,7 @@ export class PortScanHandler { continue } - const processName = pid != null ? await this.getProcessName(pid) : undefined + const processName = pid != null ? await this.getProcessName(pid, signal) : undefined if (processName === 'sshd') { continue @@ -93,14 +96,18 @@ export class PortScanHandler { } private async readProcNet( - path: string + path: string, + signal?: AbortSignal ): Promise<{ port: number; host: string; inode: number }[]> { + signal?.throwIfAborted() let content: string try { content = await readFile(path, 'utf-8') } catch { + signal?.throwIfAborted() return [] } + signal?.throwIfAborted() const lines = content.split('\n') const results: { port: number; host: string; inode: number }[] = [] @@ -133,7 +140,11 @@ export class PortScanHandler { return results } - private async mapInodesToPids(inodes: Set): Promise> { + private async mapInodesToPids( + inodes: Set, + signal?: AbortSignal + ): Promise> { + signal?.throwIfAborted() const result = new Map() if (inodes.size === 0) { return result @@ -143,27 +154,35 @@ export class PortScanHandler { try { pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name)) } catch { + signal?.throwIfAborted() return result } + signal?.throwIfAborted() for (const pidStr of pids) { + signal?.throwIfAborted() const fdDir = `/proc/${pidStr}/fd` let fds: string[] try { fds = await readdir(fdDir) } catch { + signal?.throwIfAborted() continue } + signal?.throwIfAborted() const pid = Number.parseInt(pidStr, 10) for (const fd of fds) { + signal?.throwIfAborted() let link: string try { link = await readlink(`${fdDir}/${fd}`) } catch { + signal?.throwIfAborted() continue } + signal?.throwIfAborted() const match = link.match(/^socket:\[(\d+)\]$/) if (!match) { @@ -180,9 +199,11 @@ export class PortScanHandler { return result } - private async getProcessName(pid: number): Promise { + private async getProcessName(pid: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted() try { const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf-8') + signal?.throwIfAborted() if (!cmdline) { return undefined } @@ -195,6 +216,7 @@ export class PortScanHandler { const parts = exe.split('/') return parts.at(-1) } catch { + signal?.throwIfAborted() return undefined } } diff --git a/src/relay/protocol-json-payload.test.ts b/src/relay/protocol-json-payload.test.ts new file mode 100644 index 00000000000..3abdb47058e --- /dev/null +++ b/src/relay/protocol-json-payload.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import * as mainProtocol from '../main/ssh/relay-protocol' +import { + HEADER_LENGTH, + MAX_MESSAGE_SIZE, + MessageType, + encodeFrame, + encodeJsonRpcFrame, + encodePreparedJsonRpcFrame, + prepareJsonRpcPayload, + type JsonRpcNotification +} from './protocol' + +describe('prepared relay JSON payload framing', () => { + it('is byte-equivalent to direct composition for every header field', () => { + const message: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-1', data: 'héllo "𝄞"\\\n\uD800', seq: 42 } + } + const payload = Buffer.from(JSON.stringify(message), 'utf8') + const relayPrepared = prepareJsonRpcPayload(message) + const mainPrepared = mainProtocol.prepareJsonRpcPayload(message) + + for (const [id, ack] of [ + [0, 0], + [19, 7], + [0xffffffff, 0xfffffffe] + ]) { + const reference = encodeFrame(MessageType.Regular, id, ack, payload) + expect(encodePreparedJsonRpcFrame(relayPrepared, id, ack).equals(reference)).toBe(true) + expect(encodeJsonRpcFrame(message, id, ack).equals(reference)).toBe(true) + expect(mainProtocol.encodePreparedJsonRpcFrame(mainPrepared, id, ack).equals(reference)).toBe( + true + ) + expect(mainProtocol.encodeJsonRpcFrame(message, id, ack).equals(reference)).toBe(true) + } + }) + + it('accepts the exact payload maximum and rejects one byte more', () => { + const base: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'x', + params: { data: '' } + } + const overhead = Buffer.byteLength(JSON.stringify(base)) + const exact = { ...base, params: { data: 'a'.repeat(MAX_MESSAGE_SIZE - overhead) } } + const oversized = { ...base, params: { data: 'a'.repeat(MAX_MESSAGE_SIZE - overhead + 1) } } + + const relayPrepared = prepareJsonRpcPayload(exact) + const mainPrepared = mainProtocol.prepareJsonRpcPayload(exact) + expect(relayPrepared.byteLength).toBe(MAX_MESSAGE_SIZE) + expect(mainPrepared.byteLength).toBe(MAX_MESSAGE_SIZE) + expect(encodePreparedJsonRpcFrame(relayPrepared, 1, 0)).toHaveLength( + HEADER_LENGTH + MAX_MESSAGE_SIZE + ) + expect(() => prepareJsonRpcPayload(oversized)).toThrow('Message too large') + expect(() => mainProtocol.prepareJsonRpcPayload(oversized)).toThrow('Message too large') + expect(() => encodeJsonRpcFrame(oversized, 1, 0)).toThrow('Message too large') + expect(() => mainProtocol.encodeJsonRpcFrame(oversized, 1, 0)).toThrow('Message too large') + }) +}) diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 8cb4ab57a75..25e158dc1e2 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -130,6 +130,13 @@ export type JsonRpcNotification = { export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification +const JSON_RPC_PAYLOAD_BYTES = Symbol('jsonRpcPayloadBytes') + +export type PreparedJsonRpcPayload = Readonly<{ + byteLength: number + [JSON_RPC_PAYLOAD_BYTES]: Buffer +}> + export function encodeFrame( type: number, id: number, @@ -145,11 +152,23 @@ export function encodeFrame( } export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer { + return encodePreparedJsonRpcFrame(prepareJsonRpcPayload(msg), id, ack) +} + +export function prepareJsonRpcPayload(msg: JsonRpcMessage): PreparedJsonRpcPayload { const payload = Buffer.from(JSON.stringify(msg), 'utf-8') if (payload.length > MAX_MESSAGE_SIZE) { throw new Error(`Message too large: ${payload.length} bytes`) } - return encodeFrame(MessageType.Regular, id, ack, payload) + return Object.freeze({ byteLength: payload.length, [JSON_RPC_PAYLOAD_BYTES]: payload }) +} + +export function encodePreparedJsonRpcFrame( + payload: PreparedJsonRpcPayload, + id: number, + ack: number +): Buffer { + return encodeFrame(MessageType.Regular, id, ack, payload[JSON_RPC_PAYLOAD_BYTES]) } export function encodeKeepAliveFrame(id: number, ack: number): Buffer { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 35d11461c53..8ddf1e67a12 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -12,8 +12,9 @@ import { } from '../shared/setup-agent-sequencing' import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' -const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ +const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), mockPtyInstance: { // Why: attach now proves the backing pid is alive before replaying, so the // default managed PTY must report a live pid. Reuse the test runner's own @@ -38,6 +39,10 @@ vi.mock('../main/pty/posix-pty-process-groups', () => ({ forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) })) +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe +})) + import { IMMEDIATE_PTY_EXIT_TIMEOUT_MS, MAX_RELAY_PTY_SESSIONS, @@ -139,6 +144,11 @@ describe('PtyHandler', () => { mockPtyInstance.clear.mockReset() mockPtyInstance.pause.mockReset() mockPtyInstance.resume.mockReset() + mockCreateShellPromptReadinessProbe.mockReset() + mockCreateShellPromptReadinessProbe.mockReturnValue({ + notifyOutput: vi.fn(), + dispose: vi.fn() + }) vi.spyOn(ptyShellUtils, 'processHasChildren').mockResolvedValue(false) mockPtySpawn.mockReturnValue({ ...mockPtyInstance }) @@ -829,6 +839,7 @@ describe('PtyHandler', () => { vi.advanceTimersByTime(49) const term = mockPtySpawn.mock.results[0]?.value expect(handler.retainedStartupCommandCount).toBe(1) + expect(handler.retainedStartupCommandBytes).toBe('echo provider-owned'.length) expect(term.write).not.toHaveBeenCalled() vi.advanceTimersByTime(1) @@ -870,6 +881,9 @@ describe('PtyHandler', () => { | { env?: Record } | undefined expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(handler.retainedStartupCommandCount).toBe(1) + expect(handler.retainedStartupCommandBytes).toBe(0) + vi.advanceTimersByTime(15_000) expect(handler.retainedStartupCommandCount).toBe(0) } ) @@ -906,7 +920,7 @@ describe('PtyHandler', () => { | { env?: Record } | undefined expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') - expect(handler.retainedStartupCommandCount).toBe(0) + expect(handler.retainedStartupCommandCount).toBe(1) } ) @@ -1043,6 +1057,224 @@ describe('PtyHandler', () => { } ) + it.skipIf(process.platform === 'win32')( + 'recovers provider delivery when startup exec replaces the relay wrapper', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-exec-spawn-')) + const oldShell = process.env.SHELL + process.env.SHELL = '/bin/bash' + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo after-exec', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + rmSync(homeDir, { recursive: true, force: true }) + } + + dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `) + await vi.advanceTimersByTimeAsync(8) + + const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as { + onPromptReady: () => void + } + expect( + mockCreateShellPromptReadinessProbe.mock.results[0]?.value.notifyOutput + ).toHaveBeenCalledWith('\x1b[?2004hremote $ ') + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b[?2004hremote $ ' + }) + promptOptions.onPromptReady() + await vi.advanceTimersByTimeAsync(50) + + expect(term.write).toHaveBeenCalledWith('echo after-exec\n') + expect(handler.retainedStartupCommandCount).toBe(0) + } + ) + + it.skipIf(process.platform === 'win32')( + 'signals renderer delivery when startup exec replaces the relay wrapper', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-exec-spawn-')) + const oldShell = process.env.SHELL + process.env.SHELL = '/bin/bash' + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo after-exec', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + rmSync(homeDir, { recursive: true, force: true }) + } + + dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `) + await vi.advanceTimersByTimeAsync(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b[?2004hremote $ ' + }) + + const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as { + onPromptReady: () => void + } + promptOptions.onPromptReady() + await vi.advanceTimersByTimeAsync(8) + + expect(term.write).not.toHaveBeenCalled() + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b]777;orca-shell-ready\x07' + }) + expect(handler.retainedStartupCommandCount).toBe(0) + } + ) + + it.skipIf(process.platform === 'win32')( + 'forwards the supported ready marker to renderer delivery', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-ready-spawn-')) + const oldShell = process.env.SHELL + process.env.SHELL = '/bin/bash' + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo after-ready', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + rmSync(homeDir, { recursive: true, force: true }) + } + + dataCallback?.( + `\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready\x07remote $ ` + ) + await vi.advanceTimersByTimeAsync(8) + + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b]777;orca-shell-ready\x07remote $ ' + }) + expect(handler.retainedStartupCommandCount).toBe(0) + } + ) + + it.skipIf(process.platform === 'win32')( + 'releases split renderer readiness through one completed path', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-split-ready-spawn-')) + const oldShell = process.env.SHELL + process.env.SHELL = '/bin/bash' + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo after-ready', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + rmSync(homeDir, { recursive: true, force: true }) + } + + dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready`) + dataCallback?.('\x07remote $ ') + await vi.advanceTimersByTimeAsync(8) + + const probe = mockCreateShellPromptReadinessProbe.mock.results[0]?.value + expect(probe.notifyOutput).not.toHaveBeenCalled() + expect(probe.dispose).toHaveBeenCalledOnce() + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b]777;orca-shell-ready\x07remote $ ' + }) + expect(handler.retainedStartupCommandCount).toBe(0) + } + ) + + it.skipIf(process.platform === 'win32')( + 'does not retain renderer readiness state for unsupported shells', + async () => { + const oldShell = process.env.SHELL + process.env.SHELL = '/bin/sh' + try { + await dispatcher.callRequest('pty.spawn', { + command: 'x'.repeat(256 * 1024), + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + } + + const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as + | { env?: Record } + | undefined + expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('0') + expect(handler.retainedStartupCommandCount).toBe(0) + } + ) + it.skipIf(process.platform === 'win32')( 'flushes held shell-ready marker bytes when provider delivery falls back', async () => { @@ -1142,6 +1374,50 @@ describe('PtyHandler', () => { ).rejects.toThrow('PTY "pty-1" not found') }) + it.skipIf(process.platform === 'win32')( + 'releases renderer readiness state when attach reaps a dead shell', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-dead-shell-ready-spawn-')) + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'x'.repeat(256 * 1024), + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + expect(handler.retainedStartupCommandCount).toBe(1) + + const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) + try { + await expect(dispatcher.callRequest('pty.attach', { id: 'pty-1' })).rejects.toThrow( + 'PTY "pty-1" not found' + ) + } finally { + aliveSpy.mockRestore() + } + + expect(handler.retainedStartupCommandCount).toBe(0) + vi.advanceTimersByTime(15_000) + expect(handler.retainedStartupCommandCount).toBe(0) + } + ) + it('settles concurrent immediate shutdown when attach proves the shell exited', async () => { const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ @@ -2734,9 +3010,16 @@ describe('PtyHandler', () => { ORCA_AGENT_HOOK_TOKEN: 'abc-uuid' })) const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + const oldStartupIdentity = process.env.ORCA_SHELL_STARTUP_IDENTITY + process.env.ORCA_SHELL_STARTUP_IDENTITY = '1' try { await dispatcher.callRequest('pty.revive', { state }) } finally { + if (oldStartupIdentity === undefined) { + delete process.env.ORCA_SHELL_STARTUP_IDENTITY + } else { + process.env.ORCA_SHELL_STARTUP_IDENTITY = oldStartupIdentity + } killSpy.mockRestore() } @@ -2749,6 +3032,8 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid') expect(callArgs.env.TERM).toBe('xterm-256color') expect(callArgs.env.TERM_PROGRAM).toBe('Orca') + expect(callArgs.env.ORCA_SHELL_READY_MARKER).toBe('0') + expect(callArgs.env.ORCA_SHELL_STARTUP_IDENTITY).toBe('0') }) it('fences both revived worktree identity and cwd with rollback', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 9471b2a3b9f..6dd3cd8a7fc 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -31,8 +31,19 @@ import { createShellReadyScanState, drainShellReadyHeldBytes, scanForShellReady, + SHELL_READY_MARKER_PREFIX, type ShellReadyScanState } from '../main/shell-ready-marker-scanner' +import { + createShellStartupIdentityScanState, + drainShellStartupIdentityHeldBytes, + scanForShellStartupIdentity, + type ShellStartupIdentityScanState +} from '../main/shell-startup-identity-scanner' +import { + createShellPromptReadinessProbe, + type ShellPromptReadinessProbe +} from '../main/shell-prompt-readiness-probe' import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' import { gitCredentialPromptGuardEnv, @@ -147,6 +158,9 @@ type ManagedPty = { worktreeId?: string terminalHandle?: string explicitTerm?: string + shellPath?: string + shellCwd?: string + shellPathEnv?: string envToDelete: string[] gitCredentialPromptGuarded: boolean startupCommand?: ManagedStartupCommand @@ -178,10 +192,14 @@ type PendingPtyOutput = RelayPtySourceOutput & { } type ManagedStartupCommand = { - command: string + command: string | null + providerDelivery: boolean delivered: boolean waitForShellReady: boolean scanState: ShellReadyScanState | null + identityScanState: ShellStartupIdentityScanState | null + shellPid: number | null + promptProbe: ShellPromptReadinessProbe | null timer: ReturnType | null } @@ -245,6 +263,7 @@ const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 const STARTUP_COMMAND_WRITE_DELAY_MS = 50 const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500 +const RENDERER_SHELL_READY_RETENTION_MS = 15_000 const PTY_FORCE_KILL_RETRY_DELAY_MS = 250 const PTY_FORCE_KILL_MAX_ATTEMPTS = 2 const ALLOWED_SIGNALS = new Set([ @@ -557,7 +576,7 @@ export class PtyHandler { } /** Register an env augmenter merged into every spawn env *after* process.env and renderer env. - * Used by the relay-hook server to inject ORCA_AGENT_HOOK_* coords. See docs/design/agent-status-over-ssh.md §3. */ + * Used by the relay-hook server to inject ORCA_AGENT_HOOK_* coords: evaluated per spawn (not captured once), so a late or restarted hook-server bind still reaches the next PTY. */ addEnvAugmenter(augmenter: PtyEnvAugmenter): () => void { this.envAugmenters.push(augmenter) return () => { @@ -607,11 +626,7 @@ export class PtyHandler { for (const key of envToDelete) { delete result[key] } - if ( - !envToDelete.includes('TERM') && - rendererEnv && - Object.prototype.hasOwnProperty.call(rendererEnv, 'TERM') - ) { + if (!envToDelete.includes('TERM') && rendererEnv && Object.hasOwn(rendererEnv, 'TERM')) { result.TERM = rendererEnv.TERM } // Why: node-pty defaults missing/empty TERM per-platform; normalize so POSIX and Windows children agree. @@ -638,10 +653,26 @@ export class PtyHandler { private releaseStartupCommand(managed: ManagedPty): void { this.clearStartupCommandTimer(managed) + managed.startupCommand?.promptProbe?.dispose() managed.startupCommand = undefined } - private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void { + private drainStartupScanBytes(startup: ManagedStartupCommand): string { + let heldBytes = startup.identityScanState + ? drainShellStartupIdentityHeldBytes(startup.identityScanState) + : '' + startup.identityScanState = null + if (startup.scanState && heldBytes) { + heldBytes = scanForShellReady(startup.scanState, heldBytes).output + } + if (startup.scanState) { + heldBytes += drainShellReadyHeldBytes(startup.scanState) + startup.scanState = null + } + return heldBytes + } + + private scheduleStartupCommandResolution(managed: ManagedPty, delayMs: number): void { const startup = managed.startupCommand if (!startup || startup.delivered || managed.disposed) { return @@ -649,22 +680,25 @@ export class PtyHandler { this.clearStartupCommandTimer(managed) startup.timer = setTimeout(() => { startup.timer = null - this.deliverStartupCommand(managed) + if (startup.providerDelivery) { + this.deliverStartupCommand(managed) + } else { + this.signalRendererShellReady(managed) + } }, delayMs) } private deliverStartupCommand(managed: ManagedPty): void { const startup = managed.startupCommand - if (!startup || startup.delivered || managed.disposed) { + if (!startup?.providerDelivery || !startup.command || startup.delivered || managed.disposed) { return } startup.delivered = true this.clearStartupCommandTimer(managed) - if (startup.scanState) { - const heldBytes = drainShellReadyHeldBytes(startup.scanState) - if (heldBytes) { - managed.startupIngress?.accept(heldBytes) - } + startup.promptProbe?.dispose() + const heldBytes = this.drainStartupScanBytes(startup) + if (heldBytes) { + managed.startupIngress?.accept(heldBytes) } const submit = process.platform === 'win32' ? '\r' : '\n' // Why: only the shell-ready wrapper arms bracketed-paste; other shells use raw submit so ESC[200~ markers aren't echoed. @@ -676,6 +710,19 @@ export class PtyHandler { managed.pty.write(payload) } + private signalRendererShellReady(managed: ManagedPty): void { + const startup = managed.startupCommand + if (!startup || startup.providerDelivery || startup.delivered || managed.disposed) { + return + } + startup.delivered = true + this.clearStartupCommandTimer(managed) + startup.promptProbe?.dispose() + managed.startupIngress?.accept(this.drainStartupScanBytes(startup)) + managed.startupIngress?.accept(`${SHELL_READY_MARKER_PREFIX}\x07`) + managed.startupCommand = undefined + } + /** Wire onData/onExit listeners for a managed PTY and store it. */ private wireAndStore(managed: ManagedPty): void { managed.physicalExit = new PhysicalExitTracker() @@ -701,16 +748,48 @@ export class PtyHandler { onEmission: emitIngressData, ...(echoProbe ? { echoProbe } : {}) }) + const startup = managed.startupCommand + if (startup?.waitForShellReady) { + startup.promptProbe = createShellPromptReadinessProbe({ + slavePath: readPtySlavePath(managed.pty), + shellPath: managed.shellPath, + shellCwd: managed.shellCwd, + shellPathEnv: managed.shellPathEnv, + getShellPid: () => startup.shellPid, + onPromptReady: () => { + if (startup.providerDelivery) { + this.scheduleStartupCommandResolution(managed, STARTUP_COMMAND_WRITE_DELAY_MS) + } else { + this.signalRendererShellReady(managed) + } + } + }) + } managed.pty.onData((data: string) => { const startup = managed.startupCommand + if (startup?.identityScanState && !startup.delivered) { + const scanned = scanForShellStartupIdentity(startup.identityScanState, data) + data = scanned.output + if (scanned.shellPid) { + startup.shellPid = scanned.shellPid + startup.identityScanState = null + } + } if (startup?.waitForShellReady && startup.scanState && !startup.delivered) { const scanned = scanForShellReady(startup.scanState, data) data = scanned.output if (scanned.matched) { - this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS) + if (startup.providerDelivery) { + this.scheduleStartupCommandResolution(managed, STARTUP_COMMAND_WRITE_DELAY_MS) + } else { + this.signalRendererShellReady(managed) + } } } managed.startupIngress?.accept(data) + if (startup && !startup.delivered && data.length > 0) { + startup.promptProbe?.notifyOutput(data) + } }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { managed.physicalExit?.markExited() @@ -748,11 +827,11 @@ export class PtyHandler { private releaseRelayIngress(managed: ManagedPty): void { const startupCommand = managed.startupCommand - const scanState = startupCommand?.scanState - if (scanState) { - const held = drainShellReadyHeldBytes(scanState) - startupCommand.scanState = null - managed.startupIngress?.accept(held) + if (startupCommand) { + this.clearStartupCommandTimer(managed) + startupCommand.promptProbe?.dispose() + managed.startupIngress?.accept(this.drainStartupScanBytes(startupCommand)) + managed.startupCommand = undefined } managed.startupIngress?.drainAndClose() } @@ -1431,7 +1510,7 @@ export class PtyHandler { const explicitTerm = !envToDelete.includes('TERM') && env && - Object.prototype.hasOwnProperty.call(env, 'TERM') && + Object.hasOwn(env, 'TERM') && typeof env.TERM === 'string' && env.TERM.length > 0 ? env.TERM @@ -1475,11 +1554,15 @@ export class PtyHandler { startupCommandDelivery: params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined }) + const managedStartupCommand = shouldProviderDeliverCommand ? command : launchCommandHint // Why: both renderer- and provider-delivered startup commands use this marker; the delivering side strips it from output. const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { terminalWindowsWslDistro, - emitReadyMarker: shouldEmitShellReadyMarker + emitReadyMarker: shouldEmitShellReadyMarker, + emitStartupIdentity: shouldEmitShellReadyMarker }) + const rendererShellReadySupported = + !shouldProviderDeliverCommand && shellLaunch.env.ORCA_SHELL_READY_MARKER === '1' if (context?.signal?.aborted || context?.isStale()) { // Why: cancellation remains side-effect-free until the exact native spawn seam. @@ -1500,7 +1583,12 @@ export class PtyHandler { rows, cwd, // Why: relay shells inherit process.env; don't let an ambient Orca marker enable shell-ready unless requested. - env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } + env: { + ...spawnEnv, + ORCA_SHELL_READY_MARKER: '0', + ORCA_SHELL_STARTUP_IDENTITY: '0', + ...shellLaunch.env + } }) } catch (error) { // Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here. @@ -1539,6 +1627,9 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + shellPath: shell, + shellCwd: cwd, + shellPathEnv: spawnEnv.PATH, ownerBackend: resolvePtyOwnerBackend({ platform: process.platform, shellPath: shell, @@ -1546,16 +1637,23 @@ export class PtyHandler { }), ...(startupIngressIntent ? { startupIngressIntent } : {}), ...(terminalHandle ? { terminalHandle } : {}), - ...(shouldProviderDeliverCommand + ...(managedStartupCommand && (shouldProviderDeliverCommand || rendererShellReadySupported) ? { startupCommand: { - command, + command: shouldProviderDeliverCommand ? managedStartupCommand : null, + providerDelivery: shouldProviderDeliverCommand, delivered: false, waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1', scanState: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1' ? createShellReadyScanState() : null, + identityScanState: + shellLaunch.env.ORCA_SHELL_READY_MARKER === '1' + ? createShellStartupIdentityScanState() + : null, + shellPid: null, + promptProbe: null, timer: null } } @@ -1572,11 +1670,13 @@ export class PtyHandler { this.releaseStartupCommand(managed) this.requestGracefulKill(managed, 'terminate stale') } else if (managed.startupCommand) { - this.scheduleStartupCommandDelivery( + this.scheduleStartupCommandResolution( managed, - managed.startupCommand.waitForShellReady - ? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS - : STARTUP_COMMAND_WRITE_DELAY_MS + managed.startupCommand.providerDelivery + ? managed.startupCommand.waitForShellReady + ? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS + : STARTUP_COMMAND_WRITE_DELAY_MS + : RENDERER_SHELL_READY_RETENTION_MS ) } return { @@ -2033,7 +2133,12 @@ export class PtyHandler { rows: entry.rows, cwd: entry.cwd, // Why: no provider-delivered command is waiting for a ready marker. - env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } + env: { + ...spawnEnv, + ORCA_SHELL_READY_MARKER: '0', + ORCA_SHELL_STARTUP_IDENTITY: '0', + ...shellLaunch.env + } }) this.wireAndStore({ id: entry.id, @@ -2212,6 +2317,14 @@ export class PtyHandler { return count } + get retainedStartupCommandBytes(): number { + let bytes = 0 + for (const managed of this.ptys.values()) { + bytes += managed.startupCommand?.command?.length ?? 0 + } + return bytes + } + get graceTimerActive(): boolean { return this.graceTimer !== null } diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index c4c5b5f11ad..04f7a261583 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -181,6 +181,12 @@ describe('getRelayShellLaunchConfig', () => { expect(config.args).toEqual(['-l']) expect(config.env.ZDOTDIR).toBe(zshRoot) expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( + 'printf "\\033]777;orca-shell-start:%s\\007" "$$"' + ) + expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( + 'unset ORCA_SHELL_STARTUP_IDENTITY' + ) expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark') expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"') } @@ -195,11 +201,26 @@ describe('getRelayShellLaunchConfig', () => { const bashRc = readFileSync(config.args[1] as string, 'utf8') expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') + expect(bashRc).toContain('unset ORCA_SHELL_STARTUP_IDENTITY') expect(bashRc).toContain('__orca_append_prompt_command "__orca_prompt_mark"') expect(bashRc).toContain('printf "\\033]777;orca-shell-ready\\007"') } ) + it.skipIf(process.platform === 'win32')( + 'wraps zsh when only startup identity emission is requested', + () => { + const config = getRelayShellLaunchConfig('/bin/zsh', { HOME: homeDir }, 'linux', { + emitStartupIdentity: true + }) + + expect(config.env.ZDOTDIR).toBe(join(homeDir, '.orca-relay', 'shell-ready', 'zsh')) + expect(config.env.ORCA_SHELL_STARTUP_IDENTITY).toBe('1') + expect(config.env.ORCA_SHELL_READY_MARKER).toBeUndefined() + } + ) + itWithBash('runs the relay bash wrapper without fake C/D markers before the first prompt', () => { const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }) const output = runInteractiveBashRcfile(config.args[1] as string, homeDir) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 5f58b89702c..7a6d7d7a097 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -5,6 +5,7 @@ import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' import { getZshFinalZdotdirRestoreBlock, getZshShellReadyMarkerRegistrationBlock, + SHELL_STARTUP_IDENTITY_MARKER_BLOCK, getZshStartupFileSourceBlock } from '../main/shell-templates' @@ -84,6 +85,7 @@ function ensureOverlayRestoreWrappers(root: string): void { const bashDir = join(root, 'bash') const zshEnv = `# Orca relay zsh overlay wrapper +${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" case "\${ORCA_ORIG_ZDOTDIR%/}" in */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; @@ -133,6 +135,7 @@ ${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HO ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} ` const bashRc = `# Orca relay bash overlay wrapper +${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} [[ -f /etc/profile ]] && source /etc/profile if [[ -f "$HOME/.bash_profile" ]]; then source "$HOME/.bash_profile" @@ -264,10 +267,15 @@ export function getRelayShellLaunchConfig( shellPath: string, env: Record, platform: NodeJS.Platform = process.platform, - options: { emitReadyMarker?: boolean; terminalWindowsWslDistro?: string | null } = {} + options: { + emitReadyMarker?: boolean + emitStartupIdentity?: boolean + terminalWindowsWslDistro?: string | null + } = {} ): RelayShellLaunchConfig { const shellName = shellBasename(shellPath) const emitReadyMarker = options.emitReadyMarker === true + const emitStartupIdentity = options.emitStartupIdentity === true if (platform === 'win32') { // Why: pwsh also exists on POSIX remotes; Windows-specific shell args must // only apply when the relay itself is running on native Windows. @@ -283,9 +291,9 @@ export function getRelayShellLaunchConfig( if (shellName !== 'zsh' && shellName !== 'bash') { return { args: POSIX_LOGIN_ARGS, env: {} } } - // Why: preserve plain zsh startup fast path; only force wrappers when - // shell-ready or overlay env restoration is requested. - if (shellName === 'zsh' && !hasOverlayRestoreEnv(env) && !emitReadyMarker) { + // Why: preserve plain zsh startup fast path unless markers or overlay restoration are requested. + const requiresZshWrapper = hasOverlayRestoreEnv(env) || emitReadyMarker || emitStartupIdentity + if (shellName === 'zsh' && !requiresZshWrapper) { return { args: POSIX_LOGIN_ARGS, env: {} } } @@ -298,13 +306,17 @@ export function getRelayShellLaunchConfig( env: { ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(env), ZDOTDIR: join(root, 'zsh'), - ...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}) + ...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}), + ...(emitStartupIdentity ? { ORCA_SHELL_STARTUP_IDENTITY: '1' } : {}) } } } return { args: ['--rcfile', join(root, 'bash', 'rcfile')], - env: emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {} + env: { + ...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}), + ...(emitStartupIdentity ? { ORCA_SHELL_STARTUP_IDENTITY: '1' } : {}) + } } } diff --git a/src/relay/pty-source-replay-index.test.ts b/src/relay/pty-source-replay-index.test.ts deleted file mode 100644 index efbb17451b0..00000000000 --- a/src/relay/pty-source-replay-index.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PtySourceSpan } from '../shared/pty-source-credit-contract' -import { PtySourceReplayIndex } from './pty-source-replay-index' - -function span(id: string, data: string, sourceStartSu: number): PtySourceSpan { - return { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - spanId: id, - sourceStartSu, - sourceEndSu: sourceStartSu + data.length, - displayStart: sourceStartSu, - displayEnd: sourceStartSu + data.length, - data, - splittable: true, - transform: { transformed: false, rawLengthSu: data.length, scalarSafe: true } - } -} - -describe('PtySourceReplayIndex', () => { - it('wraps the existing chunked buffer and preserves exact retained source ranges', () => { - const replay = new PtySourceReplayIndex(6) - replay.append(span('one', 'abcd', 0)) - replay.append(span('two', 'efgh', 4)) - - expect(replay.readLegacy()).toBe('cdefgh') - expect(replay.recoveryFrom(3)).toEqual({ - ok: true, - records: [ - expect.objectContaining({ sourceStartSu: 3, data: 'd' }), - expect.objectContaining({ sourceStartSu: 4, data: 'efgh' }) - ], - liveEndSourceSu: 8 - }) - expect(replay.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range' - }) - }) - - it('does not wall-clock dedupe identical replay payloads', () => { - const first = new PtySourceReplayIndex(16) - const second = new PtySourceReplayIndex(16) - first.append(span('first', 'same', 0)) - second.append({ ...span('second', 'same', 0), deliveryToken: 'token-2' }) - - expect(first.recoveryFrom(0)).toMatchObject({ ok: true }) - expect(second.recoveryFrom(0)).toMatchObject({ ok: true }) - }) - - it('preserves indivisible-form split metadata during recovery', () => { - const replay = new PtySourceReplayIndex(16) - replay.append({ - ...span('range', 'abcd', 0), - splittable: undefined, - indivisible: false - }) - - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: true, - records: [expect.objectContaining({ sourceStartSu: 2, data: 'cd' })] - }) - }) - - it('rejects replay checkpoints and retained heads inside a scalar', () => { - const replay = new PtySourceReplayIndex(4) - replay.append(span('range', 'a😀b', 0)) - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: false, - reason: 'checkpoint-inside-scalar' - }) - - const trimmed = new PtySourceReplayIndex(1) - trimmed.append(span('emoji', '😀', 0)) - expect(trimmed.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: 2 - }) - }) - - it('rejects a recovery checkpoint beyond the live source range', () => { - const replay = new PtySourceReplayIndex(16) - replay.append(span('range', 'abcd', 0)) - - expect(replay.recoveryFrom(5)).toMatchObject({ - ok: false, - reason: 'checkpoint-after-live-range', - liveEndSourceSu: 4 - }) - }) -}) diff --git a/src/relay/pty-source-replay-index.ts b/src/relay/pty-source-replay-index.ts deleted file mode 100644 index 1dfe5596eb1..00000000000 --- a/src/relay/pty-source-replay-index.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' -import { ptySourceSpanIsSplittable, type PtySourceSpan } from '../shared/pty-source-credit-contract' -import { - assertNonNegativeSafeInteger, - assertPtySourceSpan -} from '../shared/pty-source-credit-validation' - -export type PtySourceReplayRecord = Readonly< - Pick< - PtySourceSpan, - | 'spanId' - | 'sourceStartSu' - | 'sourceEndSu' - | 'displayStart' - | 'displayEnd' - | 'data' - | 'splittable' - | 'indivisible' - | 'transform' - > -> - -export type PtySourceReplayResult = - | Readonly<{ ok: true; records: readonly PtySourceReplayRecord[]; liveEndSourceSu: number }> - | Readonly<{ - ok: false - reason: - | 'checkpoint-before-retained-range' - | 'checkpoint-after-live-range' - | 'checkpoint-inside-indivisible-transform' - | 'checkpoint-inside-scalar' - retainedStartSourceSu: number - liveEndSourceSu: number - }> - -export class PtySourceReplayIndex { - private readonly output: RecentPtyOutputBuffer - private readonly records: PtySourceReplayRecord[] = [] - private displayEnd = 0 - private retainedStartSourceSu = 0 - private liveEndSourceSu = 0 - - constructor(limit: number) { - this.output = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit }) - } - - append(span: PtySourceSpan): void { - assertPtySourceSpan(span) - if ( - span.sourceStartSu !== this.liveEndSourceSu || - span.displayStart !== this.displayEnd || - span.displayEnd - span.displayStart !== span.data.length - ) { - throw new Error('PTY replay source/display ranges must be contiguous') - } - this.output.append(span.data) - this.records.push( - Object.freeze({ - spanId: span.spanId, - sourceStartSu: span.sourceStartSu, - sourceEndSu: span.sourceEndSu, - displayStart: span.displayStart, - displayEnd: span.displayEnd, - data: span.data, - splittable: span.splittable, - indivisible: span.indivisible, - transform: span.transform - }) - ) - this.displayEnd = span.displayEnd - this.liveEndSourceSu = span.sourceEndSu - this.evictUnmappedPrefix() - } - - readLegacy(): string { - return this.output.read() - } - - recoveryFrom(checkpointSourceEndSu: number): PtySourceReplayResult { - assertNonNegativeSafeInteger(checkpointSourceEndSu, 'checkpointSourceEndSu') - if (checkpointSourceEndSu < this.retainedStartSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if (checkpointSourceEndSu > this.liveEndSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-after-live-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const containing = this.records.find( - (record) => - record.sourceStartSu < checkpointSourceEndSu && checkpointSourceEndSu < record.sourceEndSu - ) - if ( - containing?.transform.transformed || - (containing && !ptySourceSpanIsSplittable(containing)) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-indivisible-transform', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if ( - containing && - splitsSurrogatePair(containing.data, checkpointSourceEndSu - containing.sourceStartSu) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-scalar', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const records = this.records - .filter((record) => record.sourceEndSu > checkpointSourceEndSu) - .map((record) => this.sliceRecord(record, checkpointSourceEndSu)) - return Object.freeze({ - ok: true, - records: Object.freeze(records), - liveEndSourceSu: this.liveEndSourceSu - }) - } - - private evictUnmappedPrefix(): void { - const retainedDisplayStart = this.displayEnd - this.output.read().length - while (this.records[0]?.displayEnd <= retainedDisplayStart) { - const removed = this.records.shift()! - this.retainedStartSourceSu = removed.sourceEndSu - } - const head = this.records[0] - if (!head || head.displayStart >= retainedDisplayStart) { - return - } - if (head.transform.transformed || !ptySourceSpanIsSplittable(head)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const displayOffset = retainedDisplayStart - head.displayStart - if (splitsSurrogatePair(head.data, displayOffset)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const sourceStartSu = head.sourceStartSu + displayOffset - this.records[0] = Object.freeze({ - ...head, - spanId: `${head.spanId}:retained:${sourceStartSu}`, - sourceStartSu, - displayStart: retainedDisplayStart, - data: head.data.slice(displayOffset), - transform: Object.freeze({ - ...head.transform, - rawLengthSu: head.sourceEndSu - sourceStartSu - }) - }) - this.retainedStartSourceSu = sourceStartSu - } - - private sliceRecord( - record: PtySourceReplayRecord, - checkpointSourceEndSu: number - ): PtySourceReplayRecord { - if (checkpointSourceEndSu <= record.sourceStartSu) { - return record - } - const offset = checkpointSourceEndSu - record.sourceStartSu - return Object.freeze({ - ...record, - spanId: `${record.spanId}:recovery:${checkpointSourceEndSu}`, - sourceStartSu: checkpointSourceEndSu, - displayStart: record.displayStart + offset, - data: record.data.slice(offset), - transform: Object.freeze({ - ...record.transform, - rawLengthSu: record.sourceEndSu - checkpointSourceEndSu - }) - }) - } -} - -function splitsSurrogatePair(data: string, offset: number): boolean { - const preceding = data.charCodeAt(offset - 1) - const following = data.charCodeAt(offset) - return ( - offset > 0 && - preceding >= 0xd800 && - preceding <= 0xdbff && - following >= 0xdc00 && - following <= 0xdfff - ) -} diff --git a/src/relay/relay-watcher-event-emitter.test.ts b/src/relay/relay-watcher-event-emitter.test.ts index acf734acd71..0f0991e5c01 100644 --- a/src/relay/relay-watcher-event-emitter.test.ts +++ b/src/relay/relay-watcher-event-emitter.test.ts @@ -61,7 +61,7 @@ function createRecordingSink(highWaterMark?: number): { drainWaiters.push(callback) return () => { const index = drainWaiters.indexOf(callback) - if (index >= 0) { + if (index !== -1) { drainWaiters.splice(index, 1) } } diff --git a/src/relay/relay-watcher-event-emitter.ts b/src/relay/relay-watcher-event-emitter.ts index 6bfef6a97f0..739ae89b2f8 100644 --- a/src/relay/relay-watcher-event-emitter.ts +++ b/src/relay/relay-watcher-event-emitter.ts @@ -104,7 +104,7 @@ function publishWatcherBatchToClient( const publish = (events: readonly MappedWatcherEvent[]): boolean => dispatcher.publishProducerNotification(clientId, 'fs.changed', { events }) - // Fast path: publish the whole batch first — two encodes, the same cost as an unchunked emit. + // Fast path: publish the whole batch before paying to group or size individual events. // logDrop:false because rejection here is a measurement, not an outcome: the batch is re-sent in // chunks below, so logging it would report a drop for events that all arrive. if ( @@ -244,7 +244,7 @@ function publishOverflowMarker( // it through onClientDetached, which fires after this settlement. retainOverflowMarker(dispatcher, state, clientId, rootPath, frameBytes) }, - { controlOverflow: 'reject', estimatedBytes: frameBytes } + { controlOverflow: 'reject' } ) if (accepted || settled) { return diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 18f3935a623..afd7ef9a02b 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -9,6 +9,7 @@ // reconnects via `relay.js --connect`, bridging the new SSH channel's stdio to the existing relay's socket. import { createServer, createConnection, type Socket, type Server } from 'node:net' +import { homedir } from 'node:os' import { join } from 'node:path' import { unlinkSync, @@ -41,6 +42,9 @@ import { PortScanHandler } from './port-scan-handler' import { AgentExecHandler } from './agent-exec-handler' import { WorkspaceSessionHandler } from './workspace-session-handler' import { AiVaultHandler } from './ai-vault-handler' +import { createRelayAiVaultService } from './ai-vault-service-factory' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import { PluginOverlayManager } from './plugin-overlay' import { @@ -545,7 +549,7 @@ async function main(): Promise { const marker = process.argv.indexOf('--orca-cli') await runOrcaCliMode( sockPath, - marker >= 0 ? process.argv.slice(marker + 1) : [], + marker !== -1 ? process.argv.slice(marker + 1) : [], endpointCredential ) return @@ -723,7 +727,17 @@ async function main(): Promise { const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) void _workspaceSessionHandler - const _aiVaultHandler = new AiVaultHandler(dispatcher) + const aiVaultRelayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) + const aiVaultHostPlatform = aiVaultRelayPlatform + ? getRemoteHostPlatform(aiVaultRelayPlatform) + : undefined + const aiVaultService = aiVaultHostPlatform + ? createRelayAiVaultService(homedir(), aiVaultHostPlatform) + : null + const _aiVaultHandler = new AiVaultHandler(dispatcher, { + hostPlatform: aiVaultHostPlatform, + service: aiVaultService ?? undefined + }) void _aiVaultHandler // Why: relay-hosted plugin provisioning is a later phase. Register the @@ -766,7 +780,7 @@ async function main(): Promise { ) // ── Agent-hook server ───────────────────────────────────────────── - // Why: loopback HTTP receiver so remote-PTY agent CLIs post hook events locally, forwarded to Orca as agent.hook notifications. See docs/design/agent-status-over-ssh.md §2-§5. + // Why: loopback HTTP receiver so remote-PTY agent CLIs post hook events to a local port (they can't reach Orca's host); the relay forwards them as agent.hook notifications. const hookServer = new RelayAgentHookServer({ // Why: scope endpoint.env/cmd by socket path so multiple relay daemons on one account can't overwrite each other's hook tokens. endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath), @@ -856,7 +870,7 @@ async function main(): Promise { return env }) - // Why: evict pane status cache + overlay dirs on PTY exit so panes don't ghost after reconnect (§5 Path 3) or leak dirs. + // Why: evict pane status cache + overlay dirs on PTY exit, else the next reconnect replays a dead pane's last status (ghost row) and overlay dirs leak. ptyHandler.setExitListener(({ paneKey, id }) => { if (paneKey) { hookServer.clearPaneState(paneKey) @@ -864,7 +878,7 @@ async function main(): Promise { pluginOverlay.clearOverlay(paneKey ?? id) }) - // Why: forward cached entries as notifications before returning so the response trails all replays, closing a reconnect race. See docs/design/agent-status-over-ssh.md §5 Path 3. + // Why: forward cached entries as notifications before returning, so the response trails every replay and Orca can't treat replay as done while frames are still in flight. dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => { const replayed = hookServer.replayCachedPayloadsForPanes() return { replayed } @@ -873,7 +887,7 @@ async function main(): Promise { // Why: relay-local installers collapse hundreds of SFTP request/response RTTs to one RPC. registerManagedHookInstaller(dispatcher) - // Why: plugin sources ship over the wire so an Orca update doesn't force a relay redeploy; cache them per spawn. See docs/design/agent-status-over-ssh.md §4. + // Why: plugin sources ship over the wire — the relay is versioned independently of Orca, so bundling them would make every agent-event change a relay redeploy. // Why: bound per-source size so a buggy/hostile Orca can't OOM the relay by pushing a giant string. dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { const opencode = params.opencodePluginSource @@ -1285,7 +1299,12 @@ async function main(): Promise { graceBranch = null void ptyHandler .dispose() - .then(() => { + .then(async () => { + await aiVaultService?.dispose().catch((error) => { + relayLogLine( + `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` + ) + }) stopPoolWatch() stopPoolActiveWatch() dispatcher.dispose() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7529436ca93..643b6497e5d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -361,6 +361,9 @@ const AddProjectFromFolderDialog = lazy( ) const ProjectAddedDialog = lazy(() => import('./components/sidebar/ProjectAddedDialog')) const DeleteWorktreeDialog = lazy(() => import('./components/sidebar/DeleteWorktreeDialog')) +const PreservedBranchBatchReviewModal = lazy( + () => import('./components/sidebar/PreservedBranchBatchReviewModal') +) const DictationController = lazy(() => import('./components/dictation/DictationController').then((module) => ({ default: module.DictationController @@ -687,6 +690,7 @@ function App(): React.JSX.Element { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) @@ -742,14 +746,7 @@ function App(): React.JSX.Element { const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false) const unmountAddRepoDialogTimerRef = useRef | null>(null) const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) - const [onboardingSettingsDetour, setOnboardingSettingsDetour] = useState(false) const shouldRenderOnboarding = onboarding !== null && shouldShowOnboarding(onboarding) - const onboardingSettingsDetourActive = - onboardingSettingsDetour && activeView === 'settings' && shouldRenderOnboarding - if (onboardingSettingsDetour && !onboardingSettingsDetourActive) { - // Why: the detour is valid only while Settings is onscreen; clear it during render so onboarding resumes without an extra Effect pass. - setOnboardingSettingsDetour(false) - } useEffect(() => { if (activeModal === 'add-repo') { @@ -872,10 +869,6 @@ function App(): React.JSX.Element { settings ]) - const beginOnboardingSettingsDetour = useCallback(() => { - setOnboardingSettingsDetour(true) - }, []) - // Why: useLayoutEffect fires before paint, so dispatching SYNC_FIT_PANES_EVENT reflows the terminal in the same frame as the width change — no wrongly-sized transient. useLayoutEffect(() => { window.dispatchEvent(new CustomEvent(SYNC_FIT_PANES_EVENT)) @@ -970,6 +963,10 @@ function App(): React.JSX.Element { // Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders. parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime' ) + // Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first. + await timeRendererStartupStep('first-window-services-await', () => + window.api.app.awaitFirstWindowStartupServices() + ) await timeRendererStartupStep('fetch-hydration-worktrees', () => mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) => actions.fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) }) @@ -1102,10 +1099,7 @@ function App(): React.JSX.Element { logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 }) } - // Why: main overlaps daemon/hook startup with hydration, but restored terminals need those services ready before they spawn/reconnect PTYs. - await timeRendererStartupStep('first-window-services-await', () => - window.api.app.awaitFirstWindowStartupServices() - ) + // first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state. await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) @@ -1419,6 +1413,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, @@ -1453,6 +1448,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, @@ -1480,18 +1476,17 @@ function App(): React.JSX.Element { } else if (settings.theme === 'light') { applyDocumentTheme('light') return undefined - } else { - // system - const mq = window.matchMedia('(prefers-color-scheme: dark)') - applyDocumentTheme('system') - const handler = (): void => { - applyDocumentTheme('system') - // System theme changes don't mutate the store, so mobile terminal colors need an explicit graph republish. - scheduleRuntimeGraphSync() - } - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) } + // system + const mq = window.matchMedia('(prefers-color-scheme: dark)') + applyDocumentTheme('system') + const handler = (): void => { + applyDocumentTheme('system') + // System theme changes don't mutate the store, so mobile terminal colors need an explicit graph republish. + scheduleRuntimeGraphSync() + } + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) }, [settings]) useEffect(() => { @@ -2347,7 +2342,8 @@ function App(): React.JSX.Element { ) ) : null}
- {stackedSidebarOpen ? ( + {/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */} + {stackedSidebarOpen && activeView !== 'automations' ? (
{titlebarMainStrip}
) : null}
@@ -2703,6 +2699,16 @@ function App(): React.JSX.Element { ) : null} + {activeModal === 'preserved-branch-review' ? ( + + + + ) : null} {hasSshCredentialRequest ? ( @@ -2738,23 +2744,18 @@ function App(): React.JSX.Element { > - {onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? ( + {onboarding && shouldRenderOnboarding ? ( - + ) : null} diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 80bb0037eac..9a64224f072 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -36,6 +36,10 @@ describe('renderer startup runtime routing', () => { const hydrationWorktreesIndex = source.indexOf( "timeRendererStartupStep('fetch-hydration-worktrees'" ) + const servicesIndex = source.indexOf( + "timeRendererStartupStep('first-window-services-await'", + sessionIndex + ) const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()') const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()') @@ -53,7 +57,8 @@ describe('renderer startup runtime routing', () => { expect(localReposIndex).toBeLessThan(localGroupsIndex) expect(localGroupsIndex).toBeLessThan(localFoldersIndex) expect(localReposIndex).toBeLessThan(sessionIndex) - expect(sessionIndex).toBeLessThan(hydrationWorktreesIndex) + expect(sessionIndex).toBeLessThan(servicesIndex) + expect(servicesIndex).toBeLessThan(hydrationWorktreesIndex) const hydrationWorktreeBlock = source.slice( hydrationWorktreesIndex, source.indexOf('await keybindingsPromise') diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 85d6a2c116a..23f335c3927 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1027,11 +1027,6 @@ html.native-shell .app-layout { color: var(--muted-foreground); } -.titlebar-profile-switcher { - -webkit-app-region: no-drag; - flex-shrink: 0; -} - .editor-header { display: flex; align-items: center; @@ -1126,6 +1121,31 @@ html.native-shell .app-layout { overflow: hidden; } +/* ── Jump palette (Cmd+J) ─────────────────────────────── */ + +/* Why the data-slot qualifier: CommandItem also sets data-[selected=true]:bg-accent; + this selector must beat that utility so light-mode selection is actually visible. */ +[data-slot='command-item'].jump-palette-item[data-selected='true'] { + /* Why: flat --accent (#f5f5f5) on the palette surface (#fff) is nearly invisible + in light mode — the keyboard cursor has to read as a solid selected row. + Mix foreground into background and draw an inset ring; expose the surface + as a var so status-pip cutouts can match without hardcoding. */ + --jump-palette-selection-surface: color-mix(in srgb, var(--foreground) 12%, var(--background)); + background: var(--jump-palette-selection-surface); + border-color: transparent; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 18%, transparent); + color: var(--foreground); +} + +.dark [data-slot='command-item'].jump-palette-item[data-selected='true'] { + /* Why accent, not the light-mode mix: --accent (#404040) already reads clearly on + dark surfaces; a low-%% foreground mix was a visible step down from the old row. */ + --jump-palette-selection-surface: var(--accent); + background: var(--jump-palette-selection-surface); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 16%, transparent); + color: var(--accent-foreground); +} + /* ── Sidebar ─────────────────────────────────────────── */ [data-file-explorer-row][data-selected='true'] { @@ -2599,106 +2619,6 @@ html.native-shell .app-layout { animation: feature-wall-meta-pulse 1100ms ease-out 1; } -/* Onboarding step 5: the compact "Take the tour" intro expands into the - full tour shell, so animate the browser snapshots to avoid a hard jump. */ -html.onboarding-tour-start-transition::view-transition-old(root), -html.onboarding-tour-start-transition::view-transition-new(root) { - animation-duration: 760ms; - animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); - animation-fill-mode: both; - mix-blend-mode: normal; -} - -html.onboarding-tour-start-transition::view-transition-old(root) { - animation-name: onboarding-tour-start-old; -} - -html.onboarding-tour-start-transition::view-transition-new(root) { - animation-name: onboarding-tour-start-new; -} - -@keyframes onboarding-tour-start-old { - 0% { - opacity: 1; - transform: translateY(0) scale(1); - } - 28% { - opacity: 0.9; - transform: translateY(-2px) scale(0.998); - } - 42% { - opacity: 0; - transform: translateY(-12px) scale(0.99); - } - 100% { - opacity: 0; - transform: translateY(-12px) scale(0.99); - } -} - -@keyframes onboarding-tour-start-new { - 0% { - opacity: 0; - transform: translateY(18px) scale(0.992); - } - 34% { - opacity: 0; - transform: translateY(18px) scale(0.992); - } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* ── Feature tour nudge: cycling preview ─────────────────────────── */ - -/* Why: the bottom-right nudge cycles through 4 silhouette frames - (Workspace → Orchestration → Tasks → Terminal). Each frame is visible for - 1/4 of the cycle. Holding fade-out late (24%–25%) gives the user dwell - time on the resolved state before swapping. Uses CSS-driven timing rather - than React state so we stay cheap when the popup is mounted but idle. */ -:root { - --feature-tour-cycle: 16s; -} - -.feature-tour-frame { - position: absolute; - inset: 0; - opacity: 0; - animation: feature-tour-frame-fade var(--feature-tour-cycle) linear infinite both; -} -.feature-tour-frame[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-frame[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-frame[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-frame[data-frame='4'] { - animation-delay: 12s; -} - -@keyframes feature-tour-frame-fade { - 0% { - opacity: 0; - } - 1.5% { - opacity: 1; - } - 24% { - opacity: 1; - } - 25% { - opacity: 0; - } - 100% { - opacity: 0; - } -} - .orca-contextual-tour-target-rings { border-radius: 9999px; pointer-events: none; @@ -2736,520 +2656,6 @@ html.onboarding-tour-start-transition::view-transition-new(root) { } } -.feature-tour-copy-slide { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - gap: 4px; - opacity: 0; - animation: feature-tour-frame-fade var(--feature-tour-cycle) linear infinite both; -} -.feature-tour-copy-slide[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-copy-slide[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-copy-slide[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-copy-slide[data-frame='4'] { - animation-delay: 12s; -} - -.feature-tour-dot { - width: 5px; - height: 5px; - border-radius: 9999px; - background: color-mix(in srgb, var(--foreground) 18%, transparent); - animation: feature-tour-dot-pulse var(--feature-tour-cycle) linear infinite; -} -.feature-tour-dot[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-dot[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-dot[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-dot[data-frame='4'] { - animation-delay: 12s; -} -@keyframes feature-tour-dot-pulse { - 0%, - 24% { - background: var(--primary); - transform: scale(1.05); - } - 25%, - 100% { - background: color-mix(in srgb, var(--foreground) 18%, transparent); - transform: scale(1); - } -} - -/* Frame 1 — Workspace: agent-state dot pulses while a "fix login race" - workspace shows two agent rows working in parallel. */ -.feature-tour-ws-status { - animation: feature-tour-status-pulse 1.6s ease-in-out infinite; -} -@keyframes feature-tour-status-pulse { - 0%, - 100% { - opacity: 0.95; - transform: scale(1); - } - 50% { - opacity: 0.55; - transform: scale(0.85); - } -} - -/* Frame 2 — Orchestration: child cards flash as bubbles land along the - dashed paths fanning down from the coordinator. */ -/* Why: bubbles use percent-based left/top so they always travel from the - root card's right edge (34%) to the child card's left edge (64%), - regardless of preview width. The previous offset-path used hardcoded - pixels and drifted off the dashed lines once the preview resized. */ -.feature-tour-orch-bubble { - position: absolute; - left: 34%; - top: 50%; - width: 12px; - height: 12px; - margin-left: -6px; - margin-top: -6px; - border-radius: 3px; - background: var(--primary); - color: var(--primary-foreground); - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - z-index: 4; -} -.feature-tour-orch-bubble.top { - animation: feature-tour-orch-travel-top var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: 4s; -} -.feature-tour-orch-bubble.mid { - animation: feature-tour-orch-travel-mid var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: calc(4s + 0.2s); -} -.feature-tour-orch-bubble.bot { - animation: feature-tour-orch-travel-bot var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: calc(4s + 0.4s); -} -@keyframes feature-tour-orch-travel-top { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 3.5% { - left: 49%; - top: 50%; - opacity: 1; - } - 6.5% { - left: 64%; - top: 18%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 18%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 18%; - opacity: 0; - } -} -@keyframes feature-tour-orch-travel-mid { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 6.5% { - left: 64%; - top: 50%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 50%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 50%; - opacity: 0; - } -} -@keyframes feature-tour-orch-travel-bot { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 3.5% { - left: 49%; - top: 50%; - opacity: 1; - } - 6.5% { - left: 64%; - top: 82%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 82%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 82%; - opacity: 0; - } -} - -.feature-tour-orch-child { - animation: feature-tour-orch-child-flash var(--feature-tour-cycle) linear infinite; -} -.feature-tour-orch-child.top { - animation-delay: 4s; -} -.feature-tour-orch-child.mid { - animation-delay: calc(4s + 0.2s); -} -.feature-tour-orch-child.bot { - animation-delay: calc(4s + 0.4s); -} -@keyframes feature-tour-orch-child-flash { - 0%, - 6% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } - 7% { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--foreground) 12%, transparent); - border-color: color-mix(in srgb, var(--foreground) 38%, transparent); - } - 9% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } - 25%, - 100% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } -} - -/* Frame 3 — Tasks: cursor lands on the second row, the "Open" pill morphs - to a dark "Start workspace →" pill, then a workspace card slides in. */ -.feature-tour-tasks-cursor { - /* Why: anchored from the row's right edge so the cursor sits just past the - pill's right edge (12px row padding + ~6px past pill end) instead of - covering the label text. The arrow tip (top-left of the SVG) lands on the - dark pill's trailing edge with the body of the cursor extending right. */ - position: absolute; - top: 50%; - right: 14px; - width: 14px; - height: 14px; - pointer-events: none; - z-index: 8; - transform: translate(120px, -30%); - opacity: 0; - animation: feature-tour-tasks-cursor var(--feature-tour-cycle) cubic-bezier(0.45, 0.05, 0.2, 1) - infinite; - animation-delay: 8s; -} -.feature-tour-tasks-cursor svg { - width: 14px; - height: 14px; - display: block; -} -@keyframes feature-tour-tasks-cursor { - 0% { - transform: translate(160px, -10%); - opacity: 0; - } - 2% { - opacity: 1; - } - 5% { - transform: translate(60px, -30%); - opacity: 1; - } - 8% { - transform: translate(0px, -30%); - opacity: 1; - } - 9% { - transform: translate(0px, -10%); - opacity: 1; - } - 10.5% { - transform: translate(0px, -30%); - opacity: 1; - } - 23.5% { - transform: translate(0px, -30%); - opacity: 1; - } - 24.5% { - opacity: 0; - } - 25%, - 100% { - transform: translate(0px, -30%); - opacity: 0; - } -} - -.feature-tour-tasks-row { - animation: feature-tour-tasks-row-hover var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-row-hover { - 0%, - 4% { - background: transparent; - box-shadow: none; - } - 6% { - background: color-mix(in srgb, var(--foreground) 5%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); - } - 23.5% { - background: color-mix(in srgb, var(--foreground) 5%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); - } - 25%, - 100% { - background: transparent; - box-shadow: none; - } -} - -.feature-tour-tasks-pill { - animation: feature-tour-tasks-pill-morph var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-pill-morph { - 0%, - 6% { - width: 28px; - background: rgb(34 197 94 / 0.14); - border-color: rgb(34 197 94 / 0.3); - box-shadow: none; - } - 8% { - width: 78px; - background: var(--primary); - border-color: var(--primary); - box-shadow: 0 1px 2px rgb(0 0 0 / 0.18); - } - 23.5% { - width: 78px; - background: var(--primary); - border-color: var(--primary); - box-shadow: 0 1px 2px rgb(0 0 0 / 0.18); - } - 25%, - 100% { - width: 28px; - background: rgb(34 197 94 / 0.14); - border-color: rgb(34 197 94 / 0.3); - box-shadow: none; - } -} - -.feature-tour-tasks-pill-label { - opacity: 0; - animation: feature-tour-tasks-pill-label var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-pill-label { - 0%, - 7% { - opacity: 0; - } - 9% { - opacity: 1; - } - 23.5% { - opacity: 1; - } - 24.5% { - opacity: 0; - } - 25%, - 100% { - opacity: 0; - } -} - -/* Click ring fires when the cursor "clicks" the morphed pill on the tasks - row. Positioned to overlay the right-side pill of the second task. */ -.feature-tour-tasks-click-ring { - /* Why: ring is centered on the cursor tip (right=28 from row's right edge) - so the click feedback lands where the cursor is, on the arrow icon — not - centered on the pill, which would visually disconnect from the cursor. */ - position: absolute; - top: 50%; - right: 14px; - width: 28px; - height: 28px; - margin-top: -14px; - border-radius: 9999px; - border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent); - opacity: 0; - z-index: 7; - pointer-events: none; - animation: feature-tour-tasks-click-ring var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-click-ring { - 0%, - 7% { - opacity: 0; - transform: scale(0.4); - } - 8% { - opacity: 0.85; - transform: scale(0.6); - } - 10% { - opacity: 0; - transform: scale(1.4); - } - 25%, - 100% { - opacity: 0; - transform: scale(1.4); - } -} - -/* Newly-created workspace card slides in beneath the task row once the - user clicks "Start workspace" — sells the "task → workspace" jump. */ -.feature-tour-tasks-workspace { - opacity: 0; - transform: translateY(-6px); - animation: feature-tour-tasks-workspace var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-workspace { - 0%, - 10% { - opacity: 0; - transform: translateY(-6px); - } - 12% { - opacity: 1; - transform: translateY(0); - } - 23.5% { - opacity: 1; - transform: translateY(0); - } - 25%, - 100% { - opacity: 0; - transform: translateY(-6px); - } -} - -/* Frame 4 — Terminal: command stays anchored while the split panes stream - Playwright and Claude activity. */ -.feature-tour-terminal-line { - width: 23ch; -} - -.feature-tour-terminal-output { - opacity: 0; - transform: translateY(2px); - animation: feature-tour-terminal-output var(--feature-tour-cycle) linear infinite; - animation-delay: 12s; -} -.feature-tour-terminal-output[data-line='1'] { - animation-delay: calc(12s + 0.05s); -} -.feature-tour-terminal-output[data-line='2'] { - animation-delay: calc(12s + 0.25s); -} -.feature-tour-terminal-output[data-line='3'] { - animation-delay: calc(12s + 0.45s); -} -@keyframes feature-tour-terminal-output { - 0%, - 10% { - opacity: 0; - transform: translateY(2px); - } - 12% { - opacity: 1; - transform: translateY(0); - } - 23.5% { - opacity: 1; - transform: translateY(0); - } - 25%, - 100% { - opacity: 0; - transform: translateY(2px); - } -} - -@media (prefers-reduced-motion: reduce) { - .feature-tour-frame, - .feature-tour-copy-slide, - .feature-tour-dot, - .feature-tour-ws-status, - .feature-tour-orch-bubble, - .feature-tour-orch-child, - .feature-tour-tasks-cursor, - .feature-tour-tasks-row, - .feature-tour-tasks-pill, - .feature-tour-tasks-pill-label, - .feature-tour-tasks-click-ring, - .feature-tour-tasks-workspace, - .feature-tour-terminal-output { - animation: none !important; - } - .feature-tour-frame[data-frame='1'], - .feature-tour-copy-slide[data-frame='1'] { - opacity: 1; - } - .feature-tour-dot[data-frame='1'] { - background: var(--primary); - } -} - /* ── Diff comment decorations ────────────────────────────────────── */ /* Why: the "+" button is appended into Monaco's editor DOM node by diff --git a/src/renderer/src/components/AgentWorkingSpinner.test.tsx b/src/renderer/src/components/AgentWorkingSpinner.test.tsx index 25c04f39783..f887dab132c 100644 --- a/src/renderer/src/components/AgentWorkingSpinner.test.tsx +++ b/src/renderer/src/components/AgentWorkingSpinner.test.tsx @@ -50,7 +50,99 @@ describe('AgentWorkingSpinner', () => { } }) - it('anchors mount and restart animations to the shared document epoch', async () => { + // Why: animationstart fires on the first start and on every restart (class + // re-add, display:none reveal), so it alone keeps every ring phase-locked. + // A negative animation-delay cannot — it is static, so a restart replays the + // stale mount-time offset and the rings fan out across steps. + it('anchors every animation start to the shared document epoch', async () => { + const animation = { + animationName: 'agent-spinner-rotate', + startTime: 321 + } as unknown as Animation + const getAnimations = vi.fn(() => [animation]) + const originalGetAnimations = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'getAnimations' + ) + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: getAnimations + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const fireAnimationStart = async (): Promise => { + const event = new Event('animationstart', { bubbles: true }) + Object.defineProperty(event, 'animationName', { value: 'agent-spinner-rotate' }) + await act(async () => { + container.querySelector('[data-agent-spinner]')!.dispatchEvent(event) + }) + } + + try { + await act(async () => { + root.render() + }) + // Why: mount must not query animations — that forces a synchronous style + // recalc per ring for a phase the first animationstart sets anyway. + expect(getAnimations).not.toHaveBeenCalled() + + await fireAnimationStart() + expect(animation.startTime).toBe(0) + + animation.startTime = 654 + await fireAnimationStart() + expect(animation.startTime).toBe(0) + expect(getAnimations).toHaveBeenCalledTimes(2) + } finally { + act(() => root.unmount()) + container.remove() + if (originalGetAnimations === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations') + } else { + Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations) + } + } + }) + + // Why: reduced motion resolves the rule to `animation: none`, so no animation + // exists to start and none to anchor. Pins that dropping the ref-time anchor + // adds no exposure here — both paths were already no-ops. + it('anchors nothing when reduced motion leaves no animation to start', async () => { + const getAnimations = vi.fn(() => [] as Animation[]) + const originalGetAnimations = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'getAnimations' + ) + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: getAnimations + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + try { + await act(async () => { + root.render() + }) + const el = container.querySelector('[data-agent-spinner]') + + expect(el).not.toBeNull() + expect(getAnimations).not.toHaveBeenCalled() + expect(el!.className).toContain('motion-reduce:border-t-yellow-500') + } finally { + act(() => root.unmount()) + container.remove() + if (originalGetAnimations === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations') + } else { + Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations) + } + } + }) + + it('ignores animation starts from other animations on the same element', async () => { const animation = { animationName: 'agent-spinner-rotate', startTime: 321 @@ -72,16 +164,14 @@ describe('AgentWorkingSpinner', () => { await act(async () => { root.render() }) - expect(animation.startTime).toBe(0) - - animation.startTime = 654 - const restart = new Event('animationstart', { bubbles: true }) - Object.defineProperty(restart, 'animationName', { value: 'agent-spinner-rotate' }) + const unrelated = new Event('animationstart', { bubbles: true }) + Object.defineProperty(unrelated, 'animationName', { value: 'compact-agent-expansion-reveal' }) await act(async () => { - container.querySelector('[data-agent-spinner]')!.dispatchEvent(restart) + container.querySelector('[data-agent-spinner]')!.dispatchEvent(unrelated) }) - expect(animation.startTime).toBe(0) - expect(getAnimations).toHaveBeenCalledTimes(2) + + expect(getAnimations).not.toHaveBeenCalled() + expect(animation.startTime).toBe(321) } finally { act(() => root.unmount()) container.remove() diff --git a/src/renderer/src/components/AgentWorkingSpinner.tsx b/src/renderer/src/components/AgentWorkingSpinner.tsx index 9b20a862b09..7d2ab0a0b5a 100644 --- a/src/renderer/src/components/AgentWorkingSpinner.tsx +++ b/src/renderer/src/components/AgentWorkingSpinner.tsx @@ -3,8 +3,10 @@ import { cn } from '@/lib/utils' const SPINNER_ANIMATION_NAME = 'agent-spinner-rotate' -// Why: CSS assigns per-element start times after refs run; anchoring the Web -// Animation timeline gives late mounts exact phase sync without recurring JS. +// Why: anchoring the Web Animation timeline gives late mounts exact phase sync +// without recurring JS. Driven only by animationstart — a ref-time call would +// force a synchronous style recalc per mount for a phase animationstart fixes +// one frame later anyway (measured: 24ms of blocking work per 200 mounts). function syncSpinnerPhase(el: HTMLSpanElement | null): void { if (el === null || typeof el.getAnimations !== 'function') { return @@ -33,7 +35,6 @@ function handleSpinnerAnimationStart(event: React.AnimationEvent + + + + + + {translate( + 'auto.components.BrowserCookieImportDisclosure.title', + "Google logins aren't imported" + )} + + + {translate( + 'auto.components.BrowserCookieImportDisclosure.description', + 'Sign in to Google directly in Orca.' + )} + + + + + ) +} diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 0e50c3bbc50..5f73fb31bea 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -93,10 +93,12 @@ import { } from '@/components/editor/large-diff-section-content' import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' import { + beginGitHubChecksTabDetails, createGitHubChecksTabState, + resetGitHubChecksTabForSource, resolveGitHubChecksTabState, + settleGitHubChecksTabDetails, toggleGitHubChecksTabExpandedKey, - updateGitHubChecksTabDetails, updateGitHubChecksTabLocalChecks, type CheckDetailsLoadState } from '@/components/github-checks-tab-state' @@ -113,10 +115,12 @@ import { import { filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters, type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' +} from '../../../shared/pr-comment-audience' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { getPRCommentGroupCount, @@ -124,11 +128,13 @@ import { getPRCommentGroupRoot, groupPRComments, isResolvedPRCommentGroup, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' +import { PR_COMMENT_OPEN_AUTHOR_CLASS, PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS, - type PRCommentGroup -} from '@/lib/pr-comment-groups' + PR_COMMENT_RESOLVED_CONTAINER_CLASS +} from '@/lib/pr-comment-resolution-classes' import { getCommentReplyTargetCandidates, resolveCommentReplyTarget @@ -141,6 +147,7 @@ import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/ import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' @@ -1233,7 +1240,7 @@ if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { invalidateWorkItemDetailsCacheByMatch(payload) }) } -if (typeof import.meta !== 'undefined' && import.meta.hot) { +if (import.meta !== undefined && import.meta.hot) { import.meta.hot.dispose(() => { workItemMutatedUnsub?.() workItemDetailsCacheEventUnsub?.() @@ -3215,19 +3222,46 @@ function ChecksTab({ variant?: 'compact' | 'page' onChecksUpdated: (checks: PRCheckDetail[]) => void }): React.JSX.Element { - const [refreshing, setRefreshing] = useState(false) - const [rerunning, setRerunning] = useState(false) const [fixingChecks, setFixingChecks] = useState(false) - const [checksState, setChecksState] = useState(() => createGitHubChecksTabState(checks)) const mountedRef = useMountedRef() - const resolvedChecksState = resolveGitHubChecksTabState(checksState, checks) + const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) + const nextCheckDetailsRequestIdRef = useRef(0) + const checkDetailsContextKey = [ + sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local', + repoId ?? item.repoId ?? '', + repoPath ?? '', + prRepo ? githubRepoIdentityKey(prRepo) : '', + item.id, + item.number, + headSha ?? '' + ].join('\0') + const [checksState, setChecksState] = useState(() => + createGitHubChecksTabState(checks, checkDetailsContextKey) + ) + const resolvedChecksState = resolveGitHubChecksTabState( + checksState, + checks, + checkDetailsContextKey + ) + const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner) + const nextChecksRefreshRequestIdRef = useRef(0) + const activeChecksRefreshRequestIdRef = useRef(null) + const [refreshingOwner, setRefreshingOwner] = useState<{ + contextOwner: object + requestId: number + } | null>(null) + const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner + const [rerunningOwner, setRerunningOwner] = useState(null) + const rerunning = rerunningOwner === resolvedChecksState.contextOwner + useLayoutEffect(() => { + committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner + }, [resolvedChecksState.contextOwner]) if (resolvedChecksState !== checksState) { // Why: a parent check refresh replaces the source list; reset local state before stale rows/details can paint. setChecksState(resolvedChecksState) } const { localChecks, expandedCheckKey, detailsByCheckKey } = resolvedChecksState const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks]) - const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) const sorted = sortChecksBySeverity(list) @@ -3258,72 +3292,107 @@ function ChecksTab({ : 'text-muted-foreground' const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) - const handleRefresh = useCallback(async (): Promise => { - if (!canUseChecksRepoContext) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.e7007aa1d8', - 'Unable to refresh checks without a repository path.' + const handleRefresh = useCallback( + async (expectedContextOwner?: object): Promise => { + if (!canUseChecksRepoContext) { + toast.error( + translate( + 'auto.components.GitHubItemDialog.e7007aa1d8', + 'Unable to refresh checks without a repository path.' + ) ) - ) - return null - } - setRefreshing(true) - try { - const nextChecks = (await (runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + return null + } + const refreshContextOwner = expectedContextOwner ?? committedChecksContextOwnerRef.current + if (committedChecksContextOwnerRef.current !== refreshContextOwner) { + return null + } + const refreshRequestId = ++nextChecksRefreshRequestIdRef.current + activeChecksRefreshRequestIdRef.current = refreshRequestId + setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) + try { + const nextChecks = (await (runtimeHost + ? callRuntimeRpc( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.prChecks', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + prNumber: item.number, + headSha, + prRepo, + noCache: true + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.prChecks({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, prRepo, noCache: true - }, - { timeoutMs: 30_000 } + }))) as PRCheckDetail[] + if ( + !mountedRef.current || + committedChecksContextOwnerRef.current !== refreshContextOwner || + activeChecksRefreshRequestIdRef.current !== refreshRequestId + ) { + return null + } + setChecksState((current) => + current.contextOwner === refreshContextOwner + ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) + : current + ) + onChecksUpdated(nextChecks) + return nextChecks + } catch (err) { + if ( + mountedRef.current && + committedChecksContextOwnerRef.current === refreshContextOwner && + activeChecksRefreshRequestIdRef.current === refreshRequestId + ) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.GitHubItemDialog.0bbdc673c1', 'Failed to refresh checks') ) - : window.api.gh.prChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - prRepo, - noCache: true - }))) as PRCheckDetail[] - setChecksState((current) => updateGitHubChecksTabLocalChecks(current, nextChecks)) - onChecksUpdated(nextChecks) - return nextChecks - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.0bbdc673c1', 'Failed to refresh checks') - ) - return null - } finally { - setRefreshing(false) - } - }, [ - canUseChecksRepoContext, - headSha, - item.number, - item.repoId, - onChecksUpdated, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ]) + } + return null + } finally { + if (activeChecksRefreshRequestIdRef.current === refreshRequestId) { + activeChecksRefreshRequestIdRef.current = null + } + if (mountedRef.current) { + setRefreshingOwner((current) => + current?.requestId === refreshRequestId ? null : current + ) + } + } + }, + [ + canUseChecksRepoContext, + headSha, + item.number, + item.repoId, + mountedRef, + onChecksUpdated, + runtimeHost, + prRepo, + repoId, + repoPath, + sourceContext + ] + ) const handleRerun = useCallback( async (failedOnly: boolean): Promise => { if (!canUseChecksRepoContext || rerunning) { return } - setRerunning(true) + const rerunContextOwner = committedChecksContextOwnerRef.current + setRerunningOwner(rerunContextOwner) try { const result = runtimeHost ? await callRuntimeRpc>>( @@ -3347,6 +3416,9 @@ function ChecksTab({ failedOnly, prRepo }) + if (!mountedRef.current || committedChecksContextOwnerRef.current !== rerunContextOwner) { + return + } if (!result.ok) { toast.error(result.error) return @@ -3356,15 +3428,19 @@ function ChecksTab({ ? translate('auto.components.GitHubItemDialog.ddafe851e1', 'Check rerun requested') : translate('auto.components.GitHubItemDialog.e463ec935f', 'Check reruns requested') ) - await handleRefresh() + await handleRefresh(rerunContextOwner) } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.9e7c221b8d', 'Failed to rerun checks') - ) + if (mountedRef.current && committedChecksContextOwnerRef.current === rerunContextOwner) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.GitHubItemDialog.9e7c221b8d', 'Failed to rerun checks') + ) + } } finally { - setRerunning(false) + if (mountedRef.current) { + setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) + } } }, [ @@ -3373,6 +3449,7 @@ function ChecksTab({ headSha, item.number, item.repoId, + mountedRef, prRepo, runtimeHost, rerunning, @@ -3441,77 +3518,74 @@ function ChecksTab({ } }, [failedChecks.length, fixingChecks, item, list, repoId]) - const handleToggleCheckDetails = useCallback( - (check: PRCheckDetail): void => { - const key = getCheckDetailsKey(check) - setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) - if ( - !canUseChecksRepoContext || - detailsByCheckKey[key] || - (!check.checkRunId && !check.workflowRunId && !check.url) - ) { + const requestCheckDetails = useCallback( + (check: PRCheckDetail, key: string): void => { + if (!canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { return } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: true, - details: null, - error: null - }) - ) - const detailsRequest = runtimeHost - ? callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prCheckDetails', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + const requestId = ++nextCheckDetailsRequestIdRef.current + const commit = (next: Omit): void => { + if (!mountedRef.current) { + return + } + setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) + } + setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) + const detailsRequest = withGitHubCheckDetailsTimeout((signal) => + runtimeHost + ? callRuntimeRpc>>( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.prCheckDetails', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo + }, + { timeoutMs: 30_000, signal } + ) + : window.api.gh.prCheckDetails({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, checkRunId: check.checkRunId, workflowRunId: check.workflowRunId, checkName: check.name, url: check.url, prRepo - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prCheckDetails({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }) + }) + ) void detailsRequest .then((details) => { - if (!mountedRef.current) { - return - } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: false, - details, - error: details ? null : 'No inline details are available for this check.' - }) - ) + commit({ + loading: false, + details, + error: details + ? null + : translate( + 'auto.components.GitHubItemDialog.e15a8b77ef', + 'No inline details are available for this check.' + ) + }) }) .catch((err) => { - if (!mountedRef.current) { - return - } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: false, - details: null, - error: err instanceof Error ? err.message : 'Failed to load check details.' - }) - ) + commit({ + loading: false, + details: null, + error: + err instanceof Error + ? err.message + : translate( + 'auto.components.GitHubItemDialog.e45324fbed', + 'Failed to load check details.' + ) + }) }) }, [ canUseChecksRepoContext, - detailsByCheckKey, item.repoId, mountedRef, runtimeHost, @@ -3522,6 +3596,18 @@ function ChecksTab({ ] ) + const handleToggleCheckDetails = useCallback( + (check: PRCheckDetail): void => { + const key = getCheckDetailsKey(check) + setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) + if (detailsByCheckKey[key]) { + return + } + requestCheckDetails(check, key) + }, + [detailsByCheckKey, requestCheckDetails] + ) + const refreshAction = ( @@ -3715,7 +3801,7 @@ function ChecksTab({ return (
- {state?.loading ? ( + {state?.loading && !state.error ? (
{translate('auto.components.GitHubItemDialog.934d87ab96', 'Loading check details…')} @@ -3746,7 +3832,27 @@ function ChecksTab({ )}
- {state?.error &&
{state.error}
} + {state?.error && ( +
+ + {state.error} + + +
+ )} {hasOutput && (
diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 2d4b392b71b..bcf5c66ec1f 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -149,7 +149,7 @@ function PreflightBanner({ repos }: { issues: PreflightIssue[] - repos: Repo[] + repos: readonly Repo[] }): React.JSX.Element | null { // Why: keying the seed on the current GitHub project set means adding a new // GitHub project (which changes the key) re-evaluates dismissals, so a lapsed diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 16a7b82beae..33fc740980b 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -871,3 +871,42 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(recipeChanges).toEqual([null]) }) }) + +describe('NewWorkspaceComposerCard note sizing', () => { + // Sizing is layout-driven (field-sizing) rather than a JS measure pass, and happy-dom + // has no layout engine, so these assert the class contract that produces the growth. + afterEach(() => { + act(() => current?.root.unmount()) + current?.container.remove() + current = null + }) + + function findNoteTextarea(container: HTMLElement): HTMLTextAreaElement { + const label = [...container.querySelectorAll('label')].find( + (candidate) => candidate.textContent?.trim() === 'Note' + ) + const textarea = label?.parentElement?.querySelector('textarea') + expect(textarea).toBeTruthy() + return textarea as HTMLTextAreaElement + } + + it('sizes from the note value, so a PR prefill written straight to state still shows in full', () => { + // #10575: the prefill never fires an input event, so nothing but the value can drive height. + current = renderCard({ + advancedOpen: true, + note: `PR #10575 — ${'a note title long enough to wrap over several lines '.repeat(3)}` + }) + + expect(findNoteTextarea(current.container).className).toContain('[field-sizing:content]') + }) + + it('keeps a note past the height cap readable instead of clipping it', () => { + current = renderCard({ advancedOpen: true, note: 'a'.repeat(4000) }) + + const { className } = findNoteTextarea(current.container) + expect(className).toContain('max-h-40') + expect(className).toContain('overflow-y-auto') + expect(className).toContain('scrollbar-sleek') + expect(className).not.toContain('overflow-hidden') + }) +}) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index f01350a6df9..b9683150d17 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -14,6 +14,7 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { SwitchIndicator } from '@/components/ui/switch' import { SettingsSwitch } from '@/components/settings/SettingsFormControls' import type RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' @@ -83,7 +84,7 @@ type NewWorkspaceComposerCardProps = { nameInputRef?: React.RefObject quickAgent: TuiAgent | null onQuickAgentChange: (agent: TuiAgent | null) => void - eligibleRepos: RepoOption[] + eligibleRepos: readonly RepoOption[] repoId: string projectOptions?: NewWorkspaceProjectOption[] selectedProjectId?: string | null @@ -97,7 +98,7 @@ type NewWorkspaceComposerCardProps = { selectedEphemeralVmRecipeId?: string | null onEphemeralVmRecipeChange?: (recipeId: string | null) => void ephemeralVmRecipeError?: string | null - repoBackedSearchRepos?: RepoOption[] + repoBackedSearchRepos?: readonly RepoOption[] repoBackedSourcesDisabled?: boolean allowSmartNameAddProject?: boolean smartNameRepoSwitchTarget?: 'project' | 'task-source' @@ -1001,18 +1002,15 @@ export default function NewWorkspaceComposerCard({ value={note} onChange={(event) => onNoteChange(event.target.value)} onPaste={handleNotePaste} - onInput={(event) => { - // Why: reset then size to content so short notes stay compact and long ones grow without a scrollbar until max-h clamps. - const ta = event.currentTarget - ta.style.height = 'auto' - ta.style.height = `${ta.scrollHeight}px` - }} placeholder={translate( 'auto.components.NewWorkspaceComposerCard.090cfedeb4', 'Write a note' )} rows={1} - className="w-full min-w-0 resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 max-h-40" + // Why (#10575): field-sizing:content grows the note with its value, so a PR/MR + // prefill written straight to state sizes like typed text — an onInput measure + // pass never saw it. Past the max-h clamp the sleek scrollbar keeps it readable. + className="w-full min-w-0 resize-none overflow-y-auto scrollbar-sleek rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [field-sizing:content] max-h-40" />
@@ -1212,20 +1210,7 @@ export default function NewWorkspaceComposerCard({ onClick={() => onCreateMultipleChange?.(!createMultiple)} className="group flex w-fit cursor-pointer items-center gap-2 rounded-md text-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > - - - + {translate('auto.components.NewWorkspaceComposerCard.createMultiple', 'Create more')} diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 4b46d0148e5..aedc7bdd5e0 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -89,10 +89,12 @@ import { import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog' import { + beginGitHubChecksTabDetails, createGitHubChecksTabState, + resetGitHubChecksTabForSource, resolveGitHubChecksTabState, + settleGitHubChecksTabDetails, toggleGitHubChecksTabExpandedKey, - updateGitHubChecksTabDetails, updateGitHubChecksTabLocalChecks, type CheckDetailsLoadState } from '@/components/github-checks-tab-state' @@ -109,10 +111,12 @@ import { import { filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters, type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' +} from '../../../shared/pr-comment-audience' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { getPRCommentGroupCount, @@ -120,11 +124,13 @@ import { getPRCommentGroupRoot, groupPRComments, isResolvedPRCommentGroup, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' +import { PR_COMMENT_OPEN_AUTHOR_CLASS, PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS, - type PRCommentGroup -} from '@/lib/pr-comment-groups' + PR_COMMENT_RESOLVED_CONTAINER_CLASS +} from '@/lib/pr-comment-resolution-classes' import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' import { attachPRReviewReplyParent, @@ -134,6 +140,7 @@ import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/ import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { @@ -1279,7 +1286,7 @@ if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { invalidateWorkItemDetailsCacheByMatch(payload) }) } -if (typeof import.meta !== 'undefined' && import.meta.hot) { +if (import.meta !== undefined && import.meta.hot) { import.meta.hot.dispose(() => { workItemMutatedUnsub?.() workItemDetailsCacheEventUnsub?.() @@ -3217,13 +3224,41 @@ function ChecksTab({ const repo = useAppStore((s) => targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null ) - const [refreshing, setRefreshing] = useState(false) - const [rerunning, setRerunning] = useState(false) const [fixingChecks, setFixingChecks] = useState(false) const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState(null) - const [checksState, setChecksState] = useState(() => createGitHubChecksTabState(checks)) const mountedRef = useMountedRef() - const resolvedChecksState = resolveGitHubChecksTabState(checksState, checks) + const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) + const nextCheckDetailsRequestIdRef = useRef(0) + const checkDetailsContextKey = [ + sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local', + repoId ?? item.repoId ?? '', + repoPath ?? '', + prRepo ? githubRepoIdentityKey(prRepo) : '', + item.id, + item.number, + headSha ?? '' + ].join('\0') + const [checksState, setChecksState] = useState(() => + createGitHubChecksTabState(checks, checkDetailsContextKey) + ) + const resolvedChecksState = resolveGitHubChecksTabState( + checksState, + checks, + checkDetailsContextKey + ) + const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner) + const nextChecksRefreshRequestIdRef = useRef(0) + const activeChecksRefreshRequestIdRef = useRef(null) + const [refreshingOwner, setRefreshingOwner] = useState<{ + contextOwner: object + requestId: number + } | null>(null) + const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner + const [rerunningOwner, setRerunningOwner] = useState(null) + const rerunning = rerunningOwner === resolvedChecksState.contextOwner + useLayoutEffect(() => { + committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner + }, [resolvedChecksState.contextOwner]) if (resolvedChecksState !== checksState) { // Why: reconcile before paint when a parent check refresh replaces the source list, so stale rows/details never show. setChecksState(resolvedChecksState) @@ -3317,7 +3352,6 @@ function ChecksTab({ }, [item, targetRepoId] ) - const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) const sorted = sortChecksBySeverity(list) @@ -3348,72 +3382,107 @@ function ChecksTab({ : 'text-muted-foreground' const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) - const handleRefresh = useCallback(async (): Promise => { - if (!canUseChecksRepoContext) { - toast.error( - translate( - 'auto.components.PullRequestPage.c057f2fcb0', - 'Unable to refresh checks without a repository path.' + const handleRefresh = useCallback( + async (expectedContextOwner?: object): Promise => { + if (!canUseChecksRepoContext) { + toast.error( + translate( + 'auto.components.PullRequestPage.c057f2fcb0', + 'Unable to refresh checks without a repository path.' + ) ) - ) - return null - } - setRefreshing(true) - try { - const nextChecks = (await (runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + return null + } + const refreshContextOwner = expectedContextOwner ?? committedChecksContextOwnerRef.current + if (committedChecksContextOwnerRef.current !== refreshContextOwner) { + return null + } + const refreshRequestId = ++nextChecksRefreshRequestIdRef.current + activeChecksRefreshRequestIdRef.current = refreshRequestId + setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) + try { + const nextChecks = (await (runtimeHost + ? callRuntimeRpc( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.prChecks', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + prNumber: item.number, + headSha, + prRepo, + noCache: true + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.prChecks({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, prRepo, noCache: true - }, - { timeoutMs: 30_000 } + }))) as PRCheckDetail[] + if ( + !mountedRef.current || + committedChecksContextOwnerRef.current !== refreshContextOwner || + activeChecksRefreshRequestIdRef.current !== refreshRequestId + ) { + return null + } + setChecksState((current) => + current.contextOwner === refreshContextOwner + ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) + : current + ) + onChecksUpdated(nextChecks) + return nextChecks + } catch (err) { + if ( + mountedRef.current && + committedChecksContextOwnerRef.current === refreshContextOwner && + activeChecksRefreshRequestIdRef.current === refreshRequestId + ) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.PullRequestPage.246b2c6456', 'Failed to refresh checks') ) - : window.api.gh.prChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - prRepo, - noCache: true - }))) as PRCheckDetail[] - setChecksState((current) => updateGitHubChecksTabLocalChecks(current, nextChecks)) - onChecksUpdated(nextChecks) - return nextChecks - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.246b2c6456', 'Failed to refresh checks') - ) - return null - } finally { - setRefreshing(false) - } - }, [ - canUseChecksRepoContext, - headSha, - item.number, - item.repoId, - onChecksUpdated, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ]) + } + return null + } finally { + if (activeChecksRefreshRequestIdRef.current === refreshRequestId) { + activeChecksRefreshRequestIdRef.current = null + } + if (mountedRef.current) { + setRefreshingOwner((current) => + current?.requestId === refreshRequestId ? null : current + ) + } + } + }, + [ + canUseChecksRepoContext, + headSha, + item.number, + item.repoId, + mountedRef, + onChecksUpdated, + runtimeHost, + prRepo, + repoId, + repoPath, + sourceContext + ] + ) const handleRerun = useCallback( async (failedOnly: boolean): Promise => { if (!canUseChecksRepoContext || rerunning) { return } - setRerunning(true) + const rerunContextOwner = committedChecksContextOwnerRef.current + setRerunningOwner(rerunContextOwner) try { const result = runtimeHost ? await callRuntimeRpc>>( @@ -3437,6 +3506,9 @@ function ChecksTab({ failedOnly, prRepo }) + if (!mountedRef.current || committedChecksContextOwnerRef.current !== rerunContextOwner) { + return + } if (!result.ok) { toast.error(result.error) return @@ -3446,15 +3518,19 @@ function ChecksTab({ ? translate('auto.components.PullRequestPage.5963a6a852', 'Check rerun requested') : translate('auto.components.PullRequestPage.18f2af42ac', 'Check reruns requested') ) - await handleRefresh() + await handleRefresh(rerunContextOwner) } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.788a782bb0', 'Failed to rerun checks') - ) + if (mountedRef.current && committedChecksContextOwnerRef.current === rerunContextOwner) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.PullRequestPage.788a782bb0', 'Failed to rerun checks') + ) + } } finally { - setRerunning(false) + if (mountedRef.current) { + setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) + } } }, [ @@ -3463,6 +3539,7 @@ function ChecksTab({ headSha, item.number, item.repoId, + mountedRef, prRepo, runtimeHost, rerunning, @@ -3525,77 +3602,74 @@ function ChecksTab({ } }, [failedChecks.length, fixingChecks, item, list, targetRepoId]) - const handleToggleCheckDetails = useCallback( - (check: PRCheckDetail): void => { - const key = getCheckDetailsKey(check) - setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) - if ( - !canUseChecksRepoContext || - detailsByCheckKey[key] || - (!check.checkRunId && !check.workflowRunId && !check.url) - ) { + const requestCheckDetails = useCallback( + (check: PRCheckDetail, key: string): void => { + if (!canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { return } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: true, - details: null, - error: null - }) - ) - const detailsRequest = runtimeHost - ? callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prCheckDetails', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + const requestId = ++nextCheckDetailsRequestIdRef.current + const commit = (next: Omit): void => { + if (!mountedRef.current) { + return + } + setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) + } + setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) + const detailsRequest = withGitHubCheckDetailsTimeout((signal) => + runtimeHost + ? callRuntimeRpc>>( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.prCheckDetails', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo + }, + { timeoutMs: 30_000, signal } + ) + : window.api.gh.prCheckDetails({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, checkRunId: check.checkRunId, workflowRunId: check.workflowRunId, checkName: check.name, url: check.url, prRepo - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prCheckDetails({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }) + }) + ) void detailsRequest .then((details) => { - if (!mountedRef.current) { - return - } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: false, - details, - error: details ? null : 'No inline details are available for this check.' - }) - ) + commit({ + loading: false, + details, + error: details + ? null + : translate( + 'auto.components.PullRequestPage.6b1d5ee3e4', + 'No inline details are available for this check.' + ) + }) }) .catch((err) => { - if (!mountedRef.current) { - return - } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: false, - details: null, - error: err instanceof Error ? err.message : 'Failed to load check details.' - }) - ) + commit({ + loading: false, + details: null, + error: + err instanceof Error + ? err.message + : translate( + 'auto.components.PullRequestPage.e04c027d98', + 'Failed to load check details.' + ) + }) }) }, [ canUseChecksRepoContext, - detailsByCheckKey, item.repoId, mountedRef, runtimeHost, @@ -3606,6 +3680,18 @@ function ChecksTab({ ] ) + const handleToggleCheckDetails = useCallback( + (check: PRCheckDetail): void => { + const key = getCheckDetailsKey(check) + setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) + if (detailsByCheckKey[key]) { + return + } + requestCheckDetails(check, key) + }, + [detailsByCheckKey, requestCheckDetails] + ) + const refreshAction = ( @@ -3799,7 +3885,7 @@ function ChecksTab({ return (
- {state?.loading ? ( + {state?.loading && !state.error ? (
{translate('auto.components.PullRequestPage.d8e82b7f15', 'Loading check details…')} @@ -3830,7 +3916,27 @@ function ChecksTab({ )}
- {state?.error &&
{state.error}
} + {state?.error && ( +
+
+ {state.error} +
+ +
+ )} {hasOutput && (
diff --git a/src/renderer/src/components/QuickOpen.mount-gating.test.tsx b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx new file mode 100644 index 00000000000..fa0c57dfcf8 --- /dev/null +++ b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import QuickOpen from './QuickOpen' + +const contentProbe = vi.hoisted(() => ({ + enabled: vi.fn(), + renders: vi.fn(), + storeNotifications: vi.fn(), + subscriptions: vi.fn(), + unsubscriptions: vi.fn() +})) + +vi.mock('@/components/quick-open-file-list', async () => { + const React = await import('react') + return { + useRuntimeFileListForWorktree: ({ enabled }: { enabled: boolean }) => { + contentProbe.enabled(enabled) + contentProbe.renders() + React.useEffect(() => { + contentProbe.subscriptions() + const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) + return () => { + contentProbe.unsubscriptions() + unsubscribe() + } + }, []) + return { files: [], loading: false, loadError: null } + } + } +}) + +vi.mock('@/hooks/useModalReturnFocus', () => ({ + useModalReturnFocus: () => ({ captureReturnFocus: vi.fn(), skipReturnFocus: vi.fn() }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/command', () => { + return { + CommandDialog: ({ children, open }: { children: ReactNode; open?: boolean }) => + open ?
{children}
: null, + CommandInput: () => , + CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, + CommandItem: ({ children }: { children: ReactNode }) =>
{children}
+ } +}) + +const initialAppState = useAppStore.getInitialState() +let testContainer: HTMLDivElement +let testRoot: Root + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function activeContentSubscriptions(): number { + return ( + contentProbe.subscriptions.mock.calls.length - contentProbe.unsubscriptions.mock.calls.length + ) +} + +async function churnClosedStore(count: number): Promise { + await act(async () => { + for (let index = 0; index < count; index += 1) { + useAppStore.setState({ activeWorktreeId: `remote-background-${index}` }) + } + }) +} + +describe('QuickOpen mount gating', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + contentProbe.enabled.mockClear() + contentProbe.renders.mockClear() + contentProbe.storeNotifications.mockClear() + contentProbe.subscriptions.mockClear() + contentProbe.unsubscriptions.mockClear() + useAppStore.setState(initialAppState, true) + useAppStore.setState({ activeModal: 'quick-open', activeWorktreeId: null }) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => testRoot.unmount()) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('unmounts subscribed content after the close linger', async () => { + await act(async () => testRoot.render()) + await flushEffects() + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => useAppStore.setState({ activeModal: 'none' })) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).toBeNull() + expect(contentProbe.enabled).toHaveBeenLastCalledWith(false) + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => vi.advanceTimersByTimeAsync(299)) + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(activeContentSubscriptions()).toBe(0) + + const rendersAfterUnmount = contentProbe.renders.mock.calls.length + const notificationsAfterUnmount = contentProbe.storeNotifications.mock.calls.length + await churnClosedStore(1_000) + + expect(contentProbe.renders).toHaveBeenCalledTimes(rendersAfterUnmount) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(notificationsAfterUnmount) + }) + + it('cancels the pending unmount when reopened during the linger', async () => { + await act(async () => testRoot.render()) + await flushEffects() + + await act(async () => useAppStore.setState({ activeModal: 'none' })) + await act(async () => vi.advanceTimersByTimeAsync(299)) + await act(async () => useAppStore.setState({ activeModal: 'quick-open' })) + await act(async () => vi.advanceTimersByTimeAsync(1_000)) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + }) +}) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index ae46f4dc0a7..cb7c8847642 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useDeferredValue, useMemo, useState } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' import { detectLanguage } from '@/lib/language-detect' @@ -21,6 +21,8 @@ import { QuickOpenInstallRgGuidance } from '@/components/quick-open-install-rg-guidance' +const QUICK_OPEN_CLOSE_LINGER_MS = 300 + function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Element { return ( @@ -31,6 +33,24 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme export default function QuickOpen(): React.JSX.Element | null { const visible = useAppStore((s) => s.activeModal === 'quick-open') + const [lingering, setLingering] = useState(visible) + useEffect(() => { + if (visible) { + setLingering(true) + return + } + // Why: keep scan cancellation and the dialog exit animation mounted before releasing remote file state. + const timer = window.setTimeout(() => setLingering(false), QUICK_OPEN_CLOSE_LINGER_MS) + return () => window.clearTimeout(timer) + }, [visible]) + + if (!visible && !lingering) { + return null + } + return +} + +function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element { const closeModal = useAppStore((s) => s.closeModal) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const openFile = useAppStore((s) => s.openFile) @@ -119,6 +139,7 @@ export default function QuickOpen(): React.JSX.Element | null { placeholder={translate('auto.components.QuickOpen.1cb6ef47b7', 'Go to file...')} value={query} onValueChange={setQuery} + className="!h-9 !py-2" /> {loading ? ( @@ -155,13 +176,14 @@ export default function QuickOpen(): React.JSX.Element | null { key={item.path} value={item.path} onSelect={() => handleSelect(item.path)} - className="min-w-0 p-0" + // Why: CommandDialog's descendant rule otherwise adds 24px of vertical padding. + className="min-w-0 !p-0" > {/* Why: the trigger is this inner element, not the CommandItem. cmdk sets its own onPointerMove after spreading props, which drops the one Radix needs to open the tooltip. */} -
+
{/* shrink-0 + max-w-full: the directory gives up all of its width before the filename loses a character. */} diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 6b86b16a000..29bc0bc4587 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -43,6 +43,7 @@ import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { compareNumericLocaleText } from '@/lib/locale-text-collators' import { getSettingsFocusedExecutionHostId, parseExecutionHostId, @@ -846,7 +847,7 @@ function compareLinearIssues(a: LinearIssue, b: LinearIssue, orderBy: LinearOrde return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() } if (orderBy === 'identifier') { - return a.identifier.localeCompare(b.identifier, undefined, { numeric: true }) + return compareNumericLocaleText(a.identifier, b.identifier) } const priorityDelta = getLinearPriorityRank(a.priority) - getLinearPriorityRank(b.priority) @@ -6959,7 +6960,7 @@ export default function TaskPage(): React.JSX.Element { const authorityPage = pages.findIndex((page) => page?.some((item) => authorityItemKeys.has(taskPageGitHubItemKey(item.repoId, item.id))) ) - const quietPage = authorityPage >= 0 ? authorityPage : currentPage + const quietPage = authorityPage !== -1 ? authorityPage : currentPage const visiblePage = currentPage > quietPage ? currentPage : undefined const pageItemKeys = (page: number): Set => new Set((pages[page] ?? []).map((item) => taskPageGitHubItemKey(item.repoId, item.id))) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index dc39c3a0644..d555a92d22c 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -12,6 +12,7 @@ import { } from '@/constants/terminal' import { useAppStore } from '../store' import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { useAllWorktrees } from '../store/selectors' import { getConnectionId } from '../lib/connection-context' import { basename } from '../lib/path' @@ -30,19 +31,14 @@ import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, + type EditorRequestCmdSaveDetail, type EditorRequestFileCloseDetail, requestEditorSaveQuiesce } from './editor/editor-autosave' import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import { preventUnloadAndScheduleShutdownCheckpointReset } from '@/lib/shutdown-checkpoint-guard' import EditorAutosaveController from './editor/EditorAutosaveController' -import type { - Tab, - TabContentType, - TabGroupLayoutNode, - TerminalTab, - TuiAgent -} from '../../../shared/types' +import type { Tab, TabGroupLayoutNode, TerminalTab, TuiAgent } from '../../../shared/types' import { hasFeatureInteraction } from '../../../shared/feature-interactions' import BrowserPane from './browser-pane/BrowserPane' import { RetainedBrowserPaneOverlayLayer } from './browser-pane/BrowserPaneOverlayLayer' @@ -185,20 +181,19 @@ import { combineTerminalWorktreeParkIds, useManualTerminalWorktreeParking } from './terminal-pane/use-manual-terminal-worktree-parking' +import { EDITOR_TAB_CONTENT_TYPES, getEditorCmdSaveFileId } from './editor/editor-cmd-save-target' +import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' const EditorPanel = lazy(() => import('./editor/EditorPanel')) // Why: gate handler runs after a dialog advances so a stray carry-over click can't act on the next dialog; ~200ms absorbs a physical double-click while staying responsive. const CLOSE_DIALOG_DEBOUNCE_MS = 200 -const EDITOR_TAB_CONTENT_TYPES = new Set([ - 'editor', - 'diff', - 'conflict-review', - 'check-details' -]) - type TerminalStoreSnapshot = ReturnType +function showClientCreationActionError(error: unknown): void { + toast.error(error instanceof Error ? error.message : String(error)) +} + function haveSameIdSet(left: ReadonlySet, right: ReadonlySet): boolean { if (left.size !== right.size) { return false @@ -1608,7 +1603,7 @@ function Terminal(): React.JSX.Element | null { void openMobileEmulatorTab(activeWorktreeId, { placement: 'rightSplit', targetGroupId: targetGroupId ?? undefined - }) + }).catch(showClientCreationActionError) }, [activeWorktreeId]) const handleNewBrowserTab = useCallback(() => { @@ -1619,22 +1614,31 @@ function Terminal(): React.JSX.Element | null { useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ?? useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id if (targetGroupId) { - void openNewBrowserTabInActiveWorkspace(targetGroupId) + void openNewBrowserTabInActiveWorkspace(targetGroupId).catch(showClientCreationActionError) return } - const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' + const state = useAppStore.getState() + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } + const defaultUrl = state.browserDefaultUrl ?? 'about:blank' const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + if (browserAvailability.provider === 'paired-runtime' && runtimeEnvironmentId) { void createWebRuntimeSessionBrowserTab({ worktreeId: activeWorktreeId, environmentId: runtimeEnvironmentId, url: defaultUrl - }) + }).catch(showClientCreationActionError) return } createBrowserTab(activeWorktreeId, defaultUrl, { title: translate('auto.components.Terminal.37da0d736f', 'New Browser Tab'), - focusAddressBar: true + focusAddressBar: true, + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) }) }, [activeWorktreeId, createBrowserTab, openNewBrowserTabInActiveWorkspace]) @@ -1654,8 +1658,16 @@ function Terminal(): React.JSX.Element | null { return } const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } if ( - isWebRuntimeSessionActive(runtimeEnvironmentId) && + browserAvailability.provider === 'paired-runtime' && + runtimeEnvironmentId && browserWorkspaceHasRemoteOwner(state, source.id, runtimeEnvironmentId) ) { void createWebRuntimeSessionBrowserTab({ @@ -1663,12 +1675,17 @@ function Terminal(): React.JSX.Element | null { environmentId: runtimeEnvironmentId, url: source.url, profileId: source.sessionProfileId - }) + }).catch(showClientCreationActionError) return } - createBrowserTab(activeWorktreeId, source.url, { - ...buildDuplicatedBrowserTabOptions(source) - }) + try { + createBrowserTab(activeWorktreeId, source.url, { + ...buildDuplicatedBrowserTabOptions(source), + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) + }) + } catch (error) { + showClientCreationActionError(error) + } }, [activeWorktreeId, createBrowserTab] ) @@ -2043,7 +2060,11 @@ function Terminal(): React.JSX.Element | null { if (!e.repeat && matchShortcut('tab.reopenClosed')) { e.preventDefault() notifyTerminalCapture('tab.reopenClosed') - useAppStore.getState().reopenClosedTab(activeWorktreeId) + try { + useAppStore.getState().reopenClosedTab(activeWorktreeId) + } catch (error) { + showClientCreationActionError(error) + } return } @@ -2051,8 +2072,18 @@ function Terminal(): React.JSX.Element | null { if (!e.repeat && matchShortcut('tab.newBrowser')) { e.preventDefault() notifyTerminalCapture('tab.newBrowser') + const browserAvailability = getClientCreationActionPolicy( + useAppStore.getState(), + floatingWorkspaceFocused ? FLOATING_TERMINAL_WORKTREE_ID : activeWorktreeId + )['managed-browser'] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } if (floatingWorkspaceFocused) { - void createFloatingWorkspaceBrowserTab(useAppStore.getState()) + void createFloatingWorkspaceBrowserTab(useAppStore.getState()).catch( + showClientCreationActionError + ) return } handleNewBrowserTab() @@ -2063,6 +2094,14 @@ function Terminal(): React.JSX.Element | null { if (!e.repeat && mobileEmulatorEnabled && matchShortcut('tab.newSimulator')) { e.preventDefault() notifyTerminalCapture('tab.newSimulator') + const simulatorAvailability = getClientCreationActionPolicy( + useAppStore.getState(), + activeWorktreeId + )['mobile-emulator'] + if (simulatorAvailability.state !== 'enabled') { + toast.error(simulatorAvailability.reason) + return + } if (!floatingWorkspaceFocused) { handleNewSimulatorTab() } @@ -2077,10 +2116,17 @@ function Terminal(): React.JSX.Element | null { target?.closest('textarea:not(.xterm-helper-textarea), input') !== null if (!inEditor) { const state = useAppStore.getState() - if (state.activeTabType === 'editor' && state.activeFileId) { + const floatingPanelOwnsEvent = + isEventTargetInsideFloatingWorkspacePanel(e.target) || floatingWorkspaceFocused + const requestedFileId = getEditorCmdSaveFileId(state, floatingPanelOwnsEvent) + if (requestedFileId) { e.preventDefault() notifyTerminalCapture('editor.save') - window.dispatchEvent(new Event(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT)) + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, { + detail: { fileId: requestedFileId } + }) + ) return } } diff --git a/src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx new file mode 100644 index 00000000000..85cb356fbc1 --- /dev/null +++ b/src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx @@ -0,0 +1,198 @@ +// @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 * as ReactI18Next from 'react-i18next' +import { useAppStore } from '@/store' +import WorktreeJumpPalette from './WorktreeJumpPalette' + +const contentProbe = vi.hoisted(() => ({ + renders: vi.fn(), + storeNotifications: vi.fn(), + subscriptions: vi.fn(), + unsubscriptions: vi.fn() +})) + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key + }) + } +}) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/hooks/useSettingsNavigationMetadata', async () => { + const React = await import('react') + return { + useSettingsNavigationMetadata: () => { + contentProbe.renders() + React.useEffect(() => { + contentProbe.subscriptions() + const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) + return () => { + contentProbe.unsubscriptions() + unsubscribe() + } + }, []) + return [] + } + } +}) + +vi.mock('@/components/sidebar/StatusIndicator', () => ({ + default: () => +})) + +vi.mock('@/components/repo/RepoBadgeLabel', () => ({ + RepoBadgeMark: () => +})) + +vi.mock('@/components/cmd-j/palette-host-badge', () => ({ + getPaletteHostBadge: () => null +})) + +vi.mock('@/components/ui/command', async () => { + const React = await import('react') + return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => + open ?
{children}
: null, + CommandInput: React.forwardRef(function CommandInput( + _props: Record, + ref: React.ForwardedRef + ) { + return + }), + CommandList: React.forwardRef(function CommandList( + { children }: { children: React.ReactNode }, + ref: React.ForwardedRef + ) { + return
{children}
+ }), + CommandEmpty: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandItem: ({ children }: { children: React.ReactNode }) => ( + + ) + } +}) + +const initialAppState = useAppStore.getInitialState() +let testContainer: HTMLDivElement +let testRoot: Root + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function churnClosedStore(count: number): Promise { + await act(async () => { + for (let index = 0; index < count; index += 1) { + useAppStore.setState({ activeWorktreeId: `background-${index}` }) + } + }) +} + +function activeContentSubscriptions(): number { + return ( + contentProbe.subscriptions.mock.calls.length - contentProbe.unsubscriptions.mock.calls.length + ) +} + +describe('WorktreeJumpPalette mount gating', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + contentProbe.renders.mockClear() + contentProbe.storeNotifications.mockClear() + contentProbe.subscriptions.mockClear() + contentProbe.unsubscriptions.mockClear() + useAppStore.setState(initialAppState, true) + useAppStore.setState({ activeModal: 'none', activeWorktreeId: null }) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => testRoot.unmount()) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('mounts content on demand and unmounts it after the close linger', async () => { + await act(async () => testRoot.render()) + await flushEffects() + + await churnClosedStore(1_000) + + expect(contentProbe.renders).not.toHaveBeenCalled() + expect(contentProbe.subscriptions).not.toHaveBeenCalled() + expect(contentProbe.storeNotifications).not.toHaveBeenCalled() + + await act(async () => { + useAppStore.getState().openModal('worktree-palette') + }) + await flushEffects() + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(contentProbe.renders).toHaveBeenCalled() + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => { + useAppStore.getState().closeModal() + }) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).toBeNull() + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => vi.advanceTimersByTimeAsync(299)) + expect(activeContentSubscriptions()).toBe(1) + + const lingerNotifications = contentProbe.storeNotifications.mock.calls.length + await act(async () => useAppStore.setState({ activeWorktreeId: 'during-close-linger' })) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(lingerNotifications + 1) + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(activeContentSubscriptions()).toBe(0) + + const rendersAfterUnmount = contentProbe.renders.mock.calls.length + const notificationsAfterUnmount = contentProbe.storeNotifications.mock.calls.length + await churnClosedStore(100) + + expect(contentProbe.renders).toHaveBeenCalledTimes(rendersAfterUnmount) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(notificationsAfterUnmount) + }) + + it('cancels the pending unmount when reopened during the linger', async () => { + await act(async () => testRoot.render()) + await act(async () => useAppStore.getState().openModal('worktree-palette')) + await flushEffects() + + await act(async () => useAppStore.getState().closeModal()) + await act(async () => vi.advanceTimersByTimeAsync(299)) + await act(async () => useAppStore.getState().openModal('worktree-palette')) + await act(async () => vi.advanceTimersByTimeAsync(1_000)) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 68de1317688..3e4037d4761 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -69,6 +69,8 @@ vi.mock('@/lib/workspace-tab-palette-activation', () => ({ vi.mock('@/components/ui/command', async () => { const React = await import('react') return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, // Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled // value is the only honest stand-in for "what would Enter activate" without mounting real cmdk. CommandDialog: ({ @@ -313,6 +315,28 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { expect(getCommandValue()).toBe('workspace-tab:tab-host') }) + // Why: after typing, arrow moves must stick. Dropping onValueChange while cmdk already + // advanced its internal cursor made the next ArrowDown a no-op (Object.is short-circuit). + it('keeps arrow selection after the typed query ranking has committed', async () => { + await renderPalette(makeTypedRelevanceState()) + + await act(async () => { + setCommandQuery?.('perf') + }) + await flushEffects() + expect(getCommandValue()).toBe('workspace-tab:tab-host') + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows.length).toBeGreaterThan(1) + + await act(async () => { + setCommandSelection?.(rows[1]) + }) + await flushEffects() + + expect(getCommandValue()).toBe(rows[1]) + }) + it('keeps worktrees ahead of tabs when a worktree holds the stronger match', async () => { await renderPalette({ ...makeTypedRelevanceState(), diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index cd1149f7224..47215a8c941 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -2,6 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { fireEvent } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactI18Next from 'react-i18next' import { useAppStore } from '@/store' @@ -74,25 +75,40 @@ vi.mock('@/components/ui/command', async () => {
) : null }, - CommandInput: ({ - value, - onValueChange, - placeholder - }: { - value?: string - onValueChange?: (next: string) => void - placeholder?: string - }) => { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandInput: React.forwardRef(function CommandInput( + { + value, + onValueChange, + placeholder, + onClick, + onSelect, + onKeyDown + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + onClick?: React.MouseEventHandler + onSelect?: React.ReactEventHandler + onKeyDown?: React.KeyboardEventHandler + }, + ref: React.ForwardedRef + ) { setCommandQuery = onValueChange ?? null return ( onValueChange?.(event.currentTarget.value)} + onClick={onClick} + onSelect={onSelect} + onKeyDown={onKeyDown} /> ) - }, + }), CommandList: React.forwardRef(function CommandList( { children }: { children: React.ReactNode }, ref: React.ForwardedRef @@ -337,4 +353,16 @@ describe('WorktreeJumpPalette', () => { expect(testContainer.textContent).toContain('Feature workspace') }) + + it('replaces a completed emoji shortcode in the search query', async () => { + await renderPalette({ worktreesByRepo: { 'repo-1': [] } }) + const input = testContainer.querySelector('[data-command-input="true"]') + expect(input).not.toBeNull() + + await act(async () => { + fireEvent.change(input!, { target: { value: ':wink:', selectionStart: 6 } }) + }) + + expect(input?.value).toBe('😉') + }) }) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 89068eb489b..45a31847834 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -54,6 +54,11 @@ import { isDefaultBranchWorkspace, isSleepingSweepExemptWorkspace } from '@/components/sidebar/visible-worktrees' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment, + isWorkspaceFromOtherDevice +} from '@/components/sidebar/workspace-creator-visibility' import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' import { @@ -79,7 +84,8 @@ import { createWorktreePaletteRequestGuard, getNextWorktreePaletteSelection, getWorktreePaletteSelectionItemIds, - getWorktreePaletteCreateActionState + getWorktreePaletteCreateActionState, + type WorktreePaletteRequestGuard } from '@/lib/worktree-palette-create-action' import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { @@ -169,6 +175,8 @@ import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-foc import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' import { usePluginCommands } from '@/store/plugin-panels' import { getComposerEligibleRepos, @@ -260,8 +268,11 @@ type PaletteListEntry = PaletteItem | CreateWorktreePaletteItem | SectionHeader const CREATE_WORKSPACE_QUICK_ACTION_ITEM_ID = `quick-action:${CREATE_WORKSPACE_QUICK_ACTION_ID}` -// Why: outlast the CommandDialog close animation (~150–200ms) so gated status maps stay live until fading rows are gone. -const PALETTE_STATUS_INPUTS_LINGER_MS = 300 +// Why: outlast the CommandDialog close animation so its rows do not disappear mid-fade. +const PALETTE_CLOSE_LINGER_MS = 300 +// Why `jump-palette-item`: selection chrome lives in main.css — flat accent is invisible on light popovers. +const JUMP_PALETTE_ITEM_CLASSNAME = + 'jump-palette-item group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 text-left outline-none transition-[background-color,box-shadow]' type OpenTabPaletteItem = BrowserPaletteItem | SimulatorPaletteItem | WorkspaceTabPaletteItem @@ -310,8 +321,8 @@ function shouldIncludeOpenTabInRecentSection({ worktree: Worktree row: RecentWorkspaceTabRow paneSources: TabPaneInputSources - unreadTerminalTabs: Record - unreadAgentCompletionPanes: Record + unreadTerminalTabs: Record + unreadAgentCompletionPanes: Record now: number }): boolean { if (worktree.isArchived) { @@ -506,9 +517,42 @@ function getSettingsTargetFromSectionId(sectionId: string): { } export default function WorktreeJumpPalette(): React.JSX.Element | null { + const visible = useAppStore((s) => s.activeModal === 'worktree-palette') + const [lingering, setLingering] = useState(visible) + useEffect(() => { + if (visible) { + setLingering(true) + return + } + const timer = window.setTimeout(() => setLingering(false), PALETTE_CLOSE_LINGER_MS) + return () => window.clearTimeout(timer) + }, [visible]) + // Why: reopening must invalidate a pending create lookup from the previous content mount. + const createLookupGuard = useMemo(() => createWorktreePaletteRequestGuard(), []) + + if (!visible && !lingering) { + return null + } + return ( + + ) +} + +function WorktreeJumpPaletteContent({ + visible, + lingering, + createLookupGuard +}: { + visible: boolean + lingering: boolean + createLookupGuard: WorktreePaletteRequestGuard +}): React.JSX.Element | null { // Why: subscribe to language changes so translated memos recompute without a fake i18n.language dependency. useTranslation() - const visible = useAppStore((s) => s.activeModal === 'worktree-palette') const closeModal = useAppStore((s) => s.closeModal) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -524,27 +568,14 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations) const pluginCommands = usePluginCommands() - // Why: keep status maps subscribed through the close animation — dropping them while CommandDialog fades out would flash rows empty mid-animation. - const [statusInputsLingering, setStatusInputsLingering] = useState(false) - useEffect(() => { - if (visible) { - setStatusInputsLingering(true) - return - } - const timer = window.setTimeout( - () => setStatusInputsLingering(false), - PALETTE_STATUS_INPUTS_LINGER_MS - ) - return () => window.clearTimeout(timer) - }, [visible]) - const paletteStatusInputsActive = visible || statusInputsLingering - // Why: these hot status maps get a new identity on every app-wide write, so gate the subscription on active-or-closing to stop the always-mounted palette re-rendering on unrelated terminals. + const paletteStatusInputsActive = visible || lingering + // Why: keep hot status maps live through the shell's close-animation linger. // Why: ptyIdsByTabId must be included — slept tabs keep a wake-hint sessionId in tab.ptyId, so without it the palette dot would lie green. const { ptyIdsByTabId, terminalLayoutsByTabId, tabsByWorktree } = useAppStore( useShallow((s) => selectPaletteStatusInputs(s, paletteStatusInputsActive)) ) const { prCache, issueCache, hostedReviewCache } = useAppStore( - useShallow((s) => selectWorktreePaletteCacheInputs(s, visible || statusInputsLingering)) + useShallow((s) => selectWorktreePaletteCacheInputs(s, paletteStatusInputsActive)) ) const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) const activeView = useAppStore((s) => s.activeView) @@ -592,6 +623,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) @@ -608,7 +640,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) const [selectedItemId, setSelectedItemId] = useState('') - const latestQueryRef = useRef('') // Why: the id cmdk auto-selected for the last committed list, so a late recent-order snapshot can // tell "nobody has moved the highlight yet" from "the user arrowed somewhere deliberately". const autoSelectedItemIdRef = useRef(null) @@ -632,7 +663,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const inputRef = useRef(null) const fallbackFocusOuterFrameRef = useRef(null) const fallbackFocusInnerFrameRef = useRef(null) - const createLookupGuard = useMemo(() => createWorktreePaletteRequestGuard(), []) const preserveCreateLookupOnCloseRef = useRef(false) const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos]) @@ -726,6 +756,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ), [agentStatusByPaneKey, tabsByWorktree] ) + const pairedDeviceIdsByEnvironment = useMemo( + () => + hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] + ) // Why: empty-query mirrors sidebar filters so Search opens on the same quiet list; typed search widens to global non-archived scope. const emptyQueryVisibleWorktrees = useMemo( @@ -751,6 +788,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { if (hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(worktree)) { return false } + if ( + hideWorkspacesFromOtherDevices && + isWorkspaceFromOtherDevice(worktree, pairedDeviceIdsByEnvironment) + ) { + return false + } if ( !showSleepingWorkspaces && // Why the exemption here too: Cmd+J re-implements the sidebar's @@ -777,6 +820,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { hideCliCreatedWorkspaces, hideDefaultBranchWorkspace, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, ptyIdsByTabId, showSleepingWorkspaces, tabsByWorktree, @@ -1435,23 +1480,37 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // Why: filtering via buildQuickActionContext() inside a memo with stable primitive deps // instead of calling it inline every render — a fresh context object as a useMemo dep // defeated the middleItems memo (new identity every keystroke). + const quickActionAvailabilityInputs = useMemo( + () => ({ + activeView, + activeWorktreeId, + worktreesByRepo, + repos, + sshConnectionStates, + activeGroupIdByWorktree, + groupsByWorktree, + isLoading, + activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + runtimeStatusByEnvironmentId + }), + [ + activeView, + activeWorktreeId, + worktreesByRepo, + repos, + sshConnectionStates, + activeGroupIdByWorktree, + groupsByWorktree, + isLoading, + settings?.activeRuntimeEnvironmentId, + runtimeStatusByEnvironmentId + ] + ) const availableActionResults = useMemo(() => { + void quickActionAvailabilityInputs const ctx = buildQuickActionContext() return actionResults.filter((action) => action.isAvailable(ctx).available) - }, [ - actionResults, - buildQuickActionContext, - // oxlint-disable-next-line react-hooks/exhaustive-deps -- these are the availability-determining primitives buildQuickActionContext reads from the store; listing them ensures the memo recomputes when availability actually changes, not on every render. - activeView, - activeWorktreeId, - worktreesByRepo, - repos, - sshConnectionStates, - activeGroupIdByWorktree, - groupsByWorktree, - isLoading, - settings?.activeRuntimeEnvironmentId - ]) + }, [actionResults, buildQuickActionContext, quickActionAvailabilityInputs]) const middleItems = useMemo<(SettingsPaletteItem | QuickActionPaletteItem)[]>( () => @@ -1828,7 +1887,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ? document.activeElement : null skipRestoreFocusRef.current = false - latestQueryRef.current = '' setQuery('') setSelectedItemId('') // Why: reset on open, not on close — closing races the fade-out, and a @@ -1865,16 +1923,23 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { showCreateAction }) - const handleCommandSelectionChange = useCallback( - (nextItemId: string) => { - // Why: cmdk can report the old list head before the deferred query commits its new ranking. - if (latestQueryRef.current !== deferredQuery) { - return - } - setSelectedItemId(nextItemId) - }, - [deferredQuery] - ) + // Why: cmdk writes its internal cursor *before* calling onValueChange. Dropping that + // callback (e.g. while deferredQuery lags the keystroke) leaves internal state advanced + // while the controlled `value` prop stays put — the next ArrowDown/Up then no-ops on + // cmdk's Object.is guard, so keyboard navigation dies after typing. Always accept the + // report so the cursor stays aligned; the deferred-commit effect below re-auto-selects + // the new ranking head once the list the user sees actually changes. + const handleCommandSelectionChange = useCallback((nextItemId: string) => { + setSelectedItemId(nextItemId) + }, []) + + // Why: while query is mid-deferral, cmdk (and mid-lag arrows) can latch a selection + // against the previous ranking. When the deferred list commits, snap Enter back to the + // new head — matching handleQueryChange's clear, but covering selection that landed + // after the keystroke and before this commit. + useLayoutEffect(() => { + setSelectedItemId('') + }, [deferredQuery]) useEffect(() => { const isCreateWorkspaceHighlighted = @@ -1888,11 +1953,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }, [commandSelectedItemId, prefetchCreateWorkspaceBaseForComposer, visible]) const handleQueryChange = useCallback((nextQuery: string) => { - latestQueryRef.current = nextQuery setQuery(nextQuery) setSelectedItemId('') listRef.current?.scrollTo(0, 0) }, []) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + inputRef, + onValueChange: handleQueryChange, + value: query + }) const cancelFallbackFocusFrames = useCallback((): void => { if (fallbackFocusOuterFrameRef.current !== null) { @@ -2455,7 +2524,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'Search chats, terminals, worktrees, settings, and actions...' )} value={query} - onValueChange={handleQueryChange} + onValueChange={emojiInput.handleValueChange} + onClick={(event) => emojiInput.syncCursor(event.currentTarget)} + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => emojiInput.handleKeyDown(event)} wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]" iconClassName="mr-2.5 h-4 w-4 text-muted-foreground/60" className="h-12 text-[14px] placeholder:text-muted-foreground/75" @@ -2471,6 +2543,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
} /> + !open && emojiInput.close()} + portalContainer={dialogElement} + side="bottom" + contentClassName="w-80" + />