diff --git a/.gitattributes b/.gitattributes index 1aa7969e805..1b447a9189e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -31,6 +31,10 @@ # the reviewable change, and pin LF because they are compared byte-for-byte. # Not -diff: the shell diff is the review surface when a wrapper does change. /src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf +# Captured agent PTY transcripts. -text, not `text eol=lf` like the wrapper snapshots above: +# these carry real CR and CRLF bytes as the terminal emitted them, and line-ending +# normalisation on a Windows checkout would rewrite the evidence the fixture exists to be. +/src/main/runtime/__fixtures__/*.txt -text # Generated runtime English subset: compared byte-for-byte by # verify:localization-runtime-catalog, so a CRLF checkout would fail the gate. /src/renderer/src/i18n/en-runtime-required.json linguist-generated=true text eol=lf diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 491d0cac794..456fa8f2831 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -21,6 +21,11 @@ pnpm install pnpm dev ``` +Ordinary installs include native optional dependencies for the current OS and CPU only. +Before a cross-architecture build (including `pnpm build:mac`, which produces both x64 and +arm64 artifacts by default), run `pnpm install:release` to add the other CPU's variants. +See [the install policy](../docs/reference/pnpm-install-policy.md). + ## Branch Naming Use a clear, descriptive branch name that reflects the change. diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 7695d2bec9b..30c98070f31 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -10,6 +10,10 @@ inputs: description: Node.js version override; defaults to the version declared in package.json. required: false default: '' + cache-dependency-path: + description: Lockfiles for the pnpm download store; include mobile/pnpm-lock.yaml only when the job installs mobile dependencies. + required: false + default: pnpm-lock.yaml persist-native-cache: description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI. required: false @@ -39,9 +43,7 @@ runs: with: install: false - # Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so - # jobs that also install mobile restored a store with none of the React Native tree - # in it and re-downloaded the lot on every run. + # Desktop-only jobs should not miss their download cache when mobile dependencies change. - name: Setup Node.js id: default-node if: inputs.node-version == '' @@ -49,9 +51,7 @@ runs: with: node-version-file: package.json cache: pnpm - cache-dependency-path: | - pnpm-lock.yaml - mobile/pnpm-lock.yaml + cache-dependency-path: ${{ inputs.cache-dependency-path }} - name: Setup requested Node.js id: requested-node @@ -60,9 +60,7 @@ runs: with: node-version: ${{ inputs.node-version }} cache: pnpm - cache-dependency-path: | - pnpm-lock.yaml - mobile/pnpm-lock.yaml + cache-dependency-path: ${{ inputs.cache-dependency-path }} - name: Validate native runtime shell: bash @@ -152,9 +150,9 @@ runs: with: path: | node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build - node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + native/windows-registry/build + node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} - name: Restore compiled native modules without saving id: native-cache-restore-only @@ -163,9 +161,9 @@ runs: with: path: | node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build - node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + native/windows-registry/build + node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} # pnpm's bundled gyp_main.py is not executable on fresh Linux runners. - name: Use external node-gyp diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 3e17eee9b68..53501add289 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -160,16 +160,14 @@ jobs: - name: Checkout the requested ref uses: actions/checkout@v6 - env: - # Full-history checkout must also preserve case-twin branch and tag names. - GIT_DEFAULT_REF_FORMAT: reftable with: # Why an input at all rather than just github.ref: the whole point is to # build code that has not landed, and the workflow definition itself # always comes from the dispatch ref — naming the branch here instead # applies main's current copy of this file to an arbitrary branch. ref: ${{ steps.vetted.outputs.sha }} - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # This job only reads stablyai/orca and never pushes; every write goes # to the adhoc repo through a minted App token passed by env. Not # persisting the checkout credential shrinks the blast radius if a build @@ -197,13 +195,15 @@ jobs: restore-keys: | electron-builder-mac- + # Why both CPUs: the mac config packages x64 and arm64 from this arm64 + # runner, so the install must carry both variants of the native optional deps. - name: Install dependencies uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile + command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 # Why: signing is what makes an adhoc build installable over an existing # Orca, so a missing cert must fail here rather than after a 20-minute build. diff --git a/.github/workflows/cloud-deploy-relay-production-director.yml b/.github/workflows/cloud-deploy-relay-production-director.yml index 4489d67b845..07e1e74ec3c 100644 --- a/.github/workflows/cloud-deploy-relay-production-director.yml +++ b/.github/workflows/cloud-deploy-relay-production-director.yml @@ -13,6 +13,11 @@ on: default: preserve type: choice options: [preserve, enable, disable] + region-correction-cohort-percent: + description: 'Preserve the measured-correction cohort, or set an integer 0–100; durable rehome stays disabled' + required: true + default: preserve + type: string prune-incompatible-revisions: description: Retain only the newly verified serving and rollback revisions required: true @@ -62,6 +67,7 @@ jobs: REGIONAL_PLACEMENT_SECRET: orca-cloud-relay-regional-placement-enabled IMAGE_DIGEST: ${{ inputs.image-digest }} REGIONAL_PLACEMENT_MODE: ${{ inputs.regional-placement-mode }} + REGION_CORRECTION_COHORT_PERCENT: ${{ inputs.region-correction-cohort-percent }} PRUNE_INCOMPATIBLE_REVISIONS: ${{ inputs.prune-incompatible-revisions }} # Floor the served revision must keep, matching relay_min_instances in # environments/production.tfvars. This gate only fails a bad deploy; Terraform @@ -106,8 +112,11 @@ jobs: echo "image-digest must be an immutable lowercase sha256 digest" >&2 exit 1 fi + if test "${REGION_CORRECTION_COHORT_PERCENT}" != preserve; then + [[ "${REGION_CORRECTION_COHORT_PERCENT}" =~ ^([0-9]|[1-9][0-9]|100)$ ]] + fi IMAGE="${IMAGE_REPOSITORY}@${IMAGE_DIGEST}" - SERVED_DIGEST="$(gcloud artifacts docker images describe "${IMAGE}" --format='value(image_summary.digest)')" + SERVED_DIGEST="$(gcloud artifacts docker images describe "${IMAGE}" --project "${GCP_PROJECT_ID}" --format='value(image_summary.digest)')" test "${SERVED_DIGEST}" = "${IMAGE_DIGEST}" [[ "${PRUNE_INCOMPATIBLE_REVISIONS}" =~ ^(true|false)$ ]] [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] @@ -218,7 +227,8 @@ jobs: --max-instances "${DIRECTOR_MAX_INSTANCES}" \ --prune-revisions "${PRUNE_INCOMPATIBLE_REVISIONS}" \ --release-id "${RELEASE_ID}" \ - --regional-placement-secret-version "${target_version}" + --regional-placement-secret-version "${target_version}" \ + --region-correction-cohort-percent "${REGION_CORRECTION_COHORT_PERCENT}" echo "REGIONAL_PLACEMENT_ENABLED=${desired}" >> "${GITHUB_ENV}" echo "REGIONAL_PLACEMENT_VERSION=${target_version}" >> "${GITHUB_ENV}" diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index 8ef61507088..2b4bb3fa439 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -67,8 +67,8 @@ jobs: [[ "${TARGET_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] [[ "${ROLLBACK_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] test "${TARGET_IMAGE_DIGEST}" != "${ROLLBACK_IMAGE_DIGEST}" - [[ "${TARGET_REHOME_PROTOCOL}" =~ ^[01]$ ]] - [[ "${ROLLBACK_REHOME_PROTOCOL}" =~ ^[01]$ ]] + [[ "${TARGET_REHOME_PROTOCOL}" =~ ^(0|1|3)$ ]] + [[ "${ROLLBACK_REHOME_PROTOCOL}" =~ ^(0|1|3)$ ]] [[ "${EXPECTED_SELECTOR_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] [[ "${WAVE_INDEX}" =~ ^[0-3]$ ]] @@ -599,7 +599,7 @@ jobs: | jq -e '.control.enabled == false' >/dev/null - name: Prove exact per-host trust and idempotent no-neighbor behavior - if: ${{ inputs.mode != 'verify' && ((inputs.mode == 'rollback' && inputs.rollback-rehome-protocol == '1') || (inputs.mode != 'rollback' && inputs.target-rehome-protocol == '1')) }} + if: ${{ inputs.mode != 'verify' && ((inputs.mode == 'rollback' && inputs.rollback-rehome-protocol != '0') || (inputs.mode != 'rollback' && inputs.target-rehome-protocol != '0')) }} env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }} run: | diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap.yml b/.github/workflows/cloud-deploy-relay-production-same-cap.yml index fba5df0dcb9..31fe9bfb203 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap.yml @@ -26,13 +26,13 @@ on: required: true default: '1' type: choice - options: ['0', '1'] + options: ['0', '1', '3'] rollback-rehome-protocol: description: Exact rollback regional-rehome protocol required: true default: '0' type: choice - options: ['0', '1'] + options: ['0', '1', '3'] expected-selector-generation: description: Exact selector generation before the first cell required: true @@ -62,7 +62,7 @@ on: required: false type: string canary-run-id: - description: Successful same-commit canary run required for batch-apply + description: Successful same-code canary in this rehome control generation; reusable across batches required: false type: string confirmation: diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 7c617550082..325ce6cb61c 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -6,6 +6,9 @@ on: - '.github/workflows/computer-e2e.yml' - 'config/electron-builder.config.cjs' - 'config/scripts/build-computer-macos.mjs' + - 'config/scripts/build-native-for-platform.mjs' + - 'config/scripts/build-native-for-platform.test.mjs' + - 'config/scripts/pnpm-cli-invocation.mjs' - 'config/scripts/build-windows-cli-launcher.mjs' - 'config/scripts/build-windows-cli-launcher.test.mjs' - 'config/scripts/computer-e2e-workflow.test.mjs' @@ -157,6 +160,12 @@ jobs: pnpm vitest run config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs config/scripts/macos-computer-helper-owner-loss-processes.test.mjs + # Why: the parallel launcher's cancellation tests are darwin-only and no + # other PR job runs on macOS. + - name: Parallel native build launcher cancellation + run: >- + pnpm vitest run --config config/vitest.config.ts + config/scripts/build-native-for-platform.test.mjs - name: Authenticated helper owner-loss smoke run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1 - name: Swift tests and signed universal helper verification diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 45130e932d8..a758d8db3a1 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -90,7 +90,8 @@ jobs: uses: actions/checkout@v6 with: ref: main - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # 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 @@ -167,6 +168,8 @@ jobs: restore-keys: | electron-builder-mac- + # Why both CPUs: the mac config packages x64 and arm64 from this arm64 + # runner, so the install must carry both variants of the native optional deps. - name: Install dependencies if: steps.freshness.outputs.should_build == 'true' uses: nick-fields/retry@v4 @@ -174,7 +177,7 @@ jobs: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile + command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 # 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. diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml index 89fda2ebef9..7913e7e6e80 100644 --- a/.github/workflows/dev-channel-win-build.yml +++ b/.github/workflows/dev-channel-win-build.yml @@ -219,6 +219,8 @@ jobs: # Why retried: pnpm install triggers electron's postinstall, which pulls the # Electron binary from GitHub release assets, and that CDN returns transient # 504s often enough to lose a build to it. + # Why host-only: this job packages only for its own runner OS and + # architecture, so the default host-scoped install is deliberate. - name: Install dependencies uses: nick-fields/retry@v4 with: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 942f0f34a56..f42d1184ce9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -170,8 +170,39 @@ jobs: # artifact instead of starting five concurrent electron-vite builds. # ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron # launches but never creates a BrowserWindow. + - name: Balance E2E shard from timing evidence + env: + ORCA_BACKGROUND_LAUNCH: '1' + SKIP_BUILD: '1' + ORCA_E2E_FORWARD_APP_LOGS: '1' + ORCA_E2E_WEB_CLIENT: '1' + ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay + run: | + mkdir -p ci-shards + pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --list --reporter=json > ci-shards/discovery.json + export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" + node config/scripts/ci-e2e-shard-plan.mjs ci-shards/discovery.json '${{ matrix.shard }}' ci-shards + pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --test-list=ci-shards/selected.txt --list --reporter=json > ci-shards/selected-discovery.json + node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json + - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }} + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt + + - name: Upload E2E shard assignment + if: always() + # Diagnostic upload outages must not change the test verdict. + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: e2e-shard-${{ matrix.shard_name }}-attempt-${{ github.run_attempt }} + path: ci-shards/ + retention-days: 14 + if-no-files-found: warn + + # The frame benchmark needs a mapped window, which the headless shards exclude. + - name: Run worktree first-paint benchmark + if: matrix.shard == '1/14' + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/worktree-switch-first-paint.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1 # Why: Playwright retains traces/screenshots only on failure. Uploading # them as an artifact makes post-mortem debugging on CI possible without diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index c300b2543b8..b4bdd803a6a 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -137,7 +137,8 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.head_sha }} - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the hourly repo through a minted App token passed by env. # Not persisting the checkout credential shrinks the blast radius if a @@ -174,13 +175,15 @@ jobs: restore-keys: | electron-builder-mac- + # Why both CPUs: the mac config packages x64 and arm64 from this arm64 + # runner, so the install must carry both variants of the native optional deps. - name: Install dependencies uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile + command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 # Why: signing is what makes an hourly installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 934b3f694a3..dd9a265d0c8 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -94,6 +94,8 @@ jobs: 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 + env: + ORCA_IOS_APS_ENVIRONMENT: production run: npx expo prebuild --platform ios --no-install - name: Install CocoaPods diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 6dbfc02aa3c..9afc865f87e 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -41,6 +41,10 @@ jobs: uses: actions/checkout@v6 - uses: ./.github/actions/install-node-dependencies + with: + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # bundler-cache installs mobile/Gemfile.lock, so this job is also what # proves the pinned fastlane the release workflow depends on still diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml new file mode 100644 index 00000000000..373afb7a539 --- /dev/null +++ b/.github/workflows/pi-owner-runtime.yml @@ -0,0 +1,29 @@ +name: Pi owner runtime verification +on: + pull_request: + paths: + - 'src/main/pi/agent-status-handler-source.ts' + - 'tests/tools/pi-owner-runtime-smoke.mjs' + - '.github/workflows/pi-owner-runtime.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Install pinned extension loader + run: npm install --prefix .cache/pi-owner --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.83.0 + - name: Verify real owner exit and hook delivery + run: node tests/tools/pi-owner-runtime-smoke.mjs .cache/pi-owner/node_modules/@earendil-works/pi-coding-agent diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml new file mode 100644 index 00000000000..837c38baf9a --- /dev/null +++ b/.github/workflows/pi-provider-runtime.yml @@ -0,0 +1,28 @@ +name: Pi extension provider verification +on: + pull_request: + paths: + - 'src/shared/commit-message-agent-specs-primary.ts' + - 'tests/tools/pi-provider-runtime-smoke.mjs' + - '.github/workflows/pi-provider-runtime.yml' +permissions: + contents: read +jobs: + runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Install pinned Pi runtime + run: npm install --prefix .cache/pi-provider --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.84.2 + - name: Verify extension model generation before and after + run: node tests/tools/pi-provider-runtime-smoke.mjs .cache/pi-provider/node_modules/@earendil-works/pi-coding-agent/dist/cli.js diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c49091ab148..398bd61bd04 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -126,6 +126,9 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Lint run: pnpm exec oxlint --format github @@ -273,6 +276,12 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" + # Why here: the READMEs embed media owned by docs/site and resources/onboarding, + # and the classifier skips static_analysis for docs-only diffs. This job runs + # on every PR and needs no install. + - name: Check README local links + run: node config/scripts/check-readme-local-links.mjs + typecheck: needs: [code_paths] if: needs.code_paths.outputs.typecheck == 'true' @@ -775,18 +784,9 @@ jobs: [[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; } - name: Verify headless serve signal shutdown - run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage - - - name: Verify extracted launcher serve signal shutdown run: >- node config/scripts/run-headless-serve-shutdown-docker.mjs - --appimage dist/orca-linux.AppImage --entrypoint launcher - - - name: Verify AppImage CLI registration and serve signal shutdown - run: >- - node config/scripts/run-headless-serve-shutdown-docker.mjs - --appimage dist/orca-linux.AppImage --entrypoint appimage - --signal-target serving-electron --int-delivery pid + --appimage dist/orca-linux.AppImage --all-entrypoints # A default container reproduces the hostile AppImage launch environment. - name: Verify Linux CLI launch contract @@ -835,9 +835,9 @@ jobs: with: path: | node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build - node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + native/windows-registry/build + node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} # vitest runs here directly rather than through `pnpm test`, so the addon # assertions only hold once install-node-dependencies has rebuilt natives. @@ -846,6 +846,11 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts config/scripts/rebuild-native-deps.test.mjs config/scripts/rebuild-native-deps-windows-process-tree.test.mjs + src/main/windows-registry-addon.test.ts + config/scripts/windows-process-tree-gyp-path.test.mjs + config/scripts/windows-process-tree-gyp-rebuild.test.mjs + config/scripts/package-electron-runtime-contract.test.mjs + config/scripts/electron-builder-runtime-resources.test.mjs src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts src/main/browser/browser-route-tcp-egress.electron.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts @@ -903,9 +908,9 @@ jobs: with: path: | node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build - node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + native/windows-registry/build + node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} - name: Prepare Electron native runtime run: node config/scripts/ensure-native-runtime.mjs --runtime=electron diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index c2124d12990..a644aef53c1 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1262,6 +1262,8 @@ jobs: # Electron binary from GitHub release assets. GitHub's download CDN # occasionally returns 504s that fail the whole release. Retry on # failure so transient network errors don't require a manual re-run. + # Why host-only: this job packages only for its own runner OS and + # architecture, so the default host-scoped install is deliberate. - name: Install dependencies uses: nick-fields/retry@v4 with: diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index f97ac0e5537..3d7e4dd05bf 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -64,13 +64,15 @@ jobs: # Electron binary from GitHub release assets. GitHub's download CDN # occasionally returns 504s that fail the whole release. Retry on # failure so transient network errors don't require a manual re-run. + # Why both CPUs: the mac config packages x64 and arm64 from this arm64 + # runner, so the install must carry both variants of the native optional deps. - name: Install dependencies uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile + command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 - name: Verify macOS signing environment run: node config/scripts/verify-macos-release-env.mjs diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index cdb334c64cd..b21feae3230 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -37,8 +37,19 @@ jobs: - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs - - name: Test shard + # The real two-cell transport test imports cloud relay source and its contracts. + - name: Install relay integration dependencies + working-directory: cloud run: | + npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts + npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build + + - name: Test shard + env: + ORCA_BALANCE_UNIT_SHARDS: '1' + ORCA_BACKGROUND_LAUNCH: '1' + run: | + export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" 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 \ @@ -58,3 +69,14 @@ jobs: --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + + - name: Upload unit shard assignment + if: always() + # Diagnostic upload outages must not change the test verdict. + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + path: ci-shards/ + retention-days: 14 + if-no-files-found: warn diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 90ab8db137c..244ee4d3e08 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -68,6 +68,8 @@ jobs: restore-keys: | electron-builder-win- + # Why host-only: this job packages only for its own runner OS and + # architecture, so the default host-scoped install is deliberate. - name: Install dependencies uses: nick-fields/retry@v4 with: diff --git a/.gitignore b/.gitignore index 913dfc4a045..b033ebe1cca 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ out/ /build/ release/ native/**/.build/ +# node-gyp output for the vendored Windows registry addon; generated per host and ABI. +native/windows-registry/build/ +native/windows-registry/bin/ # pnpm .pnpm-store/ @@ -103,13 +106,17 @@ docs/** !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ +!docs/reference/agent-pty-transcript-capture.md +!docs/reference/agent-session-search-query-tuning.md !docs/reference/agent-status-store.md +!docs/reference/antigravity-readiness-evidence.md !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/ime-regression-checklist.md !docs/reference/linux-glibc-compatibility.md !docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md +!docs/reference/pnpm-install-policy.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/windows-cmd-shim-resolution.md !docs/reference/windows-daemon-host-relocation.md diff --git a/AGENTS.md b/AGENTS.md index 5ff66b95b0f..74c049a49fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,14 @@ Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuf ## Type Declarations: Prefer `.ts` Over `.d.ts` +## Type Assertions: Prefer Checked Types + +Avoid type assertions except `as const`. Unavoidable casts need a line-specific `SAFETY:` explanation: + +```ts +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Explain the verified invariant here. +``` + # Verifying Changes - **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`) @@ -60,6 +68,10 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. +## Native Dependency Installs + +Ordinary `pnpm install` covers the host OS and CPU only. Before packaging for another architecture — including `pnpm build:mac`, which builds x64 and arm64 by default — run `pnpm install:release`. electron-builder only warns on a missing `extraResources` source, so the `beforePack` guard is what turns a thin install into a build failure instead of a silently broken artifact; see [`docs/reference/pnpm-install-policy.md`](./docs/reference/pnpm-install-policy.md). + ## SSH Use Case All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, follow [`docs/reference/ssh-execution-boundary.md`](./docs/reference/ssh-execution-boundary.md): the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms. @@ -72,6 +84,10 @@ All changes must consider folder workspaces as well as git worktrees. Don't assu The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. Before adding a producer, a cache, or a reader-side precedence rule, read [`docs/reference/agent-status-store.md`](./docs/reference/agent-status-store.md): new producers write into that store, and readers keep only presentation policy. +## Agent Terminal Screens + +A rule that reads what an agent CLI paints on a terminal — readiness, blocked prompts, idle — must be written against a captured transcript, not a remembered screen. Record one with [`docs/reference/agent-pty-transcript-capture.md`](./docs/reference/agent-pty-transcript-capture.md), which keeps escapes and wrapping intact and scrubs account identifiers before they reach git. Antigravity readiness has no transcript yet and five failed attempts without one; before touching it, read [`docs/reference/antigravity-readiness-evidence.md`](./docs/reference/antigravity-readiness-evidence.md). + ## Remote Wire Compatibility Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change. diff --git a/README.md b/README.md index 7e3540c80f1..aeb8f355450 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Fan one prompt across five agents, each in its own isolated git worktree — com - Parallel worktree orchestration + Parallel worktree orchestration @@ -68,7 +68,7 @@ Ghostty-class terminals with WebGL rendering, infinite splits, and scrollback th - Terminal splits + Terminal splits @@ -82,7 +82,7 @@ Click any UI element in a real Chromium window to send its HTML, CSS, and a crop - Embedded browser and Design Mode + Embedded browser and Design Mode @@ -96,7 +96,7 @@ Browse PRs, issues, and project boards in-app — open a worktree from any task - GitHub and Linear task workflows in Orca + GitHub and Linear task workflows in Orca @@ -110,7 +110,7 @@ Run agents on a beefy remote box with full file editing, git, and terminals — - Remote worktrees over SSH + Remote worktrees over SSH @@ -124,7 +124,7 @@ Drop comments on any diff line and ship them back to the agent — review, edit, - Annotate AI-generated diffs + Annotate AI-generated diffs @@ -138,7 +138,7 @@ VS Code's editor with autosave everywhere — drag files or images straight into - Drag files and images into an agent prompt + Drag files and images into an agent prompt @@ -152,7 +152,7 @@ Agents drive Orca too — script every workflow with `orca worktree create`, `sn - Script Orca from the CLI + Script Orca from the CLI diff --git a/cloud/apps/push/src/fcm-client.test.ts b/cloud/apps/push/src/fcm-client.test.ts index 4a8d1fb41f7..8843e7aab95 100644 --- a/cloud/apps/push/src/fcm-client.test.ts +++ b/cloud/apps/push/src/fcm-client.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { describe, expect, it } from 'vitest' -import { fcmCollapseKey, FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js' +import { FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js' import { buildPushDelivery } from './push-delivery-message.js' const NOW = 1_700_000_000_000 @@ -20,6 +20,7 @@ function delivery(agentState: 'needs-input' | null = 'needs-input') { agentState, title: 'Agent needs input', body: 'Waiting on your answer', + paneKey: 'tab-b:pane-1', worktreeId: 'wt-1' } }) @@ -59,27 +60,14 @@ describe('fcm client', () => { expect(JSON.parse(request.body)).toEqual({ message: { token: TOKEN, - notification: { title: 'Agent needs input', body: 'Waiting on your answer' }, - android: { - priority: 'HIGH', - ttl: '300s', - collapse_key: createHash('sha256') - .update( - createHash('sha256') - .update(JSON.stringify([HOST, 'note-1'])) - .digest('hex') - ) - .digest('hex') - .slice(0, 32), - notification: { - channel_id: 'orca-desktop', - tag: createHash('sha256') - .update(JSON.stringify([HOST, 'note-1'])) - .digest('hex') - } - }, + android: { priority: 'HIGH', ttl: '300s' }, data: { + title: 'Agent needs input', + message: 'Waiting on your answer', + tag: delivery().collapseId, + channelId: 'orca-desktop', hostFingerprint: HOST, + paneKey: 'tab-b:pane-1', worktreeId: 'wt-1', notificationId: 'note-1', notificationSeq: '7', @@ -96,7 +84,7 @@ describe('fcm client', () => { await fcm.send(delivery(null), { token: TOKEN }) const message = JSON.parse(fake.requests[0]!.body) as { message: { - android: { collapse_key: string; notification: { tag: string } } + android: Record data: Record } } @@ -108,9 +96,10 @@ describe('fcm client', () => { .update(JSON.stringify([HOST, 'note-1'])) .digest('hex') expect(message.message.data.coalescedCount).toBeUndefined() - expect(message.message.android.notification.tag).toBe(tag) - expect(message.message.android.collapse_key).toBe(fcmCollapseKey(tag)) - expect(message.message.android.collapse_key).toHaveLength(32) + expect(message.message.data.tag).toBe(tag) + expect(message.message.android).not.toHaveProperty('collapse_key') + expect(message.message).not.toHaveProperty('notification') + expect(message.message.data).not.toHaveProperty('body') }) it('marks an unregistered token dead from the status or the error detail', async () => { diff --git a/cloud/apps/push/src/fcm-client.ts b/cloud/apps/push/src/fcm-client.ts index c22bd3309cd..0b58aae80f5 100644 --- a/cloud/apps/push/src/fcm-client.ts +++ b/cloud/apps/push/src/fcm-client.ts @@ -1,5 +1,4 @@ import { providerRetryAfter } from './provider-retry-delay.js' -import { createHash } from 'node:crypto' import { PUSH_DEFAULTS } from '@orca-cloud/push-contract' import { orcaDataStrings, type PushDelivery } from './push-delivery-message.js' import type { PushProviderOutcome } from './push-provider-outcome.js' @@ -22,12 +21,6 @@ type FcmErrorBody = { error?: { status?: unknown; message?: unknown; details?: { errorCode?: unknown }[] } } -// FCM collapse_key is a short opaque string, so the collapse id is hashed -// rather than truncated: truncation would merge unrelated notifications. -export function fcmCollapseKey(collapseId: string): string { - return createHash('sha256').update(collapseId).digest('hex').slice(0, 32) -} - export function fcmMessageBody(input: { delivery: PushDelivery token: string @@ -39,24 +32,23 @@ export function fcmMessageBody(input: { return JSON.stringify({ message: { token: input.token, - ...(delivery.orca.kind === 'dismiss' - ? {} - : { notification: { title: delivery.title, body: delivery.body } }), android: { priority: 'HIGH', - ttl: `${Math.max(0, Math.ceil((delivery.expiresAt - now) / 1000))}s`, - collapse_key: fcmCollapseKey(delivery.collapseId), + ttl: `${Math.max(0, Math.ceil((delivery.expiresAt - now) / 1000))}s` + }, + // Notification payloads collapse offline; Expo renders these data messages natively. + data: { + ...orcaDataStrings(delivery.orca), ...(delivery.orca.kind === 'dismiss' ? {} : { - notification: { - channel_id: - delivery.sound === false ? `${input.channelId}-silent` : input.channelId, - tag: delivery.collapseId - } + title: delivery.title, + message: delivery.body, + tag: delivery.collapseId, + channelId: delivery.sound === false ? `${input.channelId}-silent` : input.channelId, + ...(delivery.sound === false ? { sound: '' } : {}) }) - }, - data: orcaDataStrings(delivery.orca) + } } }) } diff --git a/cloud/apps/push/src/push-delivery-message.ts b/cloud/apps/push/src/push-delivery-message.ts index bc2c1a5d9b2..3bd2dae6e7c 100644 --- a/cloud/apps/push/src/push-delivery-message.ts +++ b/cloud/apps/push/src/push-delivery-message.ts @@ -5,6 +5,7 @@ export type PushOrcaData = { kind?: 'alert' | 'dismiss' hostFingerprint: string worktreeId?: string + paneKey?: string notificationId?: string notificationSeq: number notificationEpoch: string @@ -51,6 +52,7 @@ export function buildPushDelivery(input: { orca: { ...(notification.kind ? { kind: notification.kind } : {}), hostFingerprint, + ...(notification.paneKey === undefined ? {} : { paneKey: notification.paneKey }), ...(notification.worktreeId === undefined ? {} : { worktreeId: notification.worktreeId }), ...(notification.notificationId === undefined ? {} diff --git a/cloud/apps/push/src/push-dismissal-provider.test.ts b/cloud/apps/push/src/push-dismissal-provider.test.ts index 15362105777..65572e8401c 100644 --- a/cloud/apps/push/src/push-dismissal-provider.test.ts +++ b/cloud/apps/push/src/push-dismissal-provider.test.ts @@ -23,5 +23,9 @@ it('dismissal provider payloads cannot display a new alert or play a sound', () const android = JSON.parse(fcmMessageBody({ delivery, token: 'test', channelId: 'test' })).message expect(android).not.toHaveProperty('notification') expect(android.android).not.toHaveProperty('notification') + expect(android.android).not.toHaveProperty('collapse_key') + expect(android.data).not.toHaveProperty('title') + expect(android.data).not.toHaveProperty('message') + expect(android.data).not.toHaveProperty('sound') expect(android.data.kind).toBe('dismiss') }) diff --git a/cloud/apps/push/src/push-notification-sound.test.ts b/cloud/apps/push/src/push-notification-sound.test.ts index 4dd1b85504f..ede4dcd3291 100644 --- a/cloud/apps/push/src/push-notification-sound.test.ts +++ b/cloud/apps/push/src/push-notification-sound.test.ts @@ -23,7 +23,11 @@ it('carries a silent preference through validation to APNs and Android payloads' expect(JSON.parse(apnsBody(delivery)).aps).not.toHaveProperty('sound') expect( JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message - .android.notification.channel_id + .data.channelId ).toBe('orca-desktop-silent') + expect( + JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message + .data.sound + ).toBe('') expect(JSON.parse(apnsBody({ ...delivery, sound: undefined })).aps.sound).toBe('default') }) diff --git a/cloud/apps/push/src/push-pane-routing.test.ts b/cloud/apps/push/src/push-pane-routing.test.ts new file mode 100644 index 00000000000..66ce0b3a38c --- /dev/null +++ b/cloud/apps/push/src/push-pane-routing.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest' +import { PushNotificationSchema } from '@orca-cloud/push-contract' +import { buildPushDelivery, orcaDataStrings } from './push-delivery-message.js' + +it('preserves pane identity for both APNs and FCM, and accepts older workspace-only messages', () => { + const base = { + notificationSeq: 1, + notificationEpoch: 'epoch', + source: 'agent-task-complete', + agentState: 'finished', + title: 'Done', + body: '', + worktreeId: 'folder:/work' + } + const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111' + for (const extra of [{}, { paneKey }]) { + const notification = PushNotificationSchema.parse({ ...base, ...extra }) + const delivery = buildPushDelivery({ + notification, + hostFingerprint: 'host', + registrationId: 'phone', + expiresAt: Date.now() + 300000 + }) + expect(delivery.orca.paneKey).toBe('paneKey' in extra ? paneKey : undefined) + expect(orcaDataStrings(delivery.orca).paneKey).toBe('paneKey' in extra ? paneKey : undefined) + } +}) diff --git a/cloud/apps/push/src/push-server-send.test.ts b/cloud/apps/push/src/push-server-send.test.ts index 2a93020ed1f..4b14b77eaec 100644 --- a/cloud/apps/push/src/push-server-send.test.ts +++ b/cloud/apps/push/src/push-server-send.test.ts @@ -56,7 +56,7 @@ describe('push gateway send route', () => { await harness.flushDeliveries() expect(harness.fcmRequests).toHaveLength(1) expect(JSON.parse(harness.fcmRequests[0]!.body)).toMatchObject({ - message: { token: FCM_TOKEN, notification: { title: 'Agent needs input' } } + message: { token: FCM_TOKEN, data: { title: 'Agent needs input' } } }) const afterDeath = await harness.post( @@ -179,7 +179,7 @@ describe('push gateway send route', () => { const message = JSON.parse(harness.fcmRequests[0]!.body) as { message: { android: { notification: { tag: string } }; data: Record } } - expect(message.message.android.notification.tag).toMatch(/^[a-f0-9]{64}$/) + expect(message.message.data.tag).toMatch(/^[a-f0-9]{64}$/) expect(message.message.data.coalescedCount).toBeUndefined() }) diff --git a/cloud/apps/relay-ops/src/incident-monitor.test.ts b/cloud/apps/relay-ops/src/incident-monitor.test.ts index c1a073cde4a..be35b55f65b 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.test.ts @@ -273,6 +273,26 @@ describe('incident monitor evaluator', () => { ) }) + it('allows at most three unexpected director errors per five minutes without relaxing other gates', () => { + for (const errors of [1, 2, 3]) { + const sample = healthySample() + sample.sources['cloud-monitoring']!.signals['director.errors'] = signal(errors) + expect(evaluateIncidentSample(sample, startedAt).status).toBe('green') + } + const excess = healthySample() + excess.sources['cloud-monitoring']!.signals['director.errors'] = signal(4) + expect(evaluateIncidentSample(excess, startedAt).failures).toContainEqual( + expect.objectContaining({ signal: 'director.errors', observed: 4, threshold: 3 }) + ) + const auth = healthySample() + auth.sources['cloud-monitoring']!.signals['auth.errors'] = signal(1) + expect(evaluateIncidentSample(auth, startedAt).status).toBe('freeze') + const pressure = healthySample() + pressure.sources['cloud-monitoring']!.signals['director.errors'] = signal(1) + pressure.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) + expect(evaluateIncidentSample(pressure, startedAt).status).toBe('freeze') + }) + it('freezes on SQL, director, relay pool, heartbeat, and migration breaches', () => { const sample = healthySample() sample.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) diff --git a/cloud/apps/relay-ops/src/incident-monitor.ts b/cloud/apps/relay-ops/src/incident-monitor.ts index 0887bb2d1ee..a48e100a6d8 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.ts @@ -101,7 +101,8 @@ export const INCIDENT_MONITOR_THRESHOLDS = { directorCpuUtilization: 0.8, directorMemoryUtilization: 0.8, directorConcurrency: 64, - directorErrors: 0, + // Sparse connection timeouts must not block a healthy rollout; four/5min still freezes. + directorErrors: 3, authErrors: 0, // Why: 800 exceeded the 600 hard cap, so this could never trigger on a capped cell. 500 is // the ordinary admission limit a cell actually stops at (600 cap - 100 control-rebind reserve). diff --git a/cloud/apps/relay/src/admin-token-verifier.ts b/cloud/apps/relay/src/admin-token-verifier.ts index 4b8ad26e695..8b236d58473 100644 --- a/cloud/apps/relay/src/admin-token-verifier.ts +++ b/cloud/apps/relay/src/admin-token-verifier.ts @@ -6,6 +6,7 @@ export const RELAY_MONITOR_ADMIN_ROUTES = [ '/v1/admin/cell-status', '/v1/admin/evacuation-status', '/v1/admin/regional-rehome-control', + '/v1/admin/regional-rehome-preview', '/v1/admin/runtime-status' ] as const diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index c45e31c4a01..44f6a6fc293 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -1,5 +1,9 @@ import { AssignmentRequestSchema, + IdleRegionalRehomeRequestSchema, + type IdleRegionalRehomeRequest, + type IdleRegionalRehomeOutcome, + type RegionCorrectionResponse, isRelayCellConnectionHardCap, RELAY_ADMISSION_BUDGETS, RELAY_DEFAULT_REGION, @@ -39,7 +43,7 @@ import { type AssignmentAdmissionRejection } from './public-assignment-admission.js' import { relayHostLogDigest } from './relay-host-log-digest.js' -import type { RelayRuntimeCounts } from './relay-observability.js' +import type { RegionalRehomeSafetySnapshot, RelayRuntimeCounts } from './relay-observability.js' import { isRegionalRehomeTrustProbe, probeRegionalRehomeTrust @@ -68,21 +72,28 @@ export function createRelayApp( store: RelayCredentialStore assignments: RelayAssignmentStore drain: (graceMs: number) => void + idleRehome?: (input: IdleRegionalRehomeRequest & { + cohortPercent: number + directorSafety: RegionalRehomeSafetySnapshot + }) => Promise<{ outcome: IdleRegionalRehomeOutcome }> drainHost?: (input: { attemptId: string userId: string relayHostId: string sourceAssignmentEpoch: number + sourceCellIncarnation: string graceMs: number - }) => 'accepted' | 'already-accepted' | 'host-not-connected' + }) => + | 'accepted' + | 'already-accepted' + | 'host-not-connected' + | Promise<'accepted' | 'already-accepted' | 'host-not-connected'> regionalRehomeIdentityToken?: (audience: string) => Promise regionalRehomeFetch?: typeof fetch - regionalRehomeTrustProbeHostExists?: (input: { - userId: string - relayHostId: string - }) => boolean + regionalRehomeTrustProbeHostExists?: (input: { userId: string; relayHostId: string }) => boolean cellIncarnation?: string isDraining?: () => boolean + regionalRehomeSafetySnapshot?: () => RegionalRehomeSafetySnapshot runtimeCounts?: () => RelayRuntimeCounts ready: () => Promise recordAssignmentAdmission?: ( @@ -226,7 +237,8 @@ export function createRelayApp( return context.json({ error: 'host_identity_mismatch' }, 403) } const identity = { userId: claims.sub, relayHostId: claims.relayHostId } - const requestedRegion = body.data.preferredRegion + const requestedRegion = + body.data.regionCorrection?.action === 'report' ? undefined : body.data.preferredRegion const targetRegion = config.regionalPlacementEnabled !== false && requestedRegion ? requestedRegion @@ -295,10 +307,30 @@ export function createRelayApp( } } let assignment: RelayAssignment + let regionCorrection: RegionCorrectionResponse | undefined try { - assignment = requestedRegion - ? await operations.assignments.assign(identity, requestedRegion, targetRegion) - : await operations.assignments.assign(identity) + if (body.data.regionCorrection?.action === 'report') { + const current = await operations.assignments.resolve(identity) + if (!current) return context.json({ error: 'assignment_not_found' }, 409) + assignment = current + } else { + assignment = requestedRegion + ? await operations.assignments.assign(identity, requestedRegion, targetRegion) + : await operations.assignments.assign(identity) + } + if (body.data.regionCorrection) { + try { + regionCorrection = await operations.assignments.exchangeRegionCorrection( + identity, + body.data.regionCorrection, + assignment.assignmentEpoch + ) + } catch (error) { + if (body.data.regionCorrection.action === 'report') throw error + // Optional measurement setup must not discard an otherwise valid placement. + console.warn(JSON.stringify({ event: 'orca_relay_region_window_unavailable' })) + } + } } catch (error) { if (isRelayAssignmentCapacityError(error) || isRelayDatabaseTransientError(error)) { logAssignmentRejection({ @@ -353,13 +385,16 @@ export function createRelayApp( v: 1, cellUrl: assignment.cellUrl, assignmentEpoch: assignment.assignmentEpoch, - lease + lease, + ...(regionCorrection ? { regionCorrection } : {}) }) }) app.post('/v1/resolve', async (context) => { if (config.role === 'cell') return context.json({ error: 'director_only' }, 404) if (!config.publicAssignmentsEnabled) return rejectPublicAssignment(context) - if (Number(context.req.header('content-length') ?? 0) > RELAY_PROTOCOL_LIMITS.maxHttpBodyBytes) { + if ( + Number(context.req.header('content-length') ?? 0) > RELAY_PROTOCOL_LIMITS.maxHttpBodyBytes + ) { return context.json({ error: 'request_too_large' }, 413) } const body = ResolveRequestSchema.safeParse(await context.req.json().catch(() => null)) @@ -433,6 +468,34 @@ export function createRelayApp( operations.drain(body.data.graceMs) return context.json({ ok: true }) }) + app.post('/v1/admin/host-idle-rehome', async (context) => { + if (config.role !== 'cell' || !operations.idleRehome) { + return context.json({ error: 'cell_only' }, 404) + } + const bearer = readBearer(context.req.header('authorization')) + if (!bearer || !(await verifyRegionalRehomeToken(bearer))) { + return context.json({ error: 'invalid_token' }, 401) + } + if (requestTooLarge(context.req.header('content-length'))) { + return context.json({ error: 'request_too_large' }, 413) + } + const body = IdleRegionalRehomeCommandSchema.safeParse( + await context.req.json().catch(() => null) + ) + if (!body.success) return context.json({ error: 'invalid_request' }, 400) + if ( + body.data.sourceCellId !== config.cellId || + !operations.cellIncarnation || + body.data.sourceCellIncarnation !== operations.cellIncarnation + ) { + return context.json({ error: 'regional_rehome_source_generation_mismatch' }, 409) + } + try { + return context.json({ v: 1, ...(await operations.idleRehome(body.data)) }) + } catch (error) { + return context.json({ error: operationError(error) }, 409) + } + }) app.post('/v1/admin/host-drain', async (context) => { if (config.role !== 'cell' || !operations.drainHost) { return context.json({ error: 'cell_only' }, 404) @@ -474,7 +537,7 @@ export function createRelayApp( } sharedRuntimeIdentityRejected = true } - const outcome = operations.drainHost(body.data) + const outcome = await operations.drainHost(body.data) return context.json({ v: 1, outcome, @@ -502,8 +565,7 @@ export function createRelayApp( region: config.region ?? RELAY_DEFAULT_REGION, imageDigest: config.imageDigest ?? null, draining: operations.isDraining?.() ?? false, - regionalRehomeProtocol: - config.rehomeAudience && config.rehomeDirectorServiceAccount ? 1 : 0, + regionalRehomeProtocol: config.rehomeAudience && config.rehomeDirectorServiceAccount ? 3 : 0, connectionCapacity: config.connectionHardCap === undefined ? null @@ -559,6 +621,18 @@ export function createRelayApp( return context.json({ error: operationError(error) }, 409) } }) + app.get('/v1/admin/regional-rehome-preview', async (context) => { + if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) + const bearer = readBearer(context.req.header('authorization')) + if (!bearer || !(await verifyAdminToken(bearer, context.req.path))) { + return context.json({ error: 'invalid_token' }, 401) + } + const preview = await operations.assignments.previewRegionalRehomeEligibility( + operations.regionalRehomeSafetySnapshot?.() + ) + const outcomes = await operations.assignments.regionCorrectionOutcomes() + return context.json({ v: 1, preview, outcomes }) + }) app.post('/v1/admin/regional-rehome-control', async (context) => { if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) const bearer = readBearer(context.req.header('authorization')) @@ -1295,6 +1369,11 @@ const RegionalRehomeSafetySchema = z }) .strict() +const IdleRegionalRehomeCommandSchema = IdleRegionalRehomeRequestSchema.extend({ + cohortPercent: z.number().int().min(0).max(100), + directorSafety: RegionalRehomeSafetySchema +}) + const CellHeartbeatSchema = z .object({ v: z.literal(1), @@ -1394,45 +1473,48 @@ const CellRegionalRehomeStatusSchema = z v: z.literal(1), cellId: z.string().min(1).max(128), cellIncarnation: z.string().uuid(), - regionalRehomeProtocol: z.number().int().min(0).max(1), + regionalRehomeProtocol: z.number().int().min(0).max(3), safety: RegionalRehomeSafetySchema }) .strict() -const RegionalRehomeControlSchema = z.discriminatedUnion('action', [ - z.object({ v: z.literal(1), action: z.literal('inspect') }).strict(), - z.object({ - v: z.literal(1), - action: z.literal('apply'), - expectedGeneration: z.number().int().nonnegative(), - enabled: z.boolean(), - notBefore: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - ratePerMinute: z.number().int().min(1).max(120), - preferenceMaxAgeMs: z - .number() - .int() - .min(60_000) - .max(30 * 24 * 60 * 60_000), - hostCooldownMs: z - .number() - .int() - .min(60_000) - .max(30 * 24 * 60 * 60_000), - drainGraceMs: z.number().int().min(60_000).max(60 * 60_000), - confirmation: z.enum([ - 'ENABLE_REGIONAL_REHOMING', - 'DISABLE_REGIONAL_REHOMING' - ]) - }).strict() -]).superRefine((value, context) => { - if (value.action !== 'apply') return - const expected = value.enabled - ? 'ENABLE_REGIONAL_REHOMING' - : 'DISABLE_REGIONAL_REHOMING' - if (value.confirmation !== expected) { - context.addIssue({ code: 'custom', message: 'confirmation does not match state' }) - } -}) +const RegionalRehomeControlSchema = z + .discriminatedUnion('action', [ + z.object({ v: z.literal(1), action: z.literal('inspect') }).strict(), + z + .object({ + v: z.literal(1), + action: z.literal('apply'), + expectedGeneration: z.number().int().nonnegative(), + enabled: z.boolean(), + notBefore: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + ratePerMinute: z.number().int().min(1).max(120), + preferenceMaxAgeMs: z + .number() + .int() + .min(60_000) + .max(30 * 24 * 60 * 60_000), + hostCooldownMs: z + .number() + .int() + .min(60_000) + .max(30 * 24 * 60 * 60_000), + drainGraceMs: z + .number() + .int() + .min(60_000) + .max(60 * 60_000), + confirmation: z.enum(['ENABLE_REGIONAL_REHOMING', 'DISABLE_REGIONAL_REHOMING']) + }) + .strict() + ]) + .superRefine((value, context) => { + if (value.action !== 'apply') return + const expected = value.enabled ? 'ENABLE_REGIONAL_REHOMING' : 'DISABLE_REGIONAL_REHOMING' + if (value.confirmation !== expected) { + context.addIssue({ code: 'custom', message: 'confirmation does not match state' }) + } + }) const RegionalRehomeTrustProbeSchema = z .object({ @@ -1776,7 +1858,11 @@ const RegionalHostDrainSchema = z sourceCellId: z.string().min(1).max(128), sourceCellIncarnation: z.string().uuid(), sourceAssignmentEpoch: z.number().int().positive(), - graceMs: z.number().int().nonnegative().max(60 * 60 * 1000) + graceMs: z + .number() + .int() + .nonnegative() + .max(60 * 60 * 1000) }) .strict() diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 824cb1e0b2f..bb7812dda86 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -1,3 +1,15 @@ +import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js' +import { IDLE_REHOME_PAGE_SIZE, selectIdleRegionalRehomes } from './idle-regional-rehome-selection.js' +import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js' +import { + previewRegionalRehomeEligibility, + type RegionCorrectionPreview +} from './region-correction-preview.js' +import { + exchangeRegionCorrection, + previewRegionCorrection, + REGIONAL_REHOME_CONCURRENT_LIMIT +} from './region-correction-state.js' import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' import { @@ -7,7 +19,10 @@ import { RELAY_DEFAULT_REGION, RELAY_REGIONS, RELAY_PROTOCOL_LIMITS, - type RelayRegion + type RelayRegion, + type RegionCorrectionRequest, + type RegionCorrectionResponse, + type IdleRegionalRehomeRequest, } from '@orca-cloud/relay-contract' import { cellAdmissionState, @@ -80,6 +95,7 @@ type CellRegionalRehomeStatus = { } type RelayAssignmentStoreOptions = { + regionalRehomeCohortPercent?: number requireLiveCells?: boolean heartbeatTtlMs?: number recordControlRenewal?: (durationMs: number, outcome: ControlRenewalOutcome) => void @@ -136,10 +152,7 @@ export type RegionalRehomeAttempt = AssignmentIdentity & { sendAttempts: number } -export type RegionalHostDrainOutcome = - | 'accepted' - | 'already-accepted' - | 'host-not-connected' +export type RegionalHostDrainOutcome = 'accepted' | 'already-accepted' | 'host-not-connected' export type RegionalRehomeFleetSafety = RegionalRehomeSafetySnapshot & { requiredCells: number @@ -415,6 +428,7 @@ const ABORTABLE_EXPIRED_MIGRATION = `( )` export class RelayAssignmentStore { + private readonly regionalRehomeCohortPercent: number private readonly requireLiveCells: boolean private readonly heartbeatTtlMs: number // Poisoned attempts never complete or abort and stay the oldest rows, so @@ -430,13 +444,19 @@ export class RelayAssignmentStore { private readonly migrationCellRegistrar: RelayMigrationCellRegistrar private readonly activityQueue = new AssignmentIdentityQueue() private assignmentTail: Promise = Promise.resolve() - private pendingRegionalRehomeDisableLog: Record | null = null constructor( private readonly database: RelayDatabase, private readonly now: () => number = Date.now, options: RelayAssignmentStoreOptions = {} ) { + this.regionalRehomeCohortPercent = options.regionalRehomeCohortPercent ?? 0 + if ( + !Number.isInteger(this.regionalRehomeCohortPercent) || + this.regionalRehomeCohortPercent < 0 || + this.regionalRehomeCohortPercent > 100 + ) + throw new Error('invalid_regional_rehome_cohort') this.requireLiveCells = options.requireLiveCells ?? false this.heartbeatTtlMs = options.heartbeatTtlMs ?? 45_000 this.recordControlRenewal = options.recordControlRenewal @@ -2168,22 +2188,16 @@ export class RelayAssignmentStore { [input.cellId] ) const cells = await this.lockCellInventory(transaction, 'request') + const assignmentRows = createDrainMigrationRowLookup(assignments, text) + const leaseRows = createDrainMigrationRowLookup(activityLeases, text) for (const migrationRow of migrations) { const identity = { userId: text(migrationRow, 'user_id'), relayHostId: text(migrationRow, 'relay_host_id') } - const assignment = assignments.find( - (candidate) => - text(candidate, 'user_id') === identity.userId && - text(candidate, 'relay_host_id') === identity.relayHostId - ) + const assignment = assignmentRows.first(identity) assertCurrentMigrationAssignment(assignment, migrationRow) - const leases = activityLeases.filter( - (lease) => - text(lease, 'user_id') === identity.userId && - text(lease, 'relay_host_id') === identity.relayHostId - ) + const leases = leaseRows.all(identity) const migrationLeases = leases.filter( (lease) => text(lease, 'activity_kind') === 'migration' ) @@ -2358,22 +2372,16 @@ export class RelayAssignmentStore { ) { throw new Error('drain_migration_source_incarnation_mismatch') } + const assignmentRows = createDrainMigrationRowLookup(assignments, text) + const leaseRows = createDrainMigrationRowLookup(activityLeases, text) for (const migrationRow of migrationIncarnations) { - const assignment = assignments.find( - (candidate) => - text(candidate, 'user_id') === text(migrationRow, 'user_id') && - text(candidate, 'relay_host_id') === text(migrationRow, 'relay_host_id') - ) + const identity = { + userId: text(migrationRow, 'user_id'), + relayHostId: text(migrationRow, 'relay_host_id') + } + const assignment = assignmentRows.first(identity) assertCurrentMigrationAssignment(assignment, migrationRow) - assertAssignmentActivityAccounting( - assignment, - activityLeases.filter( - (lease) => - text(lease, 'user_id') === text(migrationRow, 'user_id') && - text(lease, 'relay_host_id') === text(migrationRow, 'relay_host_id') - ), - migrationRow - ) + assertAssignmentActivityAccounting(assignment, leaseRows.all(identity), migrationRow) } const sendPermitExpiresAt = now + CELL_DRAIN_SEND_PERMIT_MS await transaction.query( @@ -3309,6 +3317,167 @@ export class RelayAssignmentStore { }) } + async exchangeRegionCorrection( + identity: AssignmentIdentity, + request: RegionCorrectionRequest, + assignmentEpoch: number + ): Promise { + return exchangeRegionCorrection(this.database, identity, request, assignmentEpoch, this.now()) + } + + async regionCorrectionOutcomes() { + return readRegionCorrectionOutcomes(this.database, this.now()) + } + + async previewRegionCorrection(): Promise> { + return previewRegionCorrection(this.database, this.now()) + } + + private idleRegionalCandidateOffset = 0 + + async selectIdleRegionalRehomeCandidates( + processSafety?: RegionalRehomeSafetySnapshot + ): Promise> { + const now = this.now() + if (!processSafety || this.regionalRehomeCohortPercent === 0) return [] + const control = (await this.database.query( + "SELECT enabled, not_before FROM relay_region_rehome_control WHERE control_id = 'global'" + ))[0] + if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) return [] + const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now) + if (regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now)) return [] + const candidates = await selectIdleRegionalRehomes({ + database: this.database, now, heartbeatTtlMs: this.heartbeatTtlMs, + cohortPercent: this.regionalRehomeCohortPercent, offset: this.idleRegionalCandidateOffset, + connectionHeadroom: await this.connectionHeadroomByCell(this.database), + cellIsClean: regionalRehomeCellSafetyIsClean + }) + this.idleRegionalCandidateOffset = candidates.length < IDLE_REHOME_PAGE_SIZE + ? 0 : this.idleRegionalCandidateOffset + candidates.length + return candidates + } + + async commitIdleRegionalRehome( + request: IdleRegionalRehomeRequest, + processSafety?: RegionalRehomeSafetySnapshot, + cohortPercent = this.regionalRehomeCohortPercent + ): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> { + const prior = await this.reconcileIdleRegionalRehome(request) + if (prior !== 'not-committed') return { outcome: prior } + if (!processSafety || !Number.isInteger(cohortPercent) || cohortPercent <= 0 || cohortPercent > 100) { + return { outcome: 'deferred' } + } + let safetyDisable: Record | null = null + const result = await this.database.transaction(async (transaction): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> => { + safetyDisable = null + const now = this.now() + const control = (await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` + ))[0] + if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) { + return { outcome: 'deferred' } + } + await transaction.query( + `INSERT INTO relay_region_rehome_worker_state + (worker_id, next_dispatch_at, paused_until, consecutive_failures, updated_at) + VALUES ('global', 0, 0, 0, ?) ON CONFLICT (worker_id) DO NOTHING`, [now] + ) + const worker = (await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` + ))[0]! + if (Number(worker.paused_until) > now || Number(worker.next_dispatch_at) > now) { + return { outcome: 'deferred' } + } + const open = (await transaction.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations + WHERE completed_at IS NULL AND aborted_at IS NULL` + ))[0] + if (Number(open?.count ?? 0) >= REGIONAL_REHOME_CONCURRENT_LIMIT) return { outcome: 'deferred' } + const attempt = await this.startRegionalRehomeCandidate(transaction, { + identity: request, + sourceCellId: request.sourceCellId, + assignmentEpoch: request.sourceAssignmentEpoch, + preferenceCutoff: now - Number(control.preference_max_age_ms), + cooldownCutoff: now - Number(control.host_cooldown_ms), + drainGraceMs: 0, + processSafety, + worker, + now, + skips: [], + idleRequest: request, + cohortPercent, + onSafetyDisabled: (event) => { safetyDisable = event } + }) + if (!attempt) return { outcome: 'deferred' } + await this.markRegionalRehomeDispatchClaimed( + transaction, request.attemptId, now, Math.ceil(60_000 / Number(control.rate_per_minute)) + ) + await transaction.query( + `UPDATE relay_region_rehome_attempts SET drain_receipt_at = ?, drain_outcome = 'accepted' + WHERE attempt_id = ?`, [now, request.attemptId] + ) + return { outcome: 'committed' } + }) + if (safetyDisable) console.warn(JSON.stringify(safetyDisable)) + return result + } + + async reconcileIdleRegionalRehome(request: IdleRegionalRehomeRequest): Promise<'committed' | 'not-committed' | 'stale'> { + return this.database.transaction(async (transaction) => { + // Absence is definitive only after the same assignment lock as commit/activation. + const assignment = await this.assignmentRow(transaction, request) + const attempt = (await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [request.attemptId] + ))[0] + if (attempt) { + return attempt.user_id === request.userId && + attempt.relay_host_id === request.relayHostId && + attempt.source_cell_id === request.sourceCellId && + attempt.source_cell_incarnation === request.sourceCellIncarnation && + Number(attempt.previous_epoch) === request.sourceAssignmentEpoch && + Number(attempt.source_generation) === request.sourceGeneration && + attempt.target_cell_id === request.targetCellId && + attempt.aborted_at == null + ? 'committed' : 'stale' + } + if (!assignment || assignment.cell_id !== request.sourceCellId || + Number(assignment.assignment_epoch) !== request.sourceAssignmentEpoch) return 'stale' + const control = (await transaction.query( + `SELECT capability.generation, capability.cell_incarnation + FROM relay_control_capabilities capability + JOIN relay_assignment_activity_leases lease + ON lease.user_id = capability.user_id AND lease.relay_host_id = capability.relay_host_id + AND lease.activity_id = capability.activity_id + WHERE capability.user_id = ? AND capability.relay_host_id = ? + AND capability.cell_id = ? AND capability.assignment_epoch = ? + AND lease.activity_kind = 'control' AND lease.expires_at > ? + ORDER BY capability.generation DESC LIMIT 1`, + [request.userId, request.relayHostId, request.sourceCellId, request.sourceAssignmentEpoch, this.now()] + ))[0] + return control && Number(control.generation) === request.sourceGeneration && + control.cell_incarnation === request.sourceCellIncarnation ? 'not-committed' : 'stale' + }) + } + + async previewRegionalRehomeEligibility( + processSafety?: RegionalRehomeSafetySnapshot + ): Promise { + const now = this.now() + const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now) + return previewRegionalRehomeEligibility({ + database: this.database, + now, + heartbeatTtlMs: this.heartbeatTtlMs, + cohortPercent: this.regionalRehomeCohortPercent, + globalSafetyFailure: processSafety + ? regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now) + : 'process-safety-unavailable', + connectionHeadroom: await this.connectionHeadroomByCell(this.database), + cellIsClean: regionalRehomeCellSafetyIsClean + }) + } + async renewControlActivity( identity: AssignmentIdentity, input: { activityId: string; cellId: string; expiresAt: number } @@ -3545,6 +3714,8 @@ export class RelayAssignmentStore { cellId: string assignmentEpoch: number generation: number + idleRegionalRehome?: boolean + cellIncarnation?: string connectionInclusionWatermark?: number } ): Promise { @@ -3626,6 +3797,33 @@ export class RelayAssignmentStore { input.connectionInclusionWatermark, now ) + await transaction.query( + `DELETE FROM relay_control_capabilities WHERE user_id = ? AND relay_host_id = ? + AND NOT EXISTS (SELECT 1 FROM relay_assignment_activity_leases lease + WHERE lease.user_id = relay_control_capabilities.user_id + AND lease.relay_host_id = relay_control_capabilities.relay_host_id + AND lease.activity_id = relay_control_capabilities.activity_id)`, + [identity.userId, identity.relayHostId] + ) + await transaction.query( + `INSERT INTO relay_control_capabilities + (user_id, relay_host_id, activity_id, cell_id, cell_incarnation, assignment_epoch, generation, finish_existing, idle_regional_rehome) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, relay_host_id, activity_id) DO UPDATE SET + cell_incarnation = excluded.cell_incarnation, assignment_epoch = excluded.assignment_epoch, + generation = excluded.generation, finish_existing = excluded.finish_existing, idle_regional_rehome = excluded.idle_regional_rehome`, + [ + identity.userId, + identity.relayHostId, + activityId, + input.cellId, + input.cellIncarnation ?? '', + input.assignmentEpoch, + input.generation, + 0, + input.idleRegionalRehome && input.cellIncarnation ? 1 : 0 + ] + ) return activityId }) }) @@ -5116,327 +5314,6 @@ export class RelayAssignmentStore { } } - async claimRegionalRehome( - processSafety?: RegionalRehomeSafetySnapshot - ): Promise { - const now = this.now() - // Directors poll every second; avoid taking the global worker-row lock while disabled. - const control = ( - await this.database.query( - `SELECT enabled, not_before - FROM relay_region_rehome_control - WHERE control_id = 'global'` - ) - )[0] - if (!control) { - await this.initializeRegionalRehomeControl(this.database, now) - return null - } - if (integer(control, 'enabled') !== 1 || integer(control, 'not_before') > now) { - return null - } - this.pendingRegionalRehomeDisableLog = null - const candidateSkips: RegionalRehomeCandidateSkip[] = [] - // A Postgres transaction is unusable after a NOWAIT abort, so a contended - // tick abandons the candidate it stopped on plus every one behind it. - let candidatesTotal = 0 - let candidatesFinished = 0 - const claimResult = await this.database.transaction(async (transaction) => { - candidatesTotal = 0 - candidatesFinished = 0 - candidateSkips.length = 0 - await this.initializeRegionalRehomeControl(transaction, now) - const control = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` - ) - )[0]! - if (integer(control, 'enabled') !== 1 || integer(control, 'not_before') > now) { - return null - } - const intervalMs = Math.ceil(60_000 / integer(control, 'rate_per_minute')) - const preferenceCutoff = now - integer(control, 'preference_max_age_ms') - // A host that was rehomed recently is left alone whichever way its - // preference now points: a flapping region probe must not walk one host - // back and forth across an ocean. - const cooldownCutoff = now - integer(control, 'host_cooldown_ms') - await transaction.query( - `INSERT INTO relay_region_rehome_worker_state - (worker_id, next_dispatch_at, paused_until, consecutive_failures, updated_at) - VALUES ('global', 0, 0, 0, ?) - ON CONFLICT (worker_id) DO NOTHING`, - [now] - ) - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0]! - if ( - integer(worker, 'paused_until') > now || - integer(worker, 'next_dispatch_at') > now - ) { - return null - } - const effectiveProcessSafety = processSafety ?? cleanRegionalRehomeSafety(now) - const fleetSafety = await this.readRegionalRehomeFleetSafety(transaction, now) - if ( - !(await this.regionalRehomeSafetyAllowsClaim( - transaction, - worker, - effectiveProcessSafety, - fleetSafety, - now - )) - ) { - return null - } - const retry = ( - await transaction.queryLocked( - `SELECT attempt.*, source.cell_url AS source_cell_url - FROM relay_region_rehome_attempts attempt - JOIN relay_cells source ON source.cell_id = attempt.source_cell_id - JOIN relay_cell_runtime runtime ON runtime.cell_id = attempt.source_cell_id - JOIN relay_cell_capabilities capability - ON capability.cell_id = runtime.cell_id - AND capability.cell_incarnation = runtime.cell_incarnation - JOIN relay_assignment_migrations migration - ON migration.user_id = attempt.user_id - AND migration.relay_host_id = attempt.relay_host_id - AND migration.assignment_epoch = attempt.assignment_epoch - WHERE attempt.drain_receipt_at IS NULL - AND attempt.completed_at IS NULL AND attempt.aborted_at IS NULL - AND attempt.send_attempts < 10 - AND (attempt.last_send_attempt_at IS NULL OR attempt.last_send_attempt_at <= ?) - AND runtime.cell_incarnation = attempt.source_cell_incarnation - AND runtime.ready = 1 AND runtime.last_heartbeat_at > ? - AND capability.regional_rehome_protocol >= 1 - AND migration.completed_at IS NULL AND migration.aborted_at IS NULL - ORDER BY attempt.created_at, attempt.attempt_id - LIMIT 1`, - [now - 30_000, now - this.heartbeatTtlMs] - ) - )[0] - if (retry) { - candidatesTotal = 1 - const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) - if ( - !(await this.regionalRehomeSafetyAllowsClaim( - transaction, - worker, - effectiveProcessSafety, - fleetSafety, - now - )) - ) { - return null - } - await this.markRegionalRehomeDispatchClaimed( - transaction, - text(retry, 'attempt_id'), - now, - intervalMs - ) - retry.send_attempts = integer(retry, 'send_attempts') + 1 - return regionalRehomeAttempt(retry) - } - - // A drain receipt is not convergence: grace enforcement lives only in - // source-cell session state, and attempts have been observed stalled - // dual-homed well past grace with source leases still renewing. Such - // attempts are re-dispatched with the remaining (zero) grace so the - // source force-closes and the host re-resolves onto its registered - // target. - const redrain = ( - await transaction.queryLocked( - `SELECT attempt.*, source.cell_url AS source_cell_url - FROM relay_region_rehome_attempts attempt - JOIN relay_cells source ON source.cell_id = attempt.source_cell_id - JOIN relay_cell_runtime runtime ON runtime.cell_id = attempt.source_cell_id - JOIN relay_cell_capabilities capability - ON capability.cell_id = runtime.cell_id - AND capability.cell_incarnation = runtime.cell_incarnation - JOIN relay_assignment_migrations migration - ON migration.user_id = attempt.user_id - AND migration.relay_host_id = attempt.relay_host_id - AND migration.assignment_epoch = attempt.assignment_epoch - WHERE attempt.drain_receipt_at IS NOT NULL - AND attempt.completed_at IS NULL AND attempt.aborted_at IS NULL - AND attempt.created_at + attempt.drain_grace_ms <= ? - AND attempt.send_attempts < ? - AND (attempt.last_send_attempt_at IS NULL OR attempt.last_send_attempt_at <= ?) - AND runtime.cell_incarnation = attempt.source_cell_incarnation - AND runtime.ready = 1 AND runtime.last_heartbeat_at > ? - AND capability.regional_rehome_protocol >= 1 - AND migration.completed_at IS NULL AND migration.aborted_at IS NULL - AND migration.target_registered_at IS NOT NULL - AND EXISTS ( - SELECT 1 FROM relay_assignment_activity_leases source_lease - WHERE source_lease.user_id = attempt.user_id - AND source_lease.relay_host_id = attempt.relay_host_id - AND source_lease.cell_id = attempt.source_cell_id - ) - ORDER BY attempt.created_at, attempt.attempt_id - LIMIT 1`, - [ - now, - REGIONAL_REHOME_REDRAIN_SEND_LIMIT, - now - REGIONAL_REHOME_REDRAIN_INTERVAL_MS, - now - this.heartbeatTtlMs - ] - ) - )[0] - if (redrain) { - candidatesTotal = 1 - const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) - if ( - !(await this.regionalRehomeSafetyAllowsClaim( - transaction, - worker, - effectiveProcessSafety, - fleetSafety, - now - )) - ) { - return null - } - await this.markRegionalRehomeDispatchClaimed( - transaction, - text(redrain, 'attempt_id'), - now, - intervalMs - ) - redrain.send_attempts = integer(redrain, 'send_attempts') + 1 - redrain.drain_grace_ms = 0 - return regionalRehomeAttempt(redrain) - } - - const candidates = await transaction.query( - `SELECT preference.user_id, preference.relay_host_id, - preference.observed_at, assignment.cell_id AS source_cell_id, - assignment.assignment_epoch - FROM relay_assignment_region_preferences preference - JOIN relay_assignments assignment - ON assignment.user_id = preference.user_id - AND assignment.relay_host_id = preference.relay_host_id - JOIN relay_cell_regions region ON region.cell_id = assignment.cell_id - JOIN relay_cell_admission admission ON admission.cell_id = assignment.cell_id - JOIN relay_cell_runtime runtime ON runtime.cell_id = assignment.cell_id - JOIN relay_cell_capabilities capability - ON capability.cell_id = runtime.cell_id - AND capability.cell_incarnation = runtime.cell_incarnation - WHERE preference.preferred_region <> region.region - AND preference.observed_at >= ? - AND admission.admission_state = 'general' - AND runtime.ready = 1 AND runtime.last_heartbeat_at > ? - AND capability.regional_rehome_protocol >= 1 - AND EXISTS ( - SELECT 1 FROM relay_assignment_activity_leases control - WHERE control.user_id = assignment.user_id - AND control.relay_host_id = assignment.relay_host_id - AND control.cell_id = assignment.cell_id - AND control.activity_kind = 'control' - AND control.activity_id NOT LIKE 'control-pending:%' - AND control.expires_at > ? - AND control.updated_at >= runtime.started_at - ) - AND NOT EXISTS ( - SELECT 1 FROM relay_assignment_migrations migration - WHERE migration.user_id = assignment.user_id - AND migration.relay_host_id = assignment.relay_host_id - AND migration.completed_at IS NULL AND migration.aborted_at IS NULL - ) - AND NOT EXISTS ( - SELECT 1 FROM relay_region_rehome_attempts recent - WHERE recent.user_id = preference.user_id - AND recent.relay_host_id = preference.relay_host_id - AND recent.created_at > ? - ) - AND EXISTS ( - SELECT 1 FROM relay_cell_regions target_region - JOIN relay_cells target_cell ON target_cell.cell_id = target_region.cell_id - JOIN relay_cell_admission target_admission - ON target_admission.cell_id = target_region.cell_id - JOIN relay_cell_runtime target_runtime - ON target_runtime.cell_id = target_region.cell_id - JOIN relay_cell_capabilities target_capability - ON target_capability.cell_id = target_runtime.cell_id - AND target_capability.cell_incarnation = target_runtime.cell_incarnation - WHERE target_region.region = preference.preferred_region - AND target_cell.enabled = 1 - AND target_admission.admission_state = 'general' - AND target_runtime.ready = 1 - AND target_runtime.last_heartbeat_at > ? - AND target_capability.regional_rehome_protocol >= 1 - ) - ORDER BY preference.observed_at, preference.user_id, preference.relay_host_id - LIMIT 10`, - [ - preferenceCutoff, - now - this.heartbeatTtlMs, - now, - cooldownCutoff, - now - this.heartbeatTtlMs - ] - ) - candidatesTotal = candidates.length - for (const candidate of candidates) { - const claimed = await this.startRegionalRehomeCandidate(transaction, { - identity: { - userId: text(candidate, 'user_id'), - relayHostId: text(candidate, 'relay_host_id') - }, - sourceCellId: text(candidate, 'source_cell_id'), - assignmentEpoch: integer(candidate, 'assignment_epoch'), - preferenceCutoff, - cooldownCutoff, - drainGraceMs: integer(control, 'drain_grace_ms'), - processSafety: effectiveProcessSafety, - worker, - now, - skips: candidateSkips - }) - candidatesFinished++ - if (!claimed) continue - await this.markRegionalRehomeDispatchClaimed( - transaction, - claimed.attemptId, - now, - intervalMs - ) - return { ...claimed, sendAttempts: 1 } - } - if (candidates.length > 0) { - // Skipped candidates still cost all-rows FOR UPDATE inventory scans; - // charge the dispatch interval so skips are rate-limited like claims. - await this.markRegionalRehomeTickSkipped(transaction, now, intervalMs) - } - return null - }).catch((error: unknown): RegionalRehomeAttempt | null => { - // Only inventory contention is swallowed here; every other failure keeps - // its existing propagation and its dispatch-failure accounting. - if (!isDatabaseLockUnavailable(error)) throw error - // The dispatch tick runs every second; losing one to inventory contention - // costs a second of latency and never loses durable rehome state. The - // rolled-back transaction never disabled anything, so its pending disable - // log would describe a decision that did not happen. - candidateSkips.length = 0 - this.pendingRegionalRehomeDisableLog = null - warnSweepCellInventoryBusy( - 'claim-regional-rehome', - Math.max(1, candidatesTotal - candidatesFinished) - ) - return null - }) - const pendingDisableLog = this.pendingRegionalRehomeDisableLog - this.pendingRegionalRehomeDisableLog = null - if (pendingDisableLog) console.warn(JSON.stringify(pendingDisableLog)) - if (claimResult === null && candidateSkips.length > 0) { - console.warn(JSON.stringify(aggregateRegionalRehomeCandidateSkips(candidateSkips))) - } - return claimResult - } - private async startRegionalRehomeCandidate( transaction: RelayDatabase, input: { @@ -5450,6 +5327,9 @@ export class RelayAssignmentStore { worker: SqlRow now: number skips: RegionalRehomeCandidateSkip[] + idleRequest: IdleRegionalRehomeRequest + cohortPercent: number + onSafetyDisabled: (event: Record | null) => void } ): Promise | null> { const assignment = await this.assignmentRow(transaction, input.identity) @@ -5463,12 +5343,21 @@ export class RelayAssignmentStore { } const preference = ( await transaction.queryLocked( - `SELECT * FROM relay_assignment_region_preferences + `SELECT * FROM relay_region_decisions WHERE user_id = ? AND relay_host_id = ?`, [input.identity.userId, input.identity.relayHostId] ) )[0] - if (!preference || integer(preference, 'observed_at') < input.preferenceCutoff) { + if ( + !preference || + integer(preference, 'observed_at') < input.preferenceCutoff || + Number(preference.expires_at) <= input.now || + preference.outcome !== 'conclusive' || + Number(preference.policy_version) !== 1 || + Number(preference.assignment_epoch) !== input.assignmentEpoch || + !preference.preferred_region || + Number(preference.cohort_bucket) >= input.cohortPercent + ) { input.skips.push({ reason: 'candidate_stale' }) return null } @@ -5540,13 +5429,13 @@ export class RelayAssignmentStore { input.now ) if (safetyFailure) { - await this.pauseRegionalRehomeForSafety( + input.onSafetyDisabled(await this.pauseRegionalRehomeForSafety( transaction, input.worker, input.now, safetyFailure, fleetSafety - ) + )) return null } // The preference read under lock can now agree with the cell the host is @@ -5564,9 +5453,9 @@ export class RelayAssignmentStore { integer(sourceRuntime, 'ready') !== 1 || integer(sourceRuntime, 'last_heartbeat_at') <= input.now - this.heartbeatTtlMs || !sourceCapability || - text(sourceCapability, 'cell_incarnation') !== - text(sourceRuntime, 'cell_incarnation') || - integer(sourceCapability, 'regional_rehome_protocol') < 1 + text(sourceCapability, 'cell_incarnation') !== text(sourceRuntime, 'cell_incarnation') || + integer(sourceCapability, 'regional_rehome_protocol') < 3 || + sourceRuntime.cell_incarnation !== input.idleRequest.sourceCellIncarnation ) { input.skips.push({ reason: 'source_ineligible', cellId: input.sourceCellId }) return null @@ -5575,6 +5464,32 @@ export class RelayAssignmentStore { input.skips.push(cellUncleanSkip('source_unclean', input.sourceCellId, sourceSafety)) return null } + const hostCapability = ( + await transaction.query( + `SELECT capability.* FROM relay_control_capabilities capability + JOIN relay_assignment_activity_leases lease + ON lease.user_id = capability.user_id AND lease.relay_host_id = capability.relay_host_id + AND lease.activity_id = capability.activity_id + WHERE capability.user_id = ? AND capability.relay_host_id = ? + AND capability.cell_id = ? AND capability.assignment_epoch = ? + AND capability.cell_incarnation = ? AND capability.idle_regional_rehome = 1 + AND lease.expires_at > ? AND lease.activity_kind = 'control' + ORDER BY capability.generation DESC LIMIT 1`, + [ + input.identity.userId, + input.identity.relayHostId, + input.sourceCellId, + input.assignmentEpoch, + sourceRuntime.cell_incarnation, + input.now + ] + ) + )[0] + if (!hostCapability || preference.incumbent_region !== regions.get(input.sourceCellId) || + Number(hostCapability.generation) !== input.idleRequest.sourceGeneration) { + input.skips.push({ reason: 'candidate_stale' }) + return null + } const sourceControlActive = activityLeases.some( (lease) => text(lease, 'cell_id') === input.sourceCellId && @@ -5606,7 +5521,8 @@ export class RelayAssignmentStore { integer(runtime, 'last_heartbeat_at') > input.now - this.heartbeatTtlMs && capability !== undefined && text(capability, 'cell_incarnation') === text(runtime, 'cell_incarnation') && - integer(capability, 'regional_rehome_protocol') >= 1 + integer(capability, 'regional_rehome_protocol') >= 3 && + cellId === input.idleRequest.targetCellId ) }) const targetIsClean = (row: SqlRow): boolean => { @@ -5753,7 +5669,7 @@ export class RelayAssignmentStore { text(targetRuntime, 'cell_incarnation') ] ) - const attemptId = randomUUID() + const attemptId = input.idleRequest.attemptId await transaction.query( `INSERT INTO relay_region_rehome_attempts (attempt_id, user_id, relay_host_id, preferred_region, @@ -5780,6 +5696,10 @@ export class RelayAssignmentStore { input.now ] ) + await transaction.query( + `UPDATE relay_region_rehome_attempts SET source_generation = ? WHERE attempt_id = ?`, + [input.idleRequest.sourceGeneration, attemptId] + ) return { ...input.identity, attemptId, @@ -5795,61 +5715,13 @@ export class RelayAssignmentStore { } } - private async lockedRegionalRehomeFleetSafety( - transaction: RelayDatabase, - now: number - ): Promise { - const cells = await this.lockCellInventory(transaction, 'nowait') - const admission = await cellAdmissionStates(transaction) - const regions = new Map( - (await transaction.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ - text(row, 'cell_id'), - relayRegion(row, 'region') - ]) - ) - const runtimes = await transaction.queryLocked( - `SELECT * FROM relay_cell_runtime ORDER BY cell_id` - ) - const capabilities = await transaction.queryLocked( - `SELECT * FROM relay_cell_capabilities ORDER BY cell_id` - ) - const safetyRows = await transaction.queryLocked( - `SELECT * FROM relay_cell_rehome_safety ORDER BY cell_id` - ) - return regionalRehomeFleetSafetyFromInventory({ - cells, - admission, - regions, - runtimes, - capabilities, - safetyRows, - now, - heartbeatTtlMs: this.heartbeatTtlMs - }) - } - - private async regionalRehomeSafetyAllowsClaim( - transaction: RelayDatabase, - worker: SqlRow, - processSafety: RegionalRehomeSafetySnapshot, - fleetSafety: RegionalRehomeFleetSafety, - now: number - ): Promise { - const failure = regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now) - if (!failure) { - return true - } - await this.pauseRegionalRehomeForSafety(transaction, worker, now, failure, fleetSafety) - return false - } - private async pauseRegionalRehomeForSafety( transaction: RelayDatabase, worker: SqlRow, now: number, reason: string, fleetSafety: RegionalRehomeFleetSafety - ): Promise { + ): Promise | null> { const disabled = await transaction.query( `UPDATE relay_region_rehome_control SET generation = generation + 1, enabled = 0, updated_at = ? @@ -5860,8 +5732,9 @@ export class RelayAssignmentStore { // The durable disable is otherwise invisible: nothing else records why // claims stopped and inspection only shows enabled=false. Logged after // the transaction commits so a rollback cannot fabricate the record. + let event: Record | null = null if (disabled.length > 0) { - this.pendingRegionalRehomeDisableLog = { + event = { event: 'orca_relay_regional_rehome_safety_disabled', reason, controlGeneration: integer(disabled[0]!, 'generation'), @@ -5879,19 +5752,9 @@ export class RelayAssignmentStore { } } await this.incrementRegionalRehomeWorkerFailure(transaction, worker, now) + return event } - private async markRegionalRehomeTickSkipped( - transaction: RelayDatabase, - now: number, - intervalMs: number - ): Promise { - await transaction.query( - `UPDATE relay_region_rehome_worker_state - SET next_dispatch_at = ?, updated_at = ? WHERE worker_id = 'global'`, - [now + intervalMs, now] - ) - } private async markRegionalRehomeDispatchClaimed( transaction: RelayDatabase, @@ -5912,74 +5775,6 @@ export class RelayAssignmentStore { ) } - async recordRegionalRehomeDrainReceipt( - attemptId: string, - outcome: RegionalHostDrainOutcome - ): Promise { - const now = this.now() - return await this.database.transaction(async (transaction) => { - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0] - const attempt = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attemptId] - ) - )[0] - if (!attempt) throw new Error('regional_rehome_attempt_not_found') - // Any receipt proves the source cell answered: reset the failure budget - // even when a redrain repeats the stored outcome; otherwise a - // redrain-dominated stream lets scattered transient failures reach the - // durable three-failure disable. - if (worker) { - await transaction.query( - `UPDATE relay_region_rehome_worker_state - SET consecutive_failures = 0, paused_until = 0, updated_at = ? - WHERE worker_id = 'global'`, - [now] - ) - } - const existingOutcome = optionalText(attempt, 'drain_outcome') - if (existingOutcome === outcome) return false - // Redrains produce one receipt per dispatch; the latest outcome wins. - await transaction.query( - `UPDATE relay_region_rehome_attempts - SET drain_receipt_at = ?, drain_outcome = ?, updated_at = ? - WHERE attempt_id = ?`, - [now, outcome, now, attemptId] - ) - return true - }) - } - - async recordRegionalRehomeDispatchFailure(attemptId: string): Promise { - const now = this.now() - const disableLog = await this.database.transaction(async (transaction) => { - // Match claim and enable ordering before a spent budget updates the control. - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` - ) - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0] - const attempt = ( - await transaction.queryLocked( - `SELECT attempt_id FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attemptId] - ) - )[0] - if (!worker || !attempt) return null - return await this.incrementRegionalRehomeWorkerFailure(transaction, worker, now) - }) - // Logged after the commit so a rollback cannot fabricate the record. - if (disableLog) console.warn(JSON.stringify(disableLog)) - } - // Returns the durable disable this failure caused, for the caller to log once // its transaction commits; null when the budget survives or was already spent. private async incrementRegionalRehomeWorkerFailure( @@ -6074,7 +5869,7 @@ export class RelayAssignmentStore { // LIMIT pages: poisoned rows are permanent and always the oldest, so // without exclusion they eventually starve every healthy candidate. private recordRegionalRehomeCandidateFailure( - operation: 'complete' | 'abort', + operation: 'complete' | 'abort' | 'refresh', attemptId: string, now: number, error: unknown @@ -6116,12 +5911,16 @@ export class RelayAssignmentStore { async refreshRegionalRehomeLeases(limit = 100): Promise { const now = this.now() + const quarantined = this.quarantinedRegionalRehomeAttemptIds(now) + const exclusion = quarantined.length + ? ` AND attempt_id NOT IN (${quarantined.map(() => '?').join(', ')})` + : '' const candidates = await this.database.query( - `SELECT user_id, relay_host_id, assignment_epoch + `SELECT attempt_id, user_id, relay_host_id, assignment_epoch FROM relay_region_rehome_attempts - WHERE completed_at IS NULL AND aborted_at IS NULL - ORDER BY created_at, attempt_id LIMIT ?`, - [limit] + WHERE completed_at IS NULL AND aborted_at IS NULL${exclusion} + ORDER BY updated_at, attempt_id LIMIT ?`, + [...quarantined, limit] ) let refreshed = 0 for (const candidate of candidates) { @@ -6130,50 +5929,91 @@ export class RelayAssignmentStore { relayHostId: text(candidate, 'relay_host_id') } const assignmentEpoch = integer(candidate, 'assignment_epoch') - const changed = await this.database.transaction(async (transaction) => { - const assignment = await this.assignmentRow(transaction, identity) - const attempt = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_attempts + const attemptId = text(candidate, 'attempt_id') + try { + const changed = await this.database.transaction(async (transaction) => { + const assignment = await this.assignmentRow(transaction, identity) + const attempt = ( + await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_attempts WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - )[0] - const migration = ( - await transaction.queryLocked( - `SELECT * FROM relay_assignment_migrations + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + )[0] + const migration = ( + await transaction.queryLocked( + `SELECT * FROM relay_assignment_migrations WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - )[0] - if ( - !assignment || - !attempt || - !migration || - optionalInteger(attempt, 'completed_at') !== undefined || - optionalInteger(attempt, 'aborted_at') !== undefined || - optionalInteger(migration, 'completed_at') !== undefined || - optionalInteger(migration, 'aborted_at') !== undefined - ) { - return false - } - const attemptAgeMs = now - integer(attempt, 'created_at') - if (attemptAgeMs >= REGIONAL_REHOME_MAX_REFRESH_MS) { - return false - } - if ( - optionalInteger(migration, 'target_registered_at') === undefined && - attemptAgeMs >= REGIONAL_REHOME_UNREGISTERED_REFRESH_MS - ) { - await transaction.query( - `UPDATE relay_assignment_activity_leases + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + )[0] + if (attempt && attempt.completed_at == null && attempt.aborted_at == null) { + await transaction.query( + `UPDATE relay_region_rehome_attempts SET updated_at = ? WHERE attempt_id = ?`, + [now, attempt.attempt_id] + ) + } + if ( + !assignment || + !attempt || + !migration || + optionalInteger(attempt, 'completed_at') !== undefined || + optionalInteger(attempt, 'aborted_at') !== undefined || + optionalInteger(migration, 'completed_at') !== undefined || + optionalInteger(migration, 'aborted_at') !== undefined + ) { + return false + } + const attemptAgeMs = now - integer(attempt, 'created_at') + if (attemptAgeMs >= REGIONAL_REHOME_MAX_REFRESH_MS) { + return false + } + if ( + optionalInteger(migration, 'target_registered_at') === undefined && + attemptAgeMs >= REGIONAL_REHOME_UNREGISTERED_REFRESH_MS + ) { + await transaction.query( + `UPDATE relay_assignment_activity_leases SET expires_at = CASE WHEN expires_at < ? THEN expires_at ELSE ? END, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND activity_id IN (?, ?)`, + [ + now, + now, + now, + identity.userId, + identity.relayHostId, + pendingControlActivityId(assignmentEpoch), + migrationActivityId(assignmentEpoch) + ] + ) + await transaction.query( + `UPDATE relay_assignment_migrations + SET expires_at = CASE WHEN expires_at < ? THEN expires_at ELSE ? END, + updated_at = ? + WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, + [now, now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return false + } + const leases = await this.lockAssignmentActivities(transaction, identity) + const protectedIds = new Set([ + pendingControlActivityId(assignmentEpoch), + migrationActivityId(assignmentEpoch) + ]) + const protectedLeases = leases.filter((lease) => + protectedIds.has(text(lease, 'activity_id')) + ) + if (protectedLeases.length === 0) return false + const expiresAt = now + ASSIGNMENT_LIMITS.migrationLeaseMs + await transaction.query( + `UPDATE relay_assignment_activity_leases + SET expires_at = ?, updated_at = ? + WHERE user_id = ? AND relay_host_id = ? + AND activity_id IN (?, ?)`, [ - now, - now, + expiresAt, now, identity.userId, identity.relayHostId, @@ -6182,53 +6022,25 @@ export class RelayAssignmentStore { ] ) await transaction.query( - `UPDATE relay_assignment_migrations - SET expires_at = CASE WHEN expires_at < ? THEN expires_at ELSE ? END, - updated_at = ? - WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return false - } - const leases = await this.lockAssignmentActivities(transaction, identity) - const protectedIds = new Set([ - pendingControlActivityId(assignmentEpoch), - migrationActivityId(assignmentEpoch) - ]) - const protectedLeases = leases.filter((lease) => - protectedIds.has(text(lease, 'activity_id')) - ) - if (protectedLeases.length === 0) return false - const expiresAt = now + ASSIGNMENT_LIMITS.migrationLeaseMs - await transaction.query( - `UPDATE relay_assignment_activity_leases - SET expires_at = ?, updated_at = ? - WHERE user_id = ? AND relay_host_id = ? - AND activity_id IN (?, ?)`, - [ - expiresAt, - now, - identity.userId, - identity.relayHostId, - pendingControlActivityId(assignmentEpoch), - migrationActivityId(assignmentEpoch) - ] - ) - await transaction.query( - `UPDATE relay_assignment_migrations SET expires_at = ?, updated_at = ? + `UPDATE relay_assignment_migrations SET expires_at = ?, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [expiresAt, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - await transaction.query( - `UPDATE relay_assignments SET lease_expires_at = + [expiresAt, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + await transaction.query( + `UPDATE relay_assignments SET lease_expires_at = CASE WHEN lease_expires_at > ? THEN lease_expires_at ELSE ? END, last_activity_at = ? WHERE user_id = ? AND relay_host_id = ?`, - [expiresAt, expiresAt, now, identity.userId, identity.relayHostId] - ) - return true - }) - if (changed) refreshed++ + [expiresAt, expiresAt, now, identity.userId, identity.relayHostId] + ) + return true + }) + if (changed) refreshed++ + this.regionalRehomeCandidateQuarantine.delete(attemptId) + } catch (error) { + if (!isDatabaseLockUnavailable(error)) + this.recordRegionalRehomeCandidateFailure('refresh', attemptId, now, error) + } } return refreshed } @@ -6575,92 +6387,169 @@ export class RelayAssignmentStore { let aborted = 0 let inventoryBusy = 0 for (const candidate of candidates) { - const didAbort = await this.database.transaction(async (transaction) => { - const identity = { - userId: text(candidate, 'user_id'), - relayHostId: text(candidate, 'relay_host_id') - } - // Migration cleanup follows the same assignment-first order as evacuation. - const assignment = await this.assignmentRow(transaction, identity) - const assignmentEpoch = integer(candidate, 'assignment_epoch') - const regionalAttempt = ( - await transaction.queryLocked( - `SELECT attempt_id FROM relay_region_rehome_attempts + const didAbort = await this.database + .transaction(async (transaction) => { + const identity = { + userId: text(candidate, 'user_id'), + relayHostId: text(candidate, 'relay_host_id') + } + // Migration cleanup follows the same assignment-first order as evacuation. + const assignment = await this.assignmentRow(transaction, identity) + const assignmentEpoch = integer(candidate, 'assignment_epoch') + const regionalAttempt = ( + await transaction.queryLocked( + `SELECT attempt_id FROM relay_region_rehome_attempts WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ? AND completed_at IS NULL AND aborted_at IS NULL`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - )[0] - const row = ( - await transaction.queryLocked( - `SELECT migration.* FROM relay_assignment_migrations migration + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + )[0] + const row = ( + await transaction.queryLocked( + `SELECT migration.* FROM relay_assignment_migrations migration WHERE migration.user_id = ? AND migration.relay_host_id = ? AND migration.assignment_epoch = ? AND migration.expires_at <= ? AND migration.completed_at IS NULL AND migration.aborted_at IS NULL AND ${ABORTABLE_EXPIRED_MIGRATION}`, - [ - identity.userId, - identity.relayHostId, - assignmentEpoch, - now, - now, - abandonedBefore, - abandonedBefore - ] + [ + identity.userId, + identity.relayHostId, + assignmentEpoch, + now, + now, + abandonedBefore, + abandonedBefore + ] + ) + )[0] + if (!row) return false + const targetCellId = text(row, 'target_cell_id') + const activityLeases = await this.lockAssignmentActivities(transaction, identity) + if (!assignment) throw new Error('migration_assignment_missing') + const currentAssignmentEpoch = integer(assignment, 'assignment_epoch') + const assignmentEpochMatches = + text(assignment, 'cell_id') === targetCellId && + currentAssignmentEpoch === assignmentEpoch + const pendingTargetControl = activityLeaseById( + activityLeases, + pendingControlActivityId(assignmentEpoch) ) - )[0] - if (!row) return false - const targetCellId = text(row, 'target_cell_id') - const activityLeases = await this.lockAssignmentActivities(transaction, identity) - if (!assignment) throw new Error('migration_assignment_missing') - const currentAssignmentEpoch = integer(assignment, 'assignment_epoch') - const assignmentEpochMatches = - text(assignment, 'cell_id') === targetCellId && - currentAssignmentEpoch === assignmentEpoch - const pendingTargetControl = activityLeaseById( - activityLeases, - pendingControlActivityId(assignmentEpoch) - ) - const targetGrantIsFresh = - assignmentEpochMatches && - pendingTargetControl !== undefined && - text(pendingTargetControl, 'cell_id') === targetCellId && - text(pendingTargetControl, 'activity_kind') === 'control' && - integer(pendingTargetControl, 'expires_at') > now - const targetIsActive = activityLeases.some( - (lease) => - text(lease, 'cell_id') === targetCellId && - text(lease, 'activity_kind') === 'control' && - text(lease, 'activity_id') !== pendingControlActivityId(assignmentEpoch) - ) - if (targetGrantIsFresh) return false - if (targetIsActive && assignmentEpochMatches) { - // A committed target control is stronger evidence than a failed follow-up - // write; repair the marker instead of rolling a live desktop backward. - await transaction.query( - `UPDATE relay_assignment_migrations + const targetGrantIsFresh = + assignmentEpochMatches && + pendingTargetControl !== undefined && + text(pendingTargetControl, 'cell_id') === targetCellId && + text(pendingTargetControl, 'activity_kind') === 'control' && + integer(pendingTargetControl, 'expires_at') > now + const targetIsActive = activityLeases.some( + (lease) => + text(lease, 'cell_id') === targetCellId && + text(lease, 'activity_kind') === 'control' && + text(lease, 'activity_id') !== pendingControlActivityId(assignmentEpoch) + ) + if (targetGrantIsFresh) return false + if (targetIsActive && assignmentEpochMatches) { + // A committed target control is stronger evidence than a failed follow-up + // write; repair the marker instead of rolling a live desktop backward. + await transaction.query( + `UPDATE relay_assignment_migrations SET target_registered_at = COALESCE(target_registered_at, ?), updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return false - } - if (!assignmentEpochMatches) { - if (currentAssignmentEpoch <= assignmentEpoch) { - throw new Error('migration_assignment_mismatch') + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return false } - // A newer assignment is authoritative regardless of where it landed. - // Retire only this obsolete migration; never rewrite the newer epoch. - const obsoleteLeases = [ + if (!assignmentEpochMatches) { + if (currentAssignmentEpoch <= assignmentEpoch) { + throw new Error('migration_assignment_mismatch') + } + // A newer assignment is authoritative regardless of where it landed. + // Retire only this obsolete migration; never rewrite the newer epoch. + const obsoleteLeases = [ + pendingControlActivityId(assignmentEpoch), + migrationActivityId(assignmentEpoch) + ] + .map((activityId) => activityLeaseById(activityLeases, activityId)) + .filter((lease): lease is SqlRow => lease !== undefined) + if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'nowait') + for (const lease of obsoleteLeases) { + await this.removeActivityLease(transaction, identity, lease, now) + } + await this.releaseSupersededControlConnectionReservations( + transaction, + identity, + targetCellId, + assignmentEpoch, + now + ) + await transaction.query( + `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? + WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return true + } + const cells = await this.lockCellInventory(transaction, 'nowait') + const sourceCellId = text(row, 'source_cell_id') + const admissionRows = await transaction.query( + `SELECT cell_id, admission_state, updated_at FROM relay_cell_admission + WHERE cell_id IN (?, ?)`, + [sourceCellId, targetCellId] + ) + const sourceCell = cells.find((cell) => text(cell, 'cell_id') === sourceCellId) + const targetCell = cells.find((cell) => text(cell, 'cell_id') === targetCellId) + const sourceAdmission = admissionRows.find( + (admission) => text(admission, 'cell_id') === sourceCellId + ) + const targetAdmission = admissionRows.find( + (admission) => text(admission, 'cell_id') === targetCellId + ) + const registered = optionalInteger(row, 'target_registered_at') !== undefined + const sourceIsDurablyFenced = + registered && + ( + await transaction.query( + `SELECT 1 FROM relay_assignment_migrations migration + WHERE migration.user_id = ? AND migration.relay_host_id = ? + AND migration.assignment_epoch = ? + AND ${DURABLY_FENCED_MIGRATION_SOURCE}`, + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + ).length === 1 + const retireOnTarget = + registered && + activityUnitsForCell(activityLeases, sourceCellId) === 0 && + sourceCell !== undefined && + integer(sourceCell, 'enabled') === 0 && + sourceAdmission !== undefined && + text(sourceAdmission, 'admission_state') === 'existing-only' && + (integer(sourceAdmission, 'updated_at') <= abandonedBefore || sourceIsDurablyFenced) && + targetCell !== undefined && + integer(targetCell, 'enabled') === 1 && + targetAdmission !== undefined && + ['migration-only', 'general'].includes(text(targetAdmission, 'admission_state')) + const rollbackReason = + !registered || + (targetCell !== undefined && + integer(targetCell, 'enabled') === 0 && + targetAdmission !== undefined && + text(targetAdmission, 'admission_state') === 'existing-only' && + integer(targetAdmission, 'updated_at') <= abandonedBefore) + const regionalRollbackSourceAvailable = + !regionalAttempt || + (sourceCell !== undefined && + integer(sourceCell, 'enabled') === 1 && + sourceAdmission !== undefined && + text(sourceAdmission, 'admission_state') === 'general' && + (await this.cellIsLive(transaction, sourceCellId, now))) + const rollbackToSource = rollbackReason && regionalRollbackSourceAvailable + if (!retireOnTarget && !rollbackToSource) return false + for (const activityId of [ pendingControlActivityId(assignmentEpoch), migrationActivityId(assignmentEpoch) - ] - .map((activityId) => activityLeaseById(activityLeases, activityId)) - .filter((lease): lease is SqlRow => lease !== undefined) - if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'nowait') - for (const lease of obsoleteLeases) { - await this.removeActivityLease(transaction, identity, lease, now) + ]) { + const lease = activityLeaseById(activityLeases, activityId) + if (lease) await this.removeActivityLease(transaction, identity, lease, now) } await this.releaseSupersededControlConnectionReservations( transaction, @@ -6669,116 +6558,47 @@ export class RelayAssignmentStore { assignmentEpoch, now ) - await transaction.query( - `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? - WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return true - } - const cells = await this.lockCellInventory(transaction, 'nowait') - const sourceCellId = text(row, 'source_cell_id') - const admissionRows = await transaction.query( - `SELECT cell_id, admission_state, updated_at FROM relay_cell_admission - WHERE cell_id IN (?, ?)`, - [sourceCellId, targetCellId] - ) - const sourceCell = cells.find((cell) => text(cell, 'cell_id') === sourceCellId) - const targetCell = cells.find((cell) => text(cell, 'cell_id') === targetCellId) - const sourceAdmission = admissionRows.find( - (admission) => text(admission, 'cell_id') === sourceCellId - ) - const targetAdmission = admissionRows.find( - (admission) => text(admission, 'cell_id') === targetCellId - ) - const registered = optionalInteger(row, 'target_registered_at') !== undefined - const sourceIsDurablyFenced = - registered && - ( + if (retireOnTarget) { await transaction.query( - `SELECT 1 FROM relay_assignment_migrations migration - WHERE migration.user_id = ? AND migration.relay_host_id = ? - AND migration.assignment_epoch = ? - AND ${DURABLY_FENCED_MIGRATION_SOURCE}`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - ).length === 1 - const retireOnTarget = - registered && - activityUnitsForCell(activityLeases, sourceCellId) === 0 && - sourceCell !== undefined && - integer(sourceCell, 'enabled') === 0 && - sourceAdmission !== undefined && - text(sourceAdmission, 'admission_state') === 'existing-only' && - (integer(sourceAdmission, 'updated_at') <= abandonedBefore || - sourceIsDurablyFenced) && - targetCell !== undefined && - integer(targetCell, 'enabled') === 1 && - targetAdmission !== undefined && - ['migration-only', 'general'].includes(text(targetAdmission, 'admission_state')) - const rollbackReason = - !registered || - (targetCell !== undefined && - integer(targetCell, 'enabled') === 0 && - targetAdmission !== undefined && - text(targetAdmission, 'admission_state') === 'existing-only' && - integer(targetAdmission, 'updated_at') <= abandonedBefore) - const regionalRollbackSourceAvailable = - !regionalAttempt || - (sourceCell !== undefined && - integer(sourceCell, 'enabled') === 1 && - sourceAdmission !== undefined && - text(sourceAdmission, 'admission_state') === 'general' && - (await this.cellIsLive(transaction, sourceCellId, now))) - const rollbackToSource = rollbackReason && regionalRollbackSourceAvailable - if (!retireOnTarget && !rollbackToSource) return false - for (const activityId of [ - pendingControlActivityId(assignmentEpoch), - migrationActivityId(assignmentEpoch) - ]) { - const lease = activityLeaseById(activityLeases, activityId) - if (lease) await this.removeActivityLease(transaction, identity, lease, now) - } - await this.releaseSupersededControlConnectionReservations( - transaction, - identity, - targetCellId, - assignmentEpoch, - now - ) - if (retireOnTarget) { - await transaction.query( - `UPDATE relay_assignment_migrations SET completed_at = ?, updated_at = ? + `UPDATE relay_assignment_migrations SET completed_at = ?, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return true - } - await transaction.query( - `UPDATE relay_assignments SET cell_id = ?, assignment_epoch = ?, + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return true + } + await transaction.query( + `UPDATE relay_assignments SET cell_id = ?, assignment_epoch = ?, lease_expires_at = ?, last_activity_at = ? WHERE user_id = ? AND relay_host_id = ?`, - [ - sourceCellId, - assignmentEpoch + 1, - now + ASSIGNMENT_LIMITS.activityLeaseMs, - now, - identity.userId, - identity.relayHostId - ] - ) - await transaction.query( - `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? + [ + sourceCellId, + assignmentEpoch + 1, + now + ASSIGNMENT_LIMITS.activityLeaseMs, + now, + identity.userId, + identity.relayHostId + ] + ) + await transaction.query( + `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return true - }).catch((error: unknown): boolean => { - // Expiry is durable; another director settling this row is not a failure. - if (!isDatabaseLockUnavailable(error)) throw error - inventoryBusy++ - return false - }) + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + if (regionalAttempt) { + await transaction.query( + `UPDATE relay_region_rehome_attempts SET aborted_at = ?, updated_at = ? WHERE attempt_id = ?`, + [now, now, regionalAttempt.attempt_id] + ) + } + return true + }) + .catch((error: unknown): boolean => { + // Expiry is durable; another director settling this row is not a failure. + // Invariant failures remain fatal so operators see corrupt migration state. + if (!isDatabaseLockUnavailable(error)) throw error + inventoryBusy++ + return false + }) if (didAbort) aborted++ } warnSweepCellInventoryBusy('abort-expired-evacuations', inventoryBusy) @@ -8165,7 +7985,7 @@ function migration(identity: AssignmentIdentity, row: SqlRow): RelayAssignmentMi // Attempt ids are server-minted UUIDs and this codebase's invariant messages // are snake_case slugs; anything else could carry secrets and logs redacted. function warnRegionalRehomeCandidateFailure( - operation: 'complete' | 'abort', + operation: 'complete' | 'abort' | 'refresh', attemptId: string, error: unknown ): void { @@ -8190,24 +8010,6 @@ function noteRegionalRehomeActivityCountsRepaired(attemptId: string): void { ) } -function regionalRehomeAttempt(row: SqlRow): RegionalRehomeAttempt { - return { - attemptId: text(row, 'attempt_id'), - userId: text(row, 'user_id'), - relayHostId: text(row, 'relay_host_id'), - preferredRegion: relayRegion(row, 'preferred_region'), - sourceCellId: text(row, 'source_cell_id'), - sourceCellUrl: text(row, 'source_cell_url'), - sourceCellIncarnation: text(row, 'source_cell_incarnation'), - targetCellId: text(row, 'target_cell_id'), - targetCellIncarnation: text(row, 'target_cell_incarnation'), - previousEpoch: integer(row, 'previous_epoch'), - assignmentEpoch: integer(row, 'assignment_epoch'), - drainGraceMs: integer(row, 'drain_grace_ms'), - sendAttempts: integer(row, 'send_attempts') - } -} - function regionalRehomeControl(row: SqlRow): RegionalRehomeControl { return { generation: integer(row, 'generation'), @@ -8221,17 +8023,6 @@ function regionalRehomeControl(row: SqlRow): RegionalRehomeControl { } } -function cleanRegionalRehomeSafety(now: number): RegionalRehomeSafetySnapshot { - return { - observedAt: now, - sqlFailures: 0, - reconnects: 0, - controlActivityRecoveryFailures: 0, - databasePoolWaiting: 0, - databasePoolWaitersMax: 0, - databasePoolWaitMsMax: 0 - } -} function regionalRehomeFleetSafetyFromInventory(input: { cells: SqlRow[] @@ -8353,23 +8144,6 @@ function cellUncleanSkip( // Candidate skips are otherwise invisible: they neither latch the control off // nor produce attempts, so an operator cannot tell "skipping" from "idle". // Cell ids and counters only — never free-form error text. -function aggregateRegionalRehomeCandidateSkips( - skips: readonly RegionalRehomeCandidateSkip[] -): Record { - // `candidates` counts skipped candidate iterations, not distinct cells: one - // unclean cell blocking six candidates reports candidates=6 on one cellId. - const aggregated = new Map() - for (const skip of skips) { - const key = `${skip.reason}:${skip.cellId ?? ''}` - const entry = aggregated.get(key) - if (entry) entry.candidates += 1 - else aggregated.set(key, { ...skip, candidates: 1 }) - } - return { - event: 'orca_relay_regional_rehome_candidates_skipped', - skips: [...aggregated.values()] - } -} function regionalRehomeCellSafetyIsClean( safety: SqlRow | undefined, diff --git a/cloud/apps/relay/src/cell-heartbeat-client.test.ts b/cloud/apps/relay/src/cell-heartbeat-client.test.ts index 2aa708bed1b..6c36829e768 100644 --- a/cloud/apps/relay/src/cell-heartbeat-client.test.ts +++ b/cloud/apps/relay/src/cell-heartbeat-client.test.ts @@ -92,7 +92,7 @@ describe('cell heartbeat client', () => { client.stop() expect(JSON.parse(String(requests[1]!.body))).toMatchObject({ - regionalRehomeProtocol: 1, + regionalRehomeProtocol: 3, safety: { observedAt: 120, sqlFailures: 0, @@ -149,28 +149,34 @@ describe('cell heartbeat client', () => { it('does not start outside an explicitly configured cell role', () => { expect( - startCellHeartbeat({ ...CONFIG, role: 'director' }, { - ready: async () => true, - observedRequests: () => 0, - connectionCounts: () => ({ - totalConnections: 0, - inFlightConnections: 0, - reservedConnectionUnits: 0, - enforcedConnectionUnits: 0 - }) - }) + startCellHeartbeat( + { ...CONFIG, role: 'director' }, + { + ready: async () => true, + observedRequests: () => 0, + connectionCounts: () => ({ + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0 + }) + } + ) ).toBeNull() expect( - startCellHeartbeat({ ...CONFIG, directorUrl: undefined }, { - ready: async () => true, - observedRequests: () => 0, - connectionCounts: () => ({ - totalConnections: 0, - inFlightConnections: 0, - reservedConnectionUnits: 0, - enforcedConnectionUnits: 0 - }) - }) + startCellHeartbeat( + { ...CONFIG, directorUrl: undefined }, + { + ready: async () => true, + observedRequests: () => 0, + connectionCounts: () => ({ + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0 + }) + } + ) ).toBeNull() }) }) diff --git a/cloud/apps/relay/src/cell-heartbeat-client.ts b/cloud/apps/relay/src/cell-heartbeat-client.ts index 5c990310413..54f97a17af8 100644 --- a/cloud/apps/relay/src/cell-heartbeat-client.ts +++ b/cloud/apps/relay/src/cell-heartbeat-client.ts @@ -70,8 +70,7 @@ export function startCellHeartbeat( inFlightConnections: connectionCounts!.inFlightConnections, reservedConnectionUnits: connectionCounts!.reservedConnectionUnits, enforcedConnectionUnits: connectionCounts!.enforcedConnectionUnits, - connectionInclusionWatermark: - connectionCounts!.inclusionWatermark, + connectionInclusionWatermark: connectionCounts!.inclusionWatermark, connectionHardCap: config.connectionHardCap, connectionUnobservedBound: config.connectionUnobservedBound }) @@ -94,7 +93,7 @@ export function startCellHeartbeat( cellId: config.cellId, cellIncarnation, regionalRehomeProtocol: - config.rehomeAudience && config.rehomeDirectorServiceAccount ? 1 : 0, + config.rehomeAudience && config.rehomeDirectorServiceAccount ? 3 : 0, safety: options.regionalRehomeSafety() }), signal: AbortSignal.timeout(10_000) @@ -106,7 +105,10 @@ export function startCellHeartbeat( } } catch (error) { // A heartbeat must fail closed without ever logging its bearer token. - console.warn('[orca-relay] cell heartbeat failed', error instanceof Error ? error.message : '') + console.warn( + '[orca-relay] cell heartbeat failed', + error instanceof Error ? error.message : '' + ) } finally { inFlight = false } diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts index 0ac4c8225e3..929173eb9da 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts @@ -43,14 +43,13 @@ const CENSUS: CensusEntry[] = [ { method: 'completeEvacuation', mode: 'nowait', reach: 'both' }, { method: 'completeEvacuation', mode: 'pool-default', reach: 'both' }, { method: 'rebalanceDormant', mode: 'request', reach: 'request' }, - { method: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, - { method: 'lockedRegionalRehomeFleetSafety', mode: 'nowait', reach: 'sweep' }, + { method: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'request' }, { method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredRegionalRehomes', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, - { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' }, + { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' } // reconcileReservationAccounting and leastLoadedCell are gone too: the first // repairs exactly two cells' counters and now holds only those rows, and the // second selects from the inventory its single caller has already locked. @@ -119,9 +118,10 @@ function storeCallGraph(lines: string[]): Map> { bounds.forEach((method, index) => { const end = bounds[index + 1]?.start ?? lines.length const names = callees.get(method.name) ?? new Set() - for (const call of lines.slice(method.start, end).join('\n').matchAll( - /this\.([A-Za-z_][\w]*)\s*\(/g - )) { + for (const call of lines + .slice(method.start, end) + .join('\n') + .matchAll(/this\.([A-Za-z_][\w]*)\s*\(/g)) { names.add(call[1]!) } callees.set(method.name, names) @@ -174,9 +174,7 @@ function readCallSites(): { method: string; mode: CensusMode }[] { describe('cell inventory lock call-site census', () => { it('classifies every call site exactly as recorded', () => { - expect(readCallSites()).toEqual( - CENSUS.map(({ method, mode }) => ({ method, mode })) - ) + expect(readCallSites()).toEqual(CENSUS.map(({ method, mode }) => ({ method, mode }))) }) // Why: the census only sees lockCellInventory calls, so a hand-written diff --git a/cloud/apps/relay/src/config.test.ts b/cloud/apps/relay/src/config.test.ts index bb0522dcbd3..01661a61826 100644 --- a/cloud/apps/relay/src/config.test.ts +++ b/cloud/apps/relay/src/config.test.ts @@ -25,6 +25,17 @@ function cellEnvironment(capacity: number): NodeJS.ProcessEnv { } describe('GCE relay capacity configuration', () => { + it('defaults optional region correction off and bounds the cohort', () => { + const env = cellEnvironment(4_000) + expect(loadRelayConfig(env).regionCorrectionCohortPercent).toBe(0) + env.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT = '5' + expect(loadRelayConfig(env).regionCorrectionCohortPercent).toBe(5) + for (const invalid of ['-1', '101', '1.5', 'not-a-number']) { + env.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT = invalid + expect(() => loadRelayConfig(env)).toThrow() + } + }) + it('requires distinct dedicated admin identities and accepts omitted values', () => { const env = cellEnvironment(4_000) expect(loadRelayConfig(env)).toMatchObject({ diff --git a/cloud/apps/relay/src/config.ts b/cloud/apps/relay/src/config.ts index 2bf23444a71..83dc9708f74 100644 --- a/cloud/apps/relay/src/config.ts +++ b/cloud/apps/relay/src/config.ts @@ -75,11 +75,15 @@ const EnvSchema = z.object({ ORCA_RELAY_RUNTIME_SERVICE_ACCOUNT: z.string().email().optional(), ORCA_RELAY_DIRECTOR_URL: z.string().url().optional(), ORCA_RELAY_HEARTBEAT_AUDIENCE: z.string().url().optional(), - ORCA_RELAY_IMAGE_DIGEST: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), + ORCA_RELAY_IMAGE_DIGEST: z + .string() + .regex(/^sha256:[a-f0-9]{64}$/) + .optional(), ORCA_RELAY_ADMIN_JWKS_URL: z.string().url().default('https://www.googleapis.com/oauth2/v3/certs'), ORCA_RELAY_DATABASE_POOL_MAX: z.coerce.number().int().positive().max(100).optional(), ORCA_RELAY_PUBLIC_ASSIGNMENTS_ENABLED: EnvironmentBooleanSchema, ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED: EnvironmentBooleanSchema, + ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT: z.coerce.number().int().min(0).max(100).default(0), ORCA_RELAY_PUBLIC_ASSIGNMENT_CONCURRENCY: z.coerce.number().int().positive().max(100).default(2), ORCA_RELAY_PUBLIC_STICKY_CONCURRENCY: z.coerce.number().int().positive().max(100).default(1), ORCA_RELAY_PUBLIC_STICKY_QUEUE_MAX: z.coerce.number().int().positive().max(4_096).default(64), @@ -185,6 +189,7 @@ export type RelayConfig = { databasePoolMax: number publicAssignmentsEnabled: boolean regionalPlacementEnabled?: boolean + regionCorrectionCohortPercent?: number publicAssignmentConcurrency: number publicAssignmentQueueMax: number publicAssignmentWaitMs: number @@ -332,6 +337,7 @@ export function loadRelayConfig(env: NodeJS.ProcessEnv = process.env): RelayConf databasePoolMax, publicAssignmentsEnabled: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENTS_ENABLED, regionalPlacementEnabled: parsed.ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED, + regionCorrectionCohortPercent: parsed.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT, publicAssignmentConcurrency: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENT_CONCURRENCY, publicAssignmentQueueMax: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENT_QUEUE_MAX, publicAssignmentWaitMs: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENT_WAIT_MS, diff --git a/cloud/apps/relay/src/database.test.ts b/cloud/apps/relay/src/database.test.ts index 56122def4be..0c987f95d40 100644 --- a/cloud/apps/relay/src/database.test.ts +++ b/cloud/apps/relay/src/database.test.ts @@ -18,6 +18,34 @@ afterEach(() => { }) describe('relay database', () => { + it('upgrades an existing SQLite relay without treating legacy controls as idle-capable', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-idle-schema-')) + temporaryDirectories.push(dataDir) + const legacy = await openRelayDatabase({ dataDir }) + await legacy.query('ALTER TABLE relay_control_capabilities DROP COLUMN idle_regional_rehome') + await legacy.query('ALTER TABLE relay_region_rehome_attempts DROP COLUMN source_generation') + await legacy.query( + `INSERT INTO relay_control_capabilities + (user_id, relay_host_id, activity_id, cell_id, cell_incarnation, assignment_epoch, generation, finish_existing) + VALUES ('legacy-user', 'abcdefghijklmnop', 'control:source:1', 'source', 'legacy-incarnation', 1, 1, 1)` + ) + await legacy.close() + const upgraded = await openRelayDatabase({ dataDir }) + try { + expect( + await upgraded.query('SELECT idle_regional_rehome FROM relay_control_capabilities') + ).toEqual([{ idle_regional_rehome: 0 }]) + const columns = await upgraded.query( + "SELECT * FROM pragma_table_info('relay_region_rehome_attempts')" + ) + expect(columns.find((column) => column.name === 'source_generation')).toMatchObject({ + dflt_value: '0' + }) + } finally { + await upgraded.close() + } + }) + it('creates every durable relay state table', async () => { const database = await openInMemoryRelayDatabase() const rows = await database.query( @@ -54,6 +82,7 @@ describe('relay database', () => { 'relay_confirm_results', 'relay_confirmable_splices', 'relay_connection_bases', + 'relay_control_capabilities', 'relay_control_connection_reservations', 'relay_devices', 'relay_direct_authorizations', @@ -62,6 +91,7 @@ describe('relay database', () => { 'relay_migration_leases', 'relay_post_drain_migration_pins', 'relay_rate_windows', + 'relay_region_decisions', 'relay_region_rehome_attempts', 'relay_region_rehome_control', 'relay_region_rehome_worker_state' @@ -140,9 +170,7 @@ describe('relay database', () => { const second = await openRelayDatabase({ dataDir }) expect( - await second.query(`SELECT region FROM relay_cell_regions WHERE cell_id = ?`, [ - 'legacy-cell' - ]) + await second.query(`SELECT region FROM relay_cell_regions WHERE cell_id = ?`, ['legacy-cell']) ).toEqual([{ region: 'us-central1' }]) await second.close() }) @@ -165,9 +193,7 @@ describe('relay database', () => { 'relay_region_rehome_attempts' ]) expect(checked.every((row) => String(row.sql).includes(list))).toBe(true) - expect( - POSTGRES_SCHEMA_MIGRATIONS.some((statement) => statement.includes(list)) - ).toBe(true) + expect(POSTGRES_SCHEMA_MIGRATIONS.some((statement) => statement.includes(list))).toBe(true) await database.close() }) diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index d51f4e7a423..41ead67ea60 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -197,6 +197,24 @@ CREATE TABLE IF NOT EXISTS relay_assignment_region_preferences ( CREATE INDEX IF NOT EXISTS relay_assignment_region_preferences_observed ON relay_assignment_region_preferences(observed_at); +CREATE TABLE IF NOT EXISTS relay_region_decisions ( + user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL, + generation BIGINT NOT NULL, expires_at BIGINT NOT NULL, + assignment_epoch BIGINT NOT NULL, incumbent_region TEXT NOT NULL, + policy_version BIGINT NOT NULL, outcome TEXT NOT NULL, + cohort_bucket BIGINT NOT NULL DEFAULT 0, + last_considered_at BIGINT NOT NULL DEFAULT 0, + preferred_region TEXT, observed_at BIGINT NOT NULL, report_json TEXT, + PRIMARY KEY (user_id, relay_host_id) +); +CREATE TABLE IF NOT EXISTS relay_control_capabilities ( + user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL, activity_id TEXT NOT NULL, + cell_id TEXT NOT NULL, cell_incarnation TEXT NOT NULL, + assignment_epoch BIGINT NOT NULL, generation BIGINT NOT NULL, + finish_existing BIGINT NOT NULL, + idle_regional_rehome BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, relay_host_id, activity_id) +); CREATE TABLE IF NOT EXISTS relay_region_rehome_worker_state ( worker_id TEXT PRIMARY KEY, next_dispatch_at BIGINT NOT NULL, @@ -228,6 +246,7 @@ CREATE TABLE IF NOT EXISTS relay_region_rehome_attempts ( CHECK (preferred_region IN (${REGION_LIST})), source_cell_id TEXT NOT NULL, source_cell_incarnation TEXT NOT NULL, + source_generation BIGINT NOT NULL DEFAULT 0, target_cell_id TEXT NOT NULL, target_cell_incarnation TEXT NOT NULL, previous_epoch BIGINT NOT NULL, @@ -600,6 +619,8 @@ CREATE INDEX IF NOT EXISTS relay_audit_events_at ON relay_audit_events(at); // auto-named; the replacement is named, so both statements are no-ops on a // database the current schema created and neither can drop the other. export const POSTGRES_SCHEMA_MIGRATIONS = [ + `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS last_considered_at BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS cohort_bucket BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_rehome_attempts DROP CONSTRAINT IF EXISTS relay_region_rehome_attempts_preferred_region_check`, `ALTER TABLE relay_region_rehome_attempts @@ -607,7 +628,9 @@ export const POSTGRES_SCHEMA_MIGRATIONS = [ CHECK (preferred_region IN (${REGION_LIST}))`, `ALTER TABLE relay_region_rehome_control ADD COLUMN IF NOT EXISTS host_cooldown_ms BIGINT NOT NULL - DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}` + DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}`, + `ALTER TABLE relay_control_capabilities ADD COLUMN IF NOT EXISTS idle_regional_rehome BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS source_generation BIGINT NOT NULL DEFAULT 0` ] function postgresSql(sql: string): string { @@ -1013,6 +1036,15 @@ async function applySchema(database: RelayDatabase): Promise { for (const statement of SCHEMA.split(';')) { if (statement.trim()) await database.query(statement) } + for (const [table, column] of [ + ['relay_control_capabilities', 'idle_regional_rehome'], + ['relay_region_rehome_attempts', 'source_generation'] + ]) { + const columns = await database.query('SELECT name FROM pragma_table_info(?)', [table]) + if (!columns.some((existing) => existing.name === column)) { + await database.query(`ALTER TABLE ${table} ADD COLUMN ${column} BIGINT NOT NULL DEFAULT 0`) + } + } } // Why: DDL is not a request. A CREATE INDEX on a grown table legitimately runs diff --git a/cloud/apps/relay/src/drain-migration-row-lookup.test.ts b/cloud/apps/relay/src/drain-migration-row-lookup.test.ts new file mode 100644 index 00000000000..72df5224ee2 --- /dev/null +++ b/cloud/apps/relay/src/drain-migration-row-lookup.test.ts @@ -0,0 +1,62 @@ +import { expect, it } from 'vitest' +import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js' +import type { SqlRow } from './database.js' + +function text(row: SqlRow, field: string): string { + const value = row[field] + if (typeof value !== 'string') { + throw new Error(`invalid_${field}`) + } + return value +} + +it('keeps first assignment matches, lease order, row identity, and separate identity components', () => { + const rows = [ + { user_id: 'a:b', relay_host_id: 'c', value: 1 }, + { user_id: 'a', relay_host_id: 'b:c', value: 2 }, + { user_id: 'a:b', relay_host_id: 'c', value: 3 }, + { user_id: '', relay_host_id: '', value: 4 } + ] + const lookup = createDrainMigrationRowLookup(rows, text) + for (const identity of [ + { userId: 'a:b', relayHostId: 'c' }, + { userId: 'a', relayHostId: 'b:c' }, + { userId: '', relayHostId: '' }, + { userId: 'missing', relayHostId: 'c' } + ]) { + const expected = rows.filter( + (row) => + row.user_id === identity.userId && row.relay_host_id === identity.relayHostId + ) + expect(lookup.first(identity)).toBe(expected[0]) + expect(lookup.all(identity)).toEqual(expected) + lookup.all(identity).forEach((row, index) => expect(row).toBe(expected[index])) + } +}) + +it('retains lazy validation and short circuiting when an inventory is malformed', () => { + const first = { user_id: 'user', relay_host_id: 'host' } + const identity = { userId: 'user', relayHostId: 'host' } + const lookup = createDrainMigrationRowLookup( + [first, { user_id: null, relay_host_id: 'bad' }], + text + ) + expect(lookup.first(identity)).toBe(first) + expect(() => lookup.all(identity)).toThrow('invalid_user_id') + const unrelated = createDrainMigrationRowLookup( + [first, { user_id: 'other', relay_host_id: null }], + text + ) + expect(unrelated.all(identity)).toEqual([first]) + expect(() => unrelated.first({ userId: 'other', relayHostId: 'host' })).toThrow( + 'invalid_relay_host_id' + ) +}) + +it('does not share an index between refreshed inventories', () => { + const identity = { userId: 'user', relayHostId: 'host' } + const oldRow = { user_id: 'user', relay_host_id: 'host', version: 1 } + const newRow = { ...oldRow, version: 2 } + expect(createDrainMigrationRowLookup([oldRow], text).first(identity)).toBe(oldRow) + expect(createDrainMigrationRowLookup([newRow], text).first(identity)).toBe(newRow) +}) diff --git a/cloud/apps/relay/src/drain-migration-row-lookup.ts b/cloud/apps/relay/src/drain-migration-row-lookup.ts new file mode 100644 index 00000000000..b9dd5776f7d --- /dev/null +++ b/cloud/apps/relay/src/drain-migration-row-lookup.ts @@ -0,0 +1,58 @@ +import type { SqlRow } from './database.js' + +type Identity = { userId: string; relayHostId: string } +type RowIndex = Map> + +/** A single locked inventory, never retained across transactions or refreshed queries. */ +export function createDrainMigrationRowLookup( + rows: SqlRow[], + readText: (row: SqlRow, field: string) => string +): { + first: (identity: Identity) => SqlRow | undefined + all: (identity: Identity) => SqlRow[] +} { + let index: RowIndex | null | undefined + const indexed = (identity: Identity): SqlRow[] | undefined => { + if (index === undefined) { + index = indexRows(rows) + } + return index?.get(identity.userId)?.get(identity.relayHostId) + } + const matches = (row: SqlRow, identity: Identity): boolean => + readText(row, 'user_id') === identity.userId && + readText(row, 'relay_host_id') === identity.relayHostId + return { + first(identity) { + const group = indexed(identity) + return index === null ? rows.find((row) => matches(row, identity)) : group?.[0] + }, + all(identity) { + const group = indexed(identity) + return index === null ? rows.filter((row) => matches(row, identity)) : (group ?? []) + } + } +} + +function indexRows(rows: SqlRow[]): RowIndex | null { + const index: RowIndex = new Map() + for (const row of rows) { + const userId = row.user_id + const hostId = row.relay_host_id + // Preserve the original lazy validation and refusal order for malformed database rows. + if (typeof userId !== 'string' || typeof hostId !== 'string') { + return null + } + let hosts = index.get(userId) + if (!hosts) { + hosts = new Map() + index.set(userId, hosts) + } + const group = hosts.get(hostId) + if (group) { + group.push(row) + } else { + hosts.set(hostId, [row]) + } + } + return index +} diff --git a/cloud/apps/relay/src/drain-migration-row-scaling.test.ts b/cloud/apps/relay/src/drain-migration-row-scaling.test.ts new file mode 100644 index 00000000000..3ffd6490bd1 --- /dev/null +++ b/cloud/apps/relay/src/drain-migration-row-scaling.test.ts @@ -0,0 +1,121 @@ +import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' +import { afterEach, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openInMemoryRelayDatabase, type RelayDatabase, type SqlRow } from './database.js' + +let database: RelayDatabase | undefined +afterEach(async () => await database?.close()) + +it.each([false, true])( + 'looks up a whole drain inventory with linear identity reads (expired: %s)', + async (expired) => { + database = await openInMemoryRelayDatabase() + let measuring = false + let identityReads = 0 + let indexedRows = 0 + const instrument = (delegate: RelayDatabase): RelayDatabase => ({ + query: (sql, params) => delegate.query(sql, params), + queryLocked: async (sql, params, options) => { + const rows = await delegate.queryLocked(sql, params, options) + if ( + !measuring || + !sql.includes('WHERE EXISTS') || + !(sql.includes('SELECT assignment.*') || sql.includes('SELECT lease.*')) + ) { + return rows + } + indexedRows += rows.length + return rows.map( + (row): SqlRow => + new Proxy(row, { + get(target, key) { + if (key === 'user_id' || key === 'relay_host_id') { + identityReads++ + } + return Reflect.get(target, key) + } + }) + ) + }, + transaction: (operation, options) => + delegate.transaction((tx) => operation(instrument(tx)), options), + close: () => delegate.close() + }) + let now = 100 + const store = new RelayAssignmentStore(instrument(database), () => now, { + requireLiveCells: true + }) + const cells = ['a', 'b'].map((id) => ({ + id: `cell-${id}`, + url: `https://relay-${id}.example.com`, + capacityRequests: 500 + })) + await store.reconcileCells(cells) + const incarnation = '11111111-1111-4111-8111-111111111111' + for (const cell of cells) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: incarnation, + startedAt: 50, + ready: true, + observedRequests: 0 + }) + } + await store.setCellEnabled('cell-b', false) + const identities = Array.from({ length: 50 }, (_, index) => ({ + userId: `user-${index % 5}`, + relayHostId: `host${String(index).padStart(12, '0')}` + })) + for (const identity of identities) { + await store.assign(identity) + } + await store.setCellEnabled('cell-b', true) + await store.setCellEnabled('cell-a', false) + for (const identity of identities) { + const migration = await store.startEvacuation(identity, 'cell-b') + await store.markMigrationTargetRegistered(identity, { + cellId: 'cell-b', + assignmentEpoch: migration.assignmentEpoch + }) + } + const attempt = { + attemptId: '55555555-5555-4555-8555-555555555555', + cellId: 'cell-a', + cellIncarnation: incarnation, + traceValue: '66666666-6666-4666-8666-666666666666', + plannedGraceMs: 120_000 + } + await store.prepareCellDrainAttempt(attempt) + if (expired) { + now += ASSIGNMENT_LIMITS.migrationLeaseMs + 1 + await store.releaseExpiredActivityLeases() + for (const cell of cells) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: incarnation, + startedAt: 50, + ready: true, + observedRequests: 0 + }) + } + } + measuring = true + await expect(store.beginCellDrainSend(attempt)).resolves.toMatchObject({ + state: 'send-may-have-started', + shouldSend: true + }) + expect(indexedRows).toBeGreaterThanOrEqual(100) + expect(identityReads).toBeLessThanOrEqual(indexedRows * 2) + const migrations = await database.query( + 'SELECT expires_at FROM relay_assignment_migrations' + ) + expect(migrations).toHaveLength(50) + expect( + migrations.every( + (row) => row.expires_at === now + ASSIGNMENT_LIMITS.migrationLeaseMs + ) + ).toBe(true) + } +) diff --git a/cloud/apps/relay/src/host-session-client-accept.test.ts b/cloud/apps/relay/src/host-session-client-accept.test.ts index 0cec6531e3f..0b7febaa7b4 100644 --- a/cloud/apps/relay/src/host-session-client-accept.test.ts +++ b/cloud/apps/relay/src/host-session-client-accept.test.ts @@ -155,6 +155,66 @@ describe('client accept abandoned mid-DB-phase', () => { vi.useRealTimers() }) + it('does not admit new source work after a drain crosses activity acquisition', async () => { + const h = harness() + const control = await activeHost(h) + const slow = deferred() + h.acquireActivity.mockReturnValueOnce(slow.promise) + const client = new FakeSocket() + const capacity = { bind: vi.fn(), release: vi.fn() } + const accepting = h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential', + capacity + ) + await vi.advanceTimersByTimeAsync(0) + h.registry.drainHost({ + attemptId: 'attempt', + userId: identity.sub, + relayHostId: identity.relayHostId, + sourceAssignmentEpoch: 1, + graceMs: 60_000 + }) + slow.resolve() + await accepting + expect(control.send).not.toHaveBeenCalledWith(expect.stringContaining('conn-open')) + expect(capacity.bind).not.toHaveBeenCalled() + expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + expect(h.releaseActivity).toHaveBeenCalled() + }) + + it('does not splice an attachment whose generation retired during basis persistence', async () => { + const h = harness() + await activeHost(h) + const client = new FakeSocket() + await h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential' + ) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + const pending = [...session.pendingConns.values()][0]! + const slow = deferred() + h.store.recordConnectionBasis.mockReturnValueOnce(slow.promise) + const host = new FakeSocket() + const attaching = h.registry.acceptHostData( + host as unknown as WebSocket, + pending.connId, + pending.connTicket, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + h.registry.drain(0) + await vi.advanceTimersByTimeAsync(0) + slow.resolve() + expect(await attaching).toBe(false) + expect(session.activeSplices.size).toBe(0) + expect(h.store.deactivateBasis).toHaveBeenCalledWith(pending.connId) + expect(client.send).not.toHaveBeenCalledWith(expect.stringContaining('\"ok\":true')) + expect(host.close).toHaveBeenCalled() + }) + it('stops after a slow activity acquire when the phone already hung up', async () => { const h = harness() const control = await activeHost(h) @@ -364,6 +424,8 @@ describe('successful client accept timing', () => { ) as { connId: string; connTicket: string } // The desktop's data leg is the attach window this is meant to expose. now += 23 + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + const ownerProbe = vi.spyOn(session.pendingConns, 'has') const accepted = await h.registry.acceptHostData( hostData as unknown as WebSocket, connOpen.connId, @@ -372,6 +434,7 @@ describe('successful client accept timing', () => { ) expect(accepted).toBe(true) + expect(ownerProbe).toHaveBeenCalledOnce() expect(h.observer.recordClientAcceptCompleted).toHaveBeenCalledWith({ totalMs: 49, stageMs: { assignment: 5, credential: 7, activity: 11, attach: 23, basis: 3 } @@ -390,6 +453,7 @@ describe('successful client accept timing', () => { relayHostIdDigest: string } expect(event.credentialKind).toBe('resume') + expect(event).toMatchObject({ assignmentEpoch: 1, controlGeneration: 1, drainMode: 'none' }) // Joins the line back to the emitting process, like the runtime metrics event. expect(event).toMatchObject({ role: 'cell', cellId: config.cellId, region: 'us-central1' }) expect(Object.keys(event.stageMs).sort()).toEqual([ @@ -424,6 +488,80 @@ async function advanceToPing(control: FakeSocket, clock: { now: number }): Promi return (JSON.parse(String(ping[0])) as { t: number }).t } +// The attach resolves its owning session once and hands it to the unfenced leg; +// these hold the session it must be and the order the client hears about it. +describe('host data attach ownership', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + + const bystander = { ...identity, sub: 'user-2', relayHostId: 'qponmlkjihgfedcb' } + + async function pendingAttach(h: ReturnType) { + const client = new FakeSocket() + await h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential' + ) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + return { client, session, pending: [...session.pendingConns.values()][0]! } + } + + it('attaches the session that owns the connection, not the first one registered', async () => { + const h = harness() + const idle = new FakeSocket() + await h.activate(idle as unknown as WebSocket, bystander, null, 1, false, 1, '1.4.197') + await activeHost(h) + const { client, session, pending } = await pendingAttach(h) + const idleSession = h.registry.get({ + userId: bystander.sub, + relayHostId: bystander.relayHostId + })! + const host = new FakeSocket() + expect( + await h.registry.acceptHostData( + host as unknown as WebSocket, + pending.connId, + pending.connTicket, + 1 + ) + ).toBe(true) + expect(client.send).toHaveBeenCalledWith(expect.stringContaining('"type":"relay-hello"')) + expect(session.activeSplices.has(pending.connId)).toBe(true) + expect(idleSession.activeSplices.size).toBe(0) + expect(idleSession.activeConnIds.size).toBe(0) + h.registry.drain(0) + vi.advanceTimersByTime(0) + }) + + it('acknowledges the client only after the connection basis is persisted', async () => { + const h = harness() + await activeHost(h) + const { client, session, pending } = await pendingAttach(h) + const basis = deferred() + h.store.recordConnectionBasis.mockReturnValueOnce(basis.promise) + const host = new FakeSocket() + const attaching = h.registry.acceptHostData( + host as unknown as WebSocket, + pending.connId, + pending.connTicket, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + expect(h.store.recordConnectionBasis).toHaveBeenCalledOnce() + expect(client.send).not.toHaveBeenCalledWith(expect.stringContaining('relay-hello')) + basis.resolve() + expect(await attaching).toBe(true) + expect(client.send).toHaveBeenCalledWith(expect.stringContaining('"type":"relay-hello"')) + expect(session.activeSplices.has(pending.connId)).toBe(true) + h.registry.drain(0) + vi.advanceTimersByTime(0) + }) +}) + describe('control round-trip sampling', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => { @@ -460,6 +598,9 @@ describe('control round-trip sampling', () => { cellId: config.cellId, region: 'us-central1', rttMsMedian: 40, + assignmentEpoch: 1, + controlGeneration: 1, + drainMode: 'none', sampleCount: 4 }) expect(rttLines()[0]).not.toContain(identity.relayHostId) diff --git a/cloud/apps/relay/src/host-session-registry.test.ts b/cloud/apps/relay/src/host-session-registry.test.ts index 920faa6f4b8..7c50285454c 100644 --- a/cloud/apps/relay/src/host-session-registry.test.ts +++ b/cloud/apps/relay/src/host-session-registry.test.ts @@ -4,6 +4,7 @@ import { CONTROL_CONTINUITY_LIMITS, RELAY_CLOSE_CODE, RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS, + RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME, RELAY_PROTOCOL_LIMITS } from '@orca-cloud/relay-contract' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -110,6 +111,7 @@ function createRegistry( renewControlActivity: ReturnType releaseActivity: ReturnType observer: { + recordAuth: ReturnType recordControlClose: ReturnType recordSpliceClose: ReturnType } @@ -140,7 +142,10 @@ function createRegistry( store as RelayCredentialStore, assignments, new ProcessQueuedByteBudget(), - observer + observer, + Date.now, + Math.random, + 'incarnation-1' ) // Mirrors the production signature exactly so a future positional shift fails to compile. const bound = ( @@ -166,7 +171,14 @@ function createRegistry( assignmentEpoch, appVersion = '1.4.173' ) => bound(socket, identity, existing, generation, rebind, assignmentEpoch, appVersion) - return { registry, activate, acquireActivity, renewControlActivity, releaseActivity, observer } + return { + registry, + activate, + acquireActivity, + renewControlActivity, + releaseActivity, + observer + } } describe('host session cleanup races', () => { @@ -398,26 +410,20 @@ describe('host session cleanup races', () => { attemptId: '22222222-2222-4222-8222-222222222222' }) ).toThrow('regional_rehome_attempt_conflict') - expect(() => - registry.drainHost({ ...request, sourceAssignmentEpoch: 8 }) - ).toThrow('regional_rehome_assignment_epoch_mismatch') + expect(() => registry.drainHost({ ...request, sourceAssignmentEpoch: 8 })).toThrow( + 'regional_rehome_assignment_epoch_mismatch' + ) const rebound = new FakeSocket() - await activate( - rebound as unknown as WebSocket, - identity, - registry.get(request), - 1, - true, - 7 - ) + await activate(rebound as unknown as WebSocket, identity, registry.get(request), 1, true, 7) expect(registry.get(request)?.state).toBe('drain-only') expect(rebound.send).toHaveBeenCalledWith(expect.stringContaining('"type":"drain"')) await vi.advanceTimersByTimeAsync(30_000) expect(registry.get(request)).toBeNull() - expect(registry.get({ userId: secondIdentity.sub, relayHostId: secondIdentity.relayHostId })) - .not.toBeNull() + expect( + registry.get({ userId: secondIdentity.sub, relayHostId: secondIdentity.relayHostId }) + ).not.toBeNull() expect(secondSocket.close).not.toHaveBeenCalled() }) @@ -513,14 +519,7 @@ describe('host session cleanup races', () => { expect(original).not.toBeNull() const rebindSocket = new FakeSocket() - const rebinding = activate( - rebindSocket as unknown as WebSocket, - identity, - original, - 1, - true, - 1 - ) + const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1) rebindSocket.close() blocked.resolve('control:production-gce-c3:1') await rebinding @@ -659,14 +658,7 @@ describe('host session cleanup races', () => { originalSocket.close() const replacementSocket = new FakeSocket() - await activate( - replacementSocket as unknown as WebSocket, - identity, - original, - 2, - false, - 1 - ) + await activate(replacementSocket as unknown as WebSocket, identity, original, 2, false, 1) const replacement = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId @@ -696,14 +688,7 @@ describe('host session cleanup races', () => { }) expect(original).not.toBeNull() - await activate( - new FakeSocket() as unknown as WebSocket, - identity, - original, - 2, - false, - 1 - ) + await activate(new FakeSocket() as unknown as WebSocket, identity, original, 2, false, 1) vi.advanceTimersByTime(15_000) expect(renewControlActivity).toHaveBeenCalledOnce() @@ -718,6 +703,53 @@ describe('host session cleanup races', () => { vi.advanceTimersByTime(0) }) + it('ignores a denial belonging to the socket before a same-generation rebind', async () => { + const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1')) + const oldSocket = new FakeSocket() + await h.activate(oldSocket as unknown as WebSocket, identity, null, 1, false, 1) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + let reject!: (error: Error) => void + h.renewControlActivity.mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail + }) + ) + await vi.advanceTimersByTimeAsync(15_000) + const replacement = new FakeSocket() + await h.activate(replacement as unknown as WebSocket, identity, session, 1, true, 1) + reject(new Error('activity_cell_not_authoritative')) + await vi.advanceTimersByTimeAsync(0) + expect(replacement.close).not.toHaveBeenCalled() + expect(session.socket).toBe(replacement) + expect(session.generation).toBe(1) + }) + + it('ignores missing-activity recovery denial after an authority transition', async () => { + const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1')) + const socket = new FakeSocket() + await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + h.renewControlActivity.mockRejectedValueOnce(new Error('control_activity_not_found')) + let reject!: (error: Error) => void + h.acquireActivity.mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail + }) + ) + await vi.advanceTimersByTimeAsync(15_000) + h.registry.drainHost({ + attemptId: 'attempt', + userId: identity.sub, + relayHostId: identity.relayHostId, + sourceAssignmentEpoch: 1, + graceMs: 60_000 + }) + reject(new Error('activity_cell_not_authoritative')) + await vi.advanceTimersByTimeAsync(0) + expect(socket.close).not.toHaveBeenCalled() + expect(session.state).toBe('drain-only') + }) + it('keeps 15s pings while halving steady-state control renewals', async () => { const activateControl = vi .fn() @@ -732,9 +764,7 @@ describe('host session cleanup races', () => { socket.emit('message', Buffer.from(JSON.stringify({ type: 'pong' })), false) } - const pings = socket.send.mock.calls.filter((call) => - String(call[0]).includes('"ping"') - ) + const pings = socket.send.mock.calls.filter((call) => String(call[0]).includes('"ping"')) expect(pings).toHaveLength(4) expect(renewControlActivity).toHaveBeenCalledTimes(2) const firstExpiry = Number(renewControlActivity.mock.calls[0]![1].expiresAt) @@ -1118,3 +1148,551 @@ describe('host hello ack pending connections', () => { expect(rebound.pendingConns).toEqual([DETAILED_ENTRY]) }) }) + +describe('source-owned idle cutover', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + const request = { + attemptId: 'idle-1', + userId: identity.sub, + relayHostId: identity.relayHostId, + sourceAssignmentEpoch: 1, + sourceGeneration: 1, + sourceCellIncarnation: 'incarnation-1', + targetCellId: 'target' + } + async function source(store: Partial = {}) { + const h = createRegistry(vi.fn().mockResolvedValue('control:1'), store) + const socket = new FakeSocket() + h.registry.acceptControl( + socket as unknown as WebSocket, + identity, + undefined, + new Set([RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME]) + ) + socket.removeAllListeners('message') + await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + return { ...h, socket, session: h.registry.get(request)! } + } + it('keeps either established client busy until both actually leave', async () => { + const h = await source() + h.session.activeConnIds.add('phone') + h.session.activeConnIds.add('ipad') + const commit = vi.fn().mockResolvedValue({ outcome: 'committed' }) + h.session.activeConnIds.delete('ipad') + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + expect(commit).not.toHaveBeenCalled() + h.session.activeConnIds.delete('phone') + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'committed' }) + expect(h.socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, expect.any(String)) + expect(h.releaseActivity).toHaveBeenCalled() + }) + it.each([ + { userId: 'other-user' }, + { sourceAssignmentEpoch: 2 }, + { sourceGeneration: 2 }, + { sourceCellIncarnation: 'other-incarnation' }, + { targetCellId: 'other-target' } + ])('rejects a reused operation ID with changed authority %j', async (change) => { + const h = await source() + const result = deferred<{ outcome: 'deferred' }>() + const commit = vi.fn().mockReturnValue(result.promise) + const reconcile = vi.fn().mockResolvedValue('not-committed') + const moving = h.registry.idleRehome(request, commit, reconcile) + const conflicting = h.registry.idleRehome({ ...request, ...change }, commit, reconcile) + result.resolve({ outcome: 'deferred' }) + expect(await conflicting).toEqual({ outcome: 'stale' }) + expect(await moving).toEqual({ outcome: 'deferred' }) + expect(commit).toHaveBeenCalledOnce() + expect(h.socket.close).not.toHaveBeenCalled() + }) + it('accounts for accepts before credential identity resolves', async () => { + const lookup = deferred() + const h = await source({ + resolveResume: vi.fn().mockReturnValue(lookup.promise), + resolveInviteForMove: vi.fn().mockResolvedValue(null) + }) + const client = new FakeSocket() + const accept = h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential' + ) + expect( + await h.registry.idleRehome(request, vi.fn(), vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + lookup.resolve(null) + await accept + expect(h.socket.close).not.toHaveBeenCalled() + }) + it('rejects new accepts and replacements synchronously while a commit awaits', async () => { + const h = await source() + const result = deferred<{ outcome: 'deferred' }>() + const commit = vi.fn().mockReturnValue(result.promise) + const moving = h.registry.idleRehome( + request, + commit, + vi.fn().mockResolvedValue('not-committed') + ) + const duplicate = h.registry.idleRehome( + request, + commit, + vi.fn().mockResolvedValue('not-committed') + ) + const client = new FakeSocket() + const release = vi.fn() + await h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential', + { release } as never + ) + expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + expect(release).toHaveBeenCalledOnce() + const replacement = new FakeSocket() + await h.activate(replacement as unknown as WebSocket, identity, h.session, 2, false, 1) + expect(replacement.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + result.resolve({ outcome: 'deferred' }) + await moving + await duplicate + expect(commit).toHaveBeenCalledOnce() + expect(h.socket.close).not.toHaveBeenCalled() + expect( + await h.registry.idleRehome( + { ...request, attemptId: 'next' }, + vi.fn().mockResolvedValue({ outcome: 'committed' }), + vi.fn().mockResolvedValue('not-committed') + ) + ).toEqual({ outcome: 'committed' }) + }) + it.each(['ambiguous', 'deferred'])( + 'keeps %s outcomes fenced until locked reconciliation succeeds', + async (claim) => { + const h = await source() + const reconcile = vi + .fn() + .mockRejectedValueOnce(new Error('database unavailable')) + .mockRejectedValueOnce(new Error('database unavailable')) + .mockResolvedValue('not-committed') + const moving = h.registry.idleRehome( + request, + claim === 'ambiguous' + ? vi.fn().mockRejectedValue(new Error('lost commit reply')) + : vi.fn().mockResolvedValue({ outcome: 'deferred' }), + reconcile + ) + await vi.advanceTimersByTimeAsync(50) + expect( + await h.registry.idleRehome( + { ...request, attemptId: 'other' }, + vi.fn(), + vi.fn().mockResolvedValue('not-committed') + ) + ).toEqual({ outcome: 'busy' }) + expect(h.socket.close).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + expect(await moving).toEqual({ outcome: 'deferred' }) + expect(reconcile).toHaveBeenCalledTimes(3) + expect(h.socket.close).not.toHaveBeenCalled() + } + ) + it('owns accepted control mutations before the handler first awaits', async () => { + const mutation = deferred() + const h = await source() + ;(h.registry as unknown as { verifyRelayToken: unknown }).verifyRelayToken = vi + .fn() + .mockReturnValue(mutation.promise) + h.socket.emit( + 'message', + Buffer.from(JSON.stringify({ type: 'auth-refresh', relayJwt: 'token' })), + false + ) + const commit = vi.fn().mockResolvedValue({ outcome: 'deferred' }) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + mutation.resolve(identity) + await vi.advanceTimersByTimeAsync(0) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'deferred' }) + }) + it('owns queued replacement activation before its first persistence await', async () => { + const h = await source() + const activation = deferred() + const assignments = (h.registry as unknown as { assignments: { activateControl: unknown } }) + .assignments + assignments.activateControl = vi.fn().mockReturnValue(activation.promise) + const replacement = new FakeSocket() + const activating = h.activate( + replacement as unknown as WebSocket, + identity, + h.session, + 2, + false, + 1 + ) + expect( + await h.registry.idleRehome(request, vi.fn(), vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + activation.resolve('control:2') + await activating + }) + it('retires changed authority even when the claim definitively deferred', async () => { + const h = await source() + expect( + await h.registry.idleRehome( + request, + vi.fn().mockResolvedValue({ outcome: 'deferred' }), + vi.fn().mockResolvedValue('stale') + ) + ).toEqual({ outcome: 'stale' }) + expect(h.session.state).toBe('closed') + expect(h.releaseActivity).toHaveBeenCalled() + }) + it('holds attach ownership through basis failure reservation cleanup', async () => { + const basis = deferred() + const cleanup = deferred() + const h = await source({ + recordConnectionBasis: vi.fn().mockImplementation(async () => { + await basis.promise + throw new Error('basis failed') + }), + failReservation: vi.fn().mockReturnValue(cleanup.promise) + }) + const client = new FakeSocket() + h.session.pendingConns.set('conn', { + connId: 'conn', + connTicket: 'ticket', + client: client as unknown as WebSocket, + reservation: { + userId: identity.sub, + relayHostId: identity.relayHostId, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 1000 + }, + attachTimer: setTimeout(() => {}, 1000), + credentialActivityId: null + } as never) + const attached = h.registry.acceptHostData( + new FakeSocket() as unknown as WebSocket, + 'conn', + 'ticket', + 1 + ) + const commit = vi.fn().mockResolvedValue({ outcome: 'deferred' }) + expect(await h.registry.idleRehome(request, commit, vi.fn())).toEqual({ outcome: 'busy' }) + basis.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(h.session.activeConnIds.size).toBe(0) + expect(await h.registry.idleRehome(request, commit, vi.fn())).toEqual({ outcome: 'busy' }) + expect(commit).not.toHaveBeenCalled() + cleanup.resolve() + await attached + }) + it('rejects an attach mid-cutover before its ticket is ever examined', async () => { + const h = await source({ failReservation: vi.fn().mockResolvedValue(undefined) }) + const result = deferred<{ outcome: 'deferred' }>() + // The cutover must already be in flight: an idle host is what it claims. + const moving = h.registry.idleRehome(request, () => result.promise, vi.fn()) + const client = new FakeSocket() + h.session.pendingConns.set('conn', { + connId: 'conn', + connTicket: 'ticket', + client: client as unknown as WebSocket, + reservation: { + userId: identity.sub, + relayHostId: identity.relayHostId, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 1000 + }, + attachTimer: setTimeout(() => {}, 1000), + credentialActivityId: null + } as never) + const host = new FakeSocket() + // The ticket below is the live one: only the cutover fence may reject it. + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, 'conn', 'ticket', 1) + ).toBe(false) + expect(host.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + expect(h.observer.recordAuth).not.toHaveBeenCalled() + expect(h.session.pendingConns.has('conn')).toBe(true) + expect(h.session.activeConnIds.size).toBe(0) + result.resolve({ outcome: 'deferred' }) + await moving + }) + it('holds no attach ownership when no session owns the connection', async () => { + const h = await source() + const host = new FakeSocket() + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, 'stranger', 'ticket', 1) + ).toBe(false) + expect(h.observer.recordAuth).toHaveBeenCalledWith(false) + expect(host.close).toHaveBeenCalledWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + expect.any(String) + ) + // A leaked idle-work hold from the unowned attach would report `busy` here. + expect( + await h.registry.idleRehome( + request, + vi.fn().mockResolvedValue({ outcome: 'committed' }), + vi.fn() + ) + ).toEqual({ outcome: 'committed' }) + }) + it('returns the durable operation outcome after source retirement', async () => { + const h = await source() + const commit = vi.fn().mockResolvedValue({ outcome: 'committed' }) + await h.registry.idleRehome(request, commit, vi.fn()) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('committed')) + ).toEqual({ outcome: 'committed' }) + expect(commit).toHaveBeenCalledOnce() + }) + it('does not reopen a source overtaken by emergency drain', async () => { + const h = await source() + const result = deferred<{ outcome: 'deferred' }>() + const moving = h.registry.idleRehome( + request, + () => result.promise, + vi.fn().mockResolvedValue('not-committed') + ) + h.registry.drain(0) + await vi.advanceTimersByTimeAsync(0) + result.resolve({ outcome: 'deferred' }) + await moving + expect(h.session.state).toBe('closed') + expect(h.registry.get(request)).toBeNull() + }) +}) + +// The host data leg's owner lookup is the registry's only whole-inventory scan on +// an attach. These count what that scan touches, not how long it takes. +describe('host data attach owner lookup', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + + const SESSION_COUNT = 1000 + const CONN_ID = 'conn-owned' + const OWNER_INDEX = { first: 0, middle: 499, last: 999 } as const + type Placement = keyof typeof OWNER_INDEX | 'absent' + type LookupCounts = { visits: number; membership: number } + + function bindOwn(map: Map, property: string | symbol): unknown { + const value: unknown = Reflect.get(map, property, map) + return typeof value === 'function' ? value.bind(map) : value + } + + // One visit per session the scan pulls off the map iterator; answers unchanged. + function countingValues(map: Map, counts: LookupCounts): Map { + return new Proxy(map, { + get(target, property) { + if (property !== 'values') return bindOwn(target, property) + return function* (): Generator { + for (const value of target.values()) { + counts.visits += 1 + yield value + } + } + } + }) + } + + // One membership check per `pendingConns.has`; answers unchanged. + function countingHas(map: Map, counts: LookupCounts): Map { + return new Proxy(map, { + get(target, property) { + if (property !== 'has') return bindOwn(target, property) + return (key: K) => { + counts.membership += 1 + return target.has(key) + } + } + }) + } + + // The pre-change implementation, kept inline as the oracle the new counts are + // differenced against: two inventory arrays, two independent finds. + function legacyOwnerLookup( + sessions: Map, + connId: string + ): { owner: HostSession | undefined; session: HostSession | undefined } { + const owner = [...sessions.values()].find((candidate) => candidate.pendingConns.has(connId)) + const session = [...sessions.values()].find((candidate) => candidate.pendingConns.has(connId)) + return { owner, session } + } + + function pendingConn(client: FakeSocket, connTicket: string) { + return { + connId: CONN_ID, + connTicket, + client: client as unknown as WebSocket, + reservation: { + userId: identity.sub, + relayHostId: identity.relayHostId, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 1000 + }, + attachTimer: setTimeout(() => {}, 1000), + credentialActivityId: null + } as never + } + + // Every decoy holds a pending conn of its own, so each membership check the + // scan makes is real work rather than a lookup in an empty map. + function decoySession(index: number, counts: LookupCounts): HostSession { + const pendingConns = new Map([[`conn-decoy-${index}`, { connId: 'decoy' }]]) + return { + relayHostId: `decoy-host-${index}`, + generation: 1, + state: 'active', + activeConnIds: new Set(), + pendingConns: countingHas(pendingConns, counts) + } as unknown as HostSession + } + + async function attachRegistry(placement: Placement, store: Partial = {}) { + const h = createRegistry(vi.fn().mockResolvedValue('control:1'), { + failReservation: vi.fn().mockResolvedValue(undefined), + recordConnectionBasis: vi.fn().mockResolvedValue(undefined), + deactivateBasis: vi.fn().mockResolvedValue(undefined), + ...store + }) + const control = new FakeSocket() + await h.activate(control as unknown as WebSocket, identity, null, 1, false, 1) + const internals = h.registry as unknown as { sessions: Map } + const [ownerKey, owner] = [...internals.sessions.entries()][0]! + const counts: LookupCounts = { visits: 0, membership: 0 } + const client = new FakeSocket() + if (placement !== 'absent') owner.pendingConns.set(CONN_ID, pendingConn(client, 'ticket')) + owner.pendingConns = countingHas(owner.pendingConns, counts) + const ordered: HostSession[] = [] + const sessions = new Map() + const ownerIndex = placement === 'absent' ? 0 : OWNER_INDEX[placement] + for (let index = 0; index < SESSION_COUNT; index += 1) { + const session = index === ownerIndex ? owner : decoySession(index, counts) + ordered.push(session) + sessions.set(index === ownerIndex ? ownerKey : `decoy-${index}`, session) + } + internals.sessions = countingValues(sessions, counts) + return { ...h, owner, ordered, counts, client, control, sessions: internals.sessions } + } + + it.each([ + { + placement: 'first', + before: { visits: 2000, membership: 2 }, + after: { visits: 1, membership: 1 } + }, + { + placement: 'middle', + before: { visits: 2000, membership: 1000 }, + after: { visits: 500, membership: 500 } + }, + { + placement: 'last', + before: { visits: 2000, membership: 2000 }, + after: { visits: 1000, membership: 1000 } + }, + { + placement: 'absent', + before: { visits: 2000, membership: 2000 }, + after: { visits: 1000, membership: 1000 } + } + ] as const)( + 'visits the inventory once, not twice, for a $placement owner', + async ({ placement, before, after }) => { + const h = await attachRegistry(placement) + expect(h.sessions.size).toBe(SESSION_COUNT) + const oracle = legacyOwnerLookup(h.sessions, CONN_ID) + const legacy = { ...h.counts } + h.counts.visits = 0 + h.counts.membership = 0 + const host = new FakeSocket() + // An unusable ticket stops the attach immediately after the lookup, so the + // counts below belong to the lookup alone. + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, CONN_ID, 'wrong', 1) + ).toBe(false) + expect(h.observer.recordAuth).toHaveBeenCalledExactlyOnceWith(false) + expect(host.close).toHaveBeenCalledWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid host data ticket' + ) + expect(legacy).toEqual(before) + expect({ ...h.counts }).toEqual(after) + expect(oracle.owner).toBe(placement === 'absent' ? undefined : h.owner) + expect(oracle.owner).toBe(oracle.session) + } + ) + + it.each([ + { reason: 'ticket', ticket: 'wrong', generation: 1, state: 'active' }, + { reason: 'generation', ticket: 'ticket', generation: 2, state: 'active' }, + { reason: 'state', ticket: 'ticket', generation: 1, state: 'orphaned' } + ] as const)('fails an attach whose $reason does not match the owner', async (input) => { + const h = await attachRegistry('middle') + h.owner.state = input.state + const host = new FakeSocket() + expect( + await h.registry.acceptHostData( + host as unknown as WebSocket, + CONN_ID, + input.ticket, + input.generation + ) + ).toBe(false) + expect(h.observer.recordAuth).toHaveBeenCalledExactlyOnceWith(false) + expect(host.close).toHaveBeenCalledWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid host data ticket' + ) + expect(h.owner.pendingConns.has(CONN_ID)).toBe(true) + expect(h.owner.activeConnIds.size).toBe(0) + }) + + it('rejects on the earlier duplicate owner rather than the later live one', async () => { + const h = await attachRegistry('middle') + h.ordered[0]!.pendingConns.set(CONN_ID, pendingConn(new FakeSocket(), 'stale-ticket') as never) + expect(legacyOwnerLookup(h.sessions, CONN_ID).owner).toBe(h.ordered[0]) + h.counts.visits = 0 + h.counts.membership = 0 + const host = new FakeSocket() + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, CONN_ID, 'ticket', 1) + ).toBe(false) + expect({ ...h.counts }).toEqual({ visits: 1, membership: 1 }) + expect(h.owner.pendingConns.has(CONN_ID)).toBe(true) + }) + + it('splices the earlier duplicate owner and leaves the later one untouched', async () => { + const basis = vi.fn().mockRejectedValue(new Error('basis failed')) + const h = await attachRegistry('first', { recordConnectionBasis: basis }) + const duplicate = h.ordered[3]! + duplicate.pendingConns.set(CONN_ID, pendingConn(new FakeSocket(), 'ticket') as never) + h.counts.visits = 0 + h.counts.membership = 0 + const host = new FakeSocket() + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, CONN_ID, 'ticket', 1) + ).toBe(false) + expect({ ...h.counts }).toEqual({ visits: 1, membership: 1 }) + expect(h.observer.recordAuth).toHaveBeenCalledWith(true) + expect(basis).toHaveBeenCalledOnce() + // The first owner's entry was consumed; the later duplicate never was. + expect(h.owner.pendingConns.has(CONN_ID)).toBe(false) + expect(duplicate.pendingConns.has(CONN_ID)).toBe(true) + expect(h.owner.activeConnIds.size).toBe(0) + }) +}) diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 3b4e616a692..0d0cf4a7940 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -15,6 +15,7 @@ import { HostHelloSchema, InviteCreateSchema, RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS, + RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME, RELAY_PROTOCOL_LIMITS, RELAY_CLOSE_CODE, type RelayHostCloseReason, @@ -25,10 +26,7 @@ import type WebSocket from 'ws' import type { RawData } from 'ws' import type { RelayConfig } from './config.js' import type { RelayAssignmentStore } from './assignment-store.js' -import { - RelayCredentialStore, - type CredentialReservation -} from './credential-store.js' +import { RelayCredentialStore, type CredentialReservation } from './credential-store.js' import { HostCloseReasonMemory } from './host-close-reason-memory.js' import { relayHostLogDigest } from './relay-host-log-digest.js' import type { RelayTokenClaims } from './relay-token-verifier.js' @@ -78,7 +76,7 @@ export type HostSession = { identity: RelayTokenClaims readonly relayHostId: string readonly generation: number - readonly assignmentEpoch: number + assignmentEpoch: number readonly controlActivityId: string | null readonly controlResumeSecret: string // Why: reconnect churn is only actionable once it can be pinned to a client build. @@ -94,6 +92,7 @@ export type HostSession = { pendingPingAt: number | null controlRttSamplesMs: number[] controlRttLoggedAt: number | null + authorityRevision: number activityRenewalDueAt: number activityRenewalAttempt: number activityRenewalCompletedAttempt: number @@ -108,10 +107,7 @@ export type HostSession = { regionalDrainExpiresAt: number | null } -export type RegionalHostDrainOutcome = - | 'accepted' - | 'already-accepted' - | 'host-not-connected' +export type RegionalHostDrainOutcome = 'accepted' | 'already-accepted' | 'host-not-connected' type PendingConnection = { connId: string @@ -184,6 +180,113 @@ export class HostSessionRegistry { private readonly hostCapabilities = new WeakMap>() private draining = false + private readonly idleWork = new Map() + private readonly idleAttempts = new Map< + string, + { + attemptId: string + authorityKey: string + promise: Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> + } + >() + + async idleRehome( + input: { + attemptId: string + userId: string + relayHostId: string + sourceAssignmentEpoch: number + sourceGeneration: number + sourceCellIncarnation: string + targetCellId: string + }, + commit: () => Promise<{ outcome: 'committed' | 'deferred' | 'stale' }>, + reconcile: () => Promise<'committed' | 'not-committed' | 'stale'> + ): Promise<{ outcome: 'busy' | 'committed' | 'deferred' | 'stale' }> { + const authorityKey = JSON.stringify([ + input.userId, + input.sourceAssignmentEpoch, + input.sourceGeneration, + input.sourceCellIncarnation, + input.targetCellId + ]) + const prior = this.idleAttempts.get(input.relayHostId) + if (prior) { + if (prior.attemptId !== input.attemptId) return { outcome: 'busy' } + return prior.authorityKey === authorityKey ? prior.promise : { outcome: 'stale' } + } + const session = this.get(input) + if ( + this.draining || + !session || + session.state !== 'active' || + session.generation !== input.sourceGeneration || + session.assignmentEpoch !== input.sourceAssignmentEpoch || + this.cellIncarnation !== input.sourceCellIncarnation + ) { + const durable = await reconcile() + return { outcome: durable === 'committed' ? 'committed' : 'stale' } + } + if ( + !session.socket || + !this.hostCapabilities.get(session.socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME) + ) + return { outcome: 'deferred' } + if ( + (this.idleWork.get(input.relayHostId) ?? 0) !== 0 || + session.activeConnIds.size !== 0 || + session.activeSplices.size !== 0 || + session.pendingConns.size !== 0 + ) + return { outcome: 'busy' } + const revision = session.authorityRevision + const promise = Promise.resolve().then(async () => { + let outcome: 'committed' | 'deferred' | 'stale' + try { + outcome = (await commit()).outcome + if (outcome === 'deferred') { + const durable = await reconcile() + outcome = durable === 'not-committed' ? 'deferred' : durable + } + } catch { + let delay = 100 + for (;;) { + try { + const durable = await reconcile() + outcome = durable === 'not-committed' ? 'deferred' : durable + break + } catch { + await new Promise((resolve) => { + const timer = setTimeout(resolve, delay) + timer.unref?.() + }) + delay = Math.min(delay * 2, 5000) + } + } + } + if (this.get(input) === session) { + if (outcome !== 'deferred' || this.draining || session.authorityRevision !== revision) { + this.closeDrainedSession(session) + } + } + if (this.idleAttempts.get(input.relayHostId)?.promise === promise) + this.idleAttempts.delete(input.relayHostId) + return { outcome } + }) + this.idleAttempts.set(input.relayHostId, { attemptId: input.attemptId, authorityKey, promise }) + return promise + } + + private beginIdleWork(hostId: string): (() => void) | null { + if (this.idleAttempts.has(hostId)) return null + this.idleWork.set(hostId, (this.idleWork.get(hostId) ?? 0) + 1) + return () => { + const remaining = (this.idleWork.get(hostId) ?? 1) - 1 + if (remaining === 0) this.idleWork.delete(hostId) + else this.idleWork.set(hostId, remaining) + } + } + constructor( private readonly config: RelayConfig, private readonly verifyRelayToken: VerifyRelayToken, @@ -192,7 +295,8 @@ export class HostSessionRegistry { private readonly queuedByteBudget: ProcessQueuedByteBudget, private readonly observer: RelayRuntimeObserver, private readonly now: () => number = Date.now, - private readonly random: () => number = Math.random + private readonly random: () => number = Math.random, + private readonly cellIncarnation?: string ) {} // Uniform over [CONTROL_LEASE_MS - jitter, CONTROL_LEASE_MS + jitter). @@ -206,6 +310,25 @@ export class HostSessionRegistry { hostId: string, credential: string, capacityReservation?: PendingHostDataReservation + ): Promise { + const release = this.beginIdleWork(hostId) + if (!release) { + capacityReservation?.release() + this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL) + return + } + try { + await this.acceptClientUnfenced(socket, hostId, credential, capacityReservation) + } finally { + release() + } + } + + private async acceptClientUnfenced( + socket: WebSocket, + hostId: string, + credential: string, + capacityReservation?: PendingHostDataReservation ): Promise { if (this.draining) { capacityReservation?.release() @@ -295,6 +418,7 @@ export class HostSessionRegistry { this.rejectClient(socket, RELAY_CLOSE_CODE.LIMIT_EXCEEDED) return } + const admittingSocket = session.socket const connId = randomUUID() const connTicket = randomBytes(32).toString('base64url') const identity = { userId: reservation.userId, relayHostId: hostId } @@ -324,6 +448,20 @@ export class HostSessionRegistry { ) { return } + // Admission may have crossed a drain or control replacement while persisting activity. + if ( + this.draining || + this.sessions.get(sessionKey) !== session || + session.state !== 'active' || + session.socket !== admittingSocket || + admittingSocket.readyState !== admittingSocket.OPEN + ) { + capacityReservation?.release() + this.failReservationBestEffort(reservation) + if (credentialActivityId) this.releaseActivityBestEffort(identity, credentialActivityId) + this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL) + return + } markStage('activity') const attachTimer = setTimeout(() => { session.pendingConns.delete(connId) @@ -373,9 +511,34 @@ export class HostSessionRegistry { connTicket: string, generation: number ): Promise { - const session = [...this.sessions.values()].find((candidate) => - candidate.pendingConns.has(connId) - ) + // First insertion-order owner, and the only scan the attach makes: the + // unfenced leg reuses this result instead of repeating the search. + let owner: HostSession | undefined + for (const candidate of this.sessions.values()) { + if (candidate.pendingConns.has(connId)) { + owner = candidate + break + } + } + const release = owner ? this.beginIdleWork(owner.relayHostId) : () => {} + if (!release) { + socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress') + return false + } + try { + return await this.acceptHostDataUnfenced(socket, connId, connTicket, generation, owner) + } finally { + release() + } + } + + private async acceptHostDataUnfenced( + socket: WebSocket, + connId: string, + connTicket: string, + generation: number, + session: HostSession | undefined + ): Promise { const pending = session?.pendingConns.get(connId) if ( !session || @@ -427,6 +590,27 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'basis persistence failed') return false } + // Already admitted attachments may finish a regional drain, but never a retired generation. + if ( + this.draining || + this.sessions.get(this.key(identity.userId, identity.relayHostId)) !== session || + this.get(identity)?.state === 'closed' || + !session.activeConnIds.has(connId) || + socket.readyState !== socket.OPEN || + pending.client.readyState !== pending.client.OPEN + ) { + session.activeConnIds.delete(connId) + pending.capacityReservation?.release() + this.deactivateBasisBestEffort(connId) + this.failReservationBestEffort(pending.reservation) + if (spliceActivityId) this.releaseActivityBestEffort(identity, spliceActivityId) + if (pending.credentialActivityId) { + this.releaseActivityBestEffort(identity, pending.credentialActivityId) + } + this.rejectClient(pending.client, RELAY_CLOSE_CODE.DRAINING) + socket.close(RELAY_CLOSE_CODE.DRAINING, 'host retired during attachment') + return false + } const close = wireSplice({ client: pending.client, host: socket, @@ -505,6 +689,7 @@ export class HostSessionRegistry { JSON.stringify({ event: 'orca_relay_client_accept_completed', ...this.logIdentity(), + ...this.sessionPlacementLogFields(session), credentialKind: pending.reservation.credentialKind, stageMs, totalMs, @@ -513,6 +698,14 @@ export class HostSessionRegistry { ) } + private sessionPlacementLogFields(session: HostSession) { + return { + assignmentEpoch: session.assignmentEpoch, + controlGeneration: session.generation, + drainMode: session.regionalDrainAttemptId ? 'deadline' : 'none' + } + } + // Matches the runtime metrics event so a log line and a metric point can be // joined back to the process that emitted them. private logIdentity(): { role: string; cellId: string; region: RelayRegion } { @@ -549,6 +742,7 @@ export class HostSessionRegistry { JSON.stringify({ event: 'orca_relay_host_control_rtt', ...this.logIdentity(), + ...this.sessionPlacementLogFields(session), relayHostIdDigest: relayHostLogDigest(session.relayHostId), rttMsMedian: percentile(samples, 0.5), sampleCount: samples.length @@ -562,6 +756,10 @@ export class HostSessionRegistry { connectionInclusionWatermark?: number, hostCapabilities?: ReadonlySet ): void { + if (this.idleAttempts.has(identity.relayHostId)) { + socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress') + return + } // Keyed by socket, not session: a rebind swaps the session's socket, and the // successor's own advertisement is the only one that describes its decoder. if (hostCapabilities?.size) this.hostCapabilities.set(socket, hostCapabilities) @@ -602,8 +800,7 @@ export class HostSessionRegistry { socket: WebSocket | null, context: string ): void { - void Promise.resolve() - .then(task) + void (async () => task())() .catch((error: unknown) => { const message = (error instanceof Error ? error.message : 'unknown') // Untruncated, unlike peer-supplied close reasons: this is the @@ -653,6 +850,7 @@ export class HostSessionRegistry { this.draining = true for (const session of this.sessions.values()) { if (session.state === 'closed') continue + session.authorityRevision += 1 session.state = 'drain-only' if (session.socket) send(session.socket, 'drain', { graceMs, recovery: 'resolve-director' }) setTimeout(() => this.closeDrainedSession(session), graceMs) @@ -665,7 +863,8 @@ export class HostSessionRegistry { relayHostId: string sourceAssignmentEpoch: number graceMs: number - }): RegionalHostDrainOutcome { + sourceCellIncarnation?: string + }): RegionalHostDrainOutcome | Promise { const session = this.get(input) if (!session || session.state === 'closed') return 'host-not-connected' if (session.assignmentEpoch !== input.sourceAssignmentEpoch) { @@ -678,13 +877,11 @@ export class HostSessionRegistry { this.reassertRegionalDrain(session) return 'already-accepted' } + session.authorityRevision += 1 session.regionalDrainAttemptId = input.attemptId session.regionalDrainExpiresAt = this.now() + input.graceMs this.reassertRegionalDrain(session) - session.regionalDrainTimer = setTimeout( - () => this.closeDrainedSession(session), - input.graceMs - ) + session.regionalDrainTimer = setTimeout(() => this.closeDrainedSession(session), input.graceMs) return 'accepted' } @@ -740,9 +937,9 @@ export class HostSessionRegistry { const existing = this.sessions.get(key) const rebind = Boolean( existing && - hello.data.controlResumeSecret && - hello.data.controlResumeSecret === existing.controlResumeSecret && - (existing.state === 'orphaned' || existing.state === 'active') + hello.data.controlResumeSecret && + hello.data.controlResumeSecret === existing.controlResumeSecret && + (existing.state === 'orphaned' || existing.state === 'active') ) const generation = rebind ? existing!.generation : (existing?.generation ?? 0) + 1 const ephemeral = nacl.box.keyPair() @@ -784,7 +981,9 @@ export class HostSessionRegistry { }, 10_000) socket.once('message', (raw, isBinary) => { clearTimeout(proofTimer) - const ack = isBinary ? null : HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack')) + const ack = isBinary + ? null + : HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack')) const proof = ack?.success ? decodeCanonicalBase64(ack.data.proofB64, 32) : null if ( !ack?.success || @@ -826,6 +1025,11 @@ export class HostSessionRegistry { appVersion: string, connectionInclusionWatermark?: number ): Promise { + const release = this.beginIdleWork(identity.relayHostId) + if (!release) { + socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress') + return Promise.resolve() + } const key = this.key(identity.sub, identity.relayHostId) const previous = this.activationQueues.get(key) ?? Promise.resolve() // The timeout only fails this waiting socket; the queue entry still chains @@ -836,26 +1040,29 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'control activation queue stalled') }, ACTIVATION_QUEUE_WAIT_MS) queueWaitTimer.unref?.() - const activation = previous.catch(() => undefined).then(async () => { - clearTimeout(queueWaitTimer) - if (queueWaitExpired) return - if ((this.sessions.get(key) ?? null) !== existing) { - socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') - return - } - await this.activateCurrent( - socket, - identity, - existing, - generation, - rebind, - assignmentEpoch, - appVersion, - connectionInclusionWatermark - ) - }) + const activation = previous + .catch(() => undefined) + .then(async () => { + clearTimeout(queueWaitTimer) + if (queueWaitExpired) return + if ((this.sessions.get(key) ?? null) !== existing) { + socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') + return + } + await this.activateCurrent( + socket, + identity, + existing, + generation, + rebind, + assignmentEpoch, + appVersion, + connectionInclusionWatermark + ) + }) this.activationQueues.set(key, activation) const cleanup = (): void => { + release() if (this.activationQueues.get(key) === activation) this.activationQueues.delete(key) } void activation.then(cleanup, cleanup) @@ -881,7 +1088,11 @@ export class HostSessionRegistry { cellId: this.config.cellId, assignmentEpoch, generation, - connectionInclusionWatermark + connectionInclusionWatermark, + idleRegionalRehome: + this.hostCapabilities.get(socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME) ?? + false, + cellIncarnation: this.cellIncarnation } ) await this.assignments.markMigrationTargetRegistered( @@ -918,14 +1129,15 @@ export class HostSessionRegistry { const previousSocket = existing.socket if (existing.orphanTimer) clearTimeout(existing.orphanTimer) existing.orphanTimer = null + existing.authorityRevision += 1 + existing.assignmentEpoch = assignmentEpoch existing.socket = socket existing.state = existing.regionalDrainAttemptId ? 'drain-only' : 'active' existing.appVersion = appVersion existing.leaseExpiresAt = this.controlLeaseExpiresAt() existing.lastPongAt = this.now() existing.pendingPingAt = null - existing.activityRenewalDueAt = - this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs + existing.activityRenewalDueAt = this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs this.wireActiveControl(existing) this.sendHelloAck(existing) if (existing.regionalDrainAttemptId) this.reassertRegionalDrain(existing) @@ -980,6 +1192,7 @@ export class HostSessionRegistry { pendingPingAt: null, controlRttSamplesMs: [], controlRttLoggedAt: null, + authorityRevision: 0, activityRenewalDueAt: this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs, activityRenewalAttempt: 0, activityRenewalCompletedAttempt: 0, @@ -1028,7 +1241,9 @@ export class HostSessionRegistry { ` splices=${session.closingCounts?.splices ?? session.activeSplices.size}` + ` pending=${session.closingCounts?.pending ?? session.pendingConns.size}` + ` code=${code} reason=${JSON.stringify(printableCloseReason(reason))}` + - (socketError === null ? '' : ` error=${JSON.stringify(printableCloseReason(socketError))}`) + (socketError === null + ? '' + : ` error=${JSON.stringify(printableCloseReason(socketError))}`) ) }) socket.on('message', (raw, isBinary) => { @@ -1077,6 +1292,19 @@ export class HostSessionRegistry { } private async acceptRefresh(session: HostSession, raw: RawData): Promise { + const release = this.beginIdleWork(session.relayHostId) + if (!release) { + session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'resolve-director') + return + } + try { + await this.acceptRefreshUnfenced(session, raw) + } finally { + release() + } + } + + private async acceptRefreshUnfenced(session: HostSession, raw: RawData): Promise { const parsed = AuthRefreshSchema.safeParse(payload(raw, 'auth-refresh')) if (!parsed.success) return const refreshed = await this.verifyRelayToken(parsed.data.relayJwt) @@ -1107,6 +1335,16 @@ export class HostSessionRegistry { if (controlActivityId && now >= session.activityRenewalDueAt) { const attempt = ++session.activityRenewalAttempt const startedAt = now + const socket = session.socket + const authorityRevision = session.authorityRevision + const current = (): boolean => + this.sessions.get(key) === session && + session.state !== 'closed' && + session.socket === socket && + socket.readyState === socket.OPEN && + session.controlActivityId === controlActivityId && + session.authorityRevision === authorityRevision && + attempt > session.activityRenewalCompletedAttempt void this.assignments .renewControlActivity( { userId: session.identity.sub, relayHostId: session.relayHostId }, @@ -1117,11 +1355,16 @@ export class HostSessionRegistry { } ) .then(() => { - if (attempt <= session.activityRenewalCompletedAttempt) return + if (!current()) return session.activityRenewalCompletedAttempt = attempt session.activityRenewalDueAt = startedAt + CONTROL_ACTIVITY_RENEWAL_INTERVAL_MS }) .catch(async (error: unknown) => { + if (!current()) return + if (error instanceof Error && error.message === 'assignment_not_found') { + socket.close(RELAY_CLOSE_CODE.DRAINING, 'control assignment missing') + return + } if (error instanceof Error && error.message === 'activity_cell_not_authoritative') { // Completion fences a late drain-only heartbeat after all source work is gone. session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'control migration completed') @@ -1144,8 +1387,22 @@ export class HostSessionRegistry { cellId: this.config.cellId } ) + if (!current()) { + // A replaced activity must not remain leased after its owner disappears. + if ( + !this.sessions.get(key) || + this.sessions.get(key)?.controlActivityId !== controlActivityId + ) { + this.releaseActivityBestEffort( + { userId: session.identity.sub, relayHostId: session.relayHostId }, + controlActivityId + ) + } + return + } this.observer.recordControlActivityRecovery?.(true) } catch (acquireError: unknown) { + if (!current()) return this.observer.recordControlActivityRecovery?.(false) if ( acquireError instanceof Error && @@ -1218,6 +1475,21 @@ export class HostSessionRegistry { private closeDrainedSession(session: HostSession): void { if (session.state === 'closed') return + const forcedConnections = session.activeConnIds.size + session.pendingConns.size + if (forcedConnections > 0) { + console.warn( + JSON.stringify({ + event: 'orca_relay_host_drain_forced_close', + ...this.logIdentity(), + ...this.sessionPlacementLogFields(session), + relayHostIdDigest: relayHostLogDigest(session.relayHostId), + reason: this.draining ? 'emergency' : 'regional-deadline', + forcedConnections, + splices: session.activeSplices.size, + pending: session.pendingConns.size + }) + ) + } if (session.heartbeatTimer) clearInterval(session.heartbeatTimer) if (session.orphanTimer) clearTimeout(session.orphanTimer) if (session.regionalDrainTimer) clearTimeout(session.regionalDrainTimer) @@ -1250,11 +1522,7 @@ export class HostSessionRegistry { session.pendingConns.clear() session.state = 'closed' if (session.socket) { - closeRelayWebSocket( - session.socket, - RELAY_CLOSE_CODE.DRAINING, - 'resolve configured director' - ) + closeRelayWebSocket(session.socket, RELAY_CLOSE_CODE.DRAINING, 'resolve configured director') } const key = this.key(session.identity.sub, session.relayHostId) if (this.sessions.get(key) === session) this.sessions.delete(key) @@ -1278,6 +1546,23 @@ export class HostSessionRegistry { session: HostSession, type: unknown, raw: RawData + ): Promise { + const release = this.beginIdleWork(session.relayHostId) + if (!release) { + session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'resolve-director') + return + } + try { + await this.acceptControlCommandUnfenced(session, type, raw) + } finally { + release() + } + } + + private async acceptControlCommandUnfenced( + session: HostSession, + type: unknown, + raw: RawData ): Promise { if (typeof type !== 'string' || !session.socket) return try { @@ -1315,10 +1600,7 @@ export class HostSessionRegistry { } if (type === 'device-credential-install') { const request = DeviceCredentialInstallSchema.parse(payload(raw, type)) - if ( - session.state !== 'active' && - request.authorization.mode === 'authenticated-direct' - ) { + if (session.state !== 'active' && request.authorization.mode === 'authenticated-direct') { throw new Error('authorization_expired') } const installActivityId = `install:${request.reqId}` diff --git a/cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts b/cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts new file mode 100644 index 00000000000..6fc2017ab48 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayDatabase } from './database.js' +import { openIdleRehomeTestDatabase } from './idle-regional-rehome-test-database.js' + +const request = { + v: 1 as const, + attemptId: '33333333-3333-4333-8333-333333333333', + userId: 'idle-reconciliation-test', + relayHostId: 'abcdefghijklmnop', + sourceCellId: 'source', + sourceCellIncarnation: '11111111-1111-4111-8111-111111111111', + sourceAssignmentEpoch: 1, + sourceGeneration: 7, + targetCellId: 'target' +} +const databases: RelayDatabase[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const database of databases.splice(0)) await database.close() +}) + +async function setup() { + const database = await openIdleRehomeTestDatabase() + databases.push(database) + const store = new RelayAssignmentStore(database, () => 100_000_000) + await store.reconcileCells([ + { id: 'source', url: 'https://source.example.test', capacityRequests: 100 }, + { id: 'target', url: 'https://target.example.test', capacityRequests: 100 } + ]) + await store.assign(request) + await store.activateControl(request, { + cellId: request.sourceCellId, + assignmentEpoch: request.sourceAssignmentEpoch, + generation: request.sourceGeneration, + cellIncarnation: request.sourceCellIncarnation + }) + return { database, store } +} + +describe('idle cutover durable reconciliation', () => { + it('only permits reopening when the exact source still owns the assignment', async () => { + const { store } = await setup() + expect(await store.reconcileIdleRegionalRehome(request)).toBe('not-committed') + await store.activateControl(request, { + cellId: request.sourceCellId, + assignmentEpoch: request.sourceAssignmentEpoch, + generation: request.sourceGeneration + 1, + cellIncarnation: request.sourceCellIncarnation + }) + expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') + }) + + it('does not reopen an obsolete source after an assignment change', async () => { + const { store } = await setup() + await store.startEvacuation(request, request.targetCellId) + expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') + }) + + it('propagates unavailable durable state instead of declaring rollback', async () => { + const { database, store } = await setup() + vi.spyOn(database, 'transaction').mockRejectedValue(new Error('database_unavailable')) + await expect(store.reconcileIdleRegionalRehome(request)).rejects.toThrow('database_unavailable') + }) + + it('waits for an outstanding assignment transaction before deciding authority', async () => { + const { database, store } = await setup() + let release!: () => void + let entered!: () => void + const locked = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const commit = database.transaction(async (transaction) => { + await transaction.queryLocked( + 'SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?', + [request.userId, request.relayHostId] + ) + entered() + await gate + await transaction.query( + 'UPDATE relay_assignments SET assignment_epoch = assignment_epoch + 1 WHERE user_id = ? AND relay_host_id = ?', + [request.userId, request.relayHostId] + ) + }) + await locked + let settled = false + const reconciliation = store.reconcileIdleRegionalRehome(request).finally(() => { + settled = true + }) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(settled).toBe(false) + } finally { + release() + await commit + await reconciliation + } + expect(await reconciliation).toBe('stale') + }) +}) diff --git a/cloud/apps/relay/src/idle-regional-rehome-selection.ts b/cloud/apps/relay/src/idle-regional-rehome-selection.ts new file mode 100644 index 00000000000..f8790459c0b --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-selection.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' +import type { IdleRegionalRehomeRequest } from '@orca-cloud/relay-contract' +import type { RelayDatabase, SqlRow } from './database.js' + +export const IDLE_REHOME_PAGE_SIZE = 100 + +export async function selectIdleRegionalRehomes(input: { + database: RelayDatabase + now: number + heartbeatTtlMs: number + cohortPercent: number + offset: number + connectionHeadroom: Map + cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean +}): Promise> { + const [runtimes, safetyRows] = await Promise.all([ + input.database.query('SELECT * FROM relay_cell_runtime'), + input.database.query('SELECT * FROM relay_cell_rehome_safety') + ]) + const cleanCells = runtimes + .filter((runtime) => + input.cellIsClean( + safetyRows.find((safety) => safety.cell_id === runtime.cell_id), + runtime, + input.now + ) + ) + .map((runtime) => String(runtime.cell_id)) + const targetCells = cleanCells.filter((id) => input.connectionHeadroom.get(id) !== false) + if (!cleanCells.length || !targetCells.length) return [] + const rows = await input.database.query( + `SELECT a.user_id, a.relay_host_id, a.cell_id AS source_cell_id, + a.assignment_epoch, host.generation, r.cell_incarnation, + s.cell_url, target.cell_id AS target_cell_id + FROM relay_region_rehome_control policy + JOIN relay_region_decisions d ON d.outcome = 'conclusive' + JOIN relay_assignments a ON a.user_id = d.user_id AND a.relay_host_id = d.relay_host_id + JOIN relay_cells s ON s.cell_id = a.cell_id AND s.enabled = 1 + JOIN relay_cell_regions sr ON sr.cell_id = a.cell_id + JOIN relay_cell_admission sa ON sa.cell_id = a.cell_id AND sa.admission_state = 'general' + JOIN relay_cell_runtime r ON r.cell_id = a.cell_id AND r.ready = 1 + JOIN relay_cell_capabilities c ON c.cell_id = r.cell_id AND c.cell_incarnation = r.cell_incarnation + JOIN relay_control_capabilities host ON host.user_id = a.user_id AND host.relay_host_id = a.relay_host_id + AND host.cell_id = a.cell_id AND host.assignment_epoch = a.assignment_epoch + AND host.cell_incarnation = r.cell_incarnation AND host.idle_regional_rehome = 1 + JOIN relay_assignment_activity_leases lease ON lease.user_id = host.user_id + AND lease.relay_host_id = host.relay_host_id AND lease.activity_id = host.activity_id + AND lease.cell_id = a.cell_id AND lease.activity_kind = 'control' + JOIN relay_cell_regions tr ON tr.region = d.preferred_region + JOIN relay_cells target ON target.cell_id = tr.cell_id AND target.enabled = 1 + JOIN relay_cell_admission ta ON ta.cell_id = target.cell_id AND ta.admission_state = 'general' + JOIN relay_cell_runtime rt ON rt.cell_id = target.cell_id AND rt.ready = 1 + JOIN relay_cell_capabilities ct ON ct.cell_id = rt.cell_id AND ct.cell_incarnation = rt.cell_incarnation + WHERE policy.control_id = 'global' AND policy.enabled = 1 AND policy.not_before <= ? + AND d.preferred_region <> sr.region AND d.incumbent_region = sr.region + AND d.assignment_epoch = a.assignment_epoch AND d.policy_version = 1 + AND d.expires_at > ? AND d.observed_at >= ? - policy.preference_max_age_ms + AND d.cohort_bucket < ? AND lease.expires_at > ? AND lease.updated_at >= r.started_at + AND r.last_heartbeat_at > ? AND rt.last_heartbeat_at > ? + AND s.cell_id IN (${cleanCells.map(() => '?').join(',')}) + AND target.cell_id IN (${targetCells.map(() => '?').join(',')}) + -- Reserve the moving host's source activity plus its assignment on the target. + AND target.reserved_requests + 1 + ( + SELECT COALESCE(SUM(activity.request_units), 0) + FROM relay_assignment_activity_leases activity + WHERE activity.user_id = a.user_id AND activity.relay_host_id = a.relay_host_id + AND activity.cell_id = a.cell_id + ) <= target.capacity_requests + AND c.regional_rehome_protocol >= 3 AND ct.regional_rehome_protocol >= 3 + AND NOT EXISTS (SELECT 1 FROM relay_assignment_migrations migration + WHERE migration.user_id = a.user_id AND migration.relay_host_id = a.relay_host_id + AND migration.completed_at IS NULL AND migration.aborted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM relay_region_rehome_attempts attempt + WHERE attempt.user_id = a.user_id AND attempt.relay_host_id = a.relay_host_id + AND attempt.created_at > ? - policy.host_cooldown_ms) + ORDER BY a.user_id, a.relay_host_id, host.generation DESC, + (target.reserved_requests + rt.observed_requests) * 1.0 / target.capacity_requests, + target.cell_id + LIMIT ? OFFSET ?`, + [ + input.now, + input.now, + input.now, + input.cohortPercent, + input.now, + input.now - input.heartbeatTtlMs, + input.now - input.heartbeatTtlMs, + ...cleanCells, + ...targetCells, + input.now, + IDLE_REHOME_PAGE_SIZE, + input.offset + ] + ) + return rows.map((row) => { + const request = { + v: 1 as const, + userId: String(row.user_id), + relayHostId: String(row.relay_host_id), + sourceCellId: String(row.source_cell_id), + sourceCellIncarnation: String(row.cell_incarnation), + sourceAssignmentEpoch: Number(row.assignment_epoch), + sourceGeneration: Number(row.generation), + targetCellId: String(row.target_cell_id) + } + // UUIDv5 keeps retries on every director bound to the same source authority and target. + const digest = createHash('sha1') + .update(Buffer.from('0a1c5a9b197b4ea8b6f1f3bcaa3d712c', 'hex')) + .update(JSON.stringify(request)) + .digest() + digest[6] = (digest[6]! & 0x0f) | 0x50 + digest[8] = (digest[8]! & 0x3f) | 0x80 + const hex = digest.subarray(0, 16).toString('hex') + const attemptId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + return { ...request, attemptId, sourceCellUrl: String(row.cell_url) } + }) +} diff --git a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts new file mode 100644 index 00000000000..92ced43b510 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts @@ -0,0 +1,396 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayDatabase, RelayLockOptions } from './database.js' +import { openIdleRehomeTestDatabase } from './idle-regional-rehome-test-database.js' + +const identity = { userId: 'idle-store-test', relayHostId: 'abcdefghijklmnop' } +const incarnations = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' +] +const cells = [ + { + id: 'source', + url: 'https://source.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + }, + { + id: 'target', + url: 'https://target.example.test', + region: 'asia-east2' as const, + capacityRequests: 100 + } +] +const databases: RelayDatabase[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const database of databases.splice(0)) await database.close() +}) + +async function setup() { + const database = await openIdleRehomeTestDatabase() + databases.push(database) + let now = 100_000_000 + const store = new RelayAssignmentStore(database, () => now, { regionalRehomeCohortPercent: 100 }) + await store.inspectRegionalRehomeControl() + now += 86_400_000 + await store.applyRegionalRehomeControl({ + expectedGeneration: 0, + enabled: true, + notBefore: now, + ratePerMinute: 10, + preferenceMaxAgeMs: 86_400_000, + hostCooldownMs: 604_800_000, + drainGraceMs: 60_000 + }) + await store.reconcileCells(cells) + const safety = { + observedAt: now, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + for (const [index, cell] of cells.entries()) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt: now - 1_000, + ready: true, + observedRequests: 0 + }) + await store.recordCellRegionalRehomeStatus({ + cellId: cell.id, + cellIncarnation: incarnations[index]!, + regionalRehomeProtocol: 3, + safety + }) + } + const assignment = await store.assign(identity, undefined, 'us-central1') + await store.activateControl(identity, { + cellId: cells[0]!.id, + assignmentEpoch: assignment.assignmentEpoch, + generation: 7, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + const issued = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 180, 'asia-east2': 40 } + }, + assignment.assignmentEpoch + ) + const request = { + v: 1 as const, + ...identity, + attemptId: '33333333-3333-4333-8333-333333333333', + sourceCellId: cells[0]!.id, + sourceCellIncarnation: incarnations[0]!, + sourceAssignmentEpoch: assignment.assignmentEpoch, + sourceGeneration: 7, + targetCellId: cells[1]!.id + } + return { store, database, safety, request } +} + +describe('constrained idle regional assignment transaction', () => { + it.each(['missing', 'disabled', 'future'] as const)( + 'does only one read per tick with %s durable control and sees later enablement', + async (state) => { + const { store, database, safety } = await setup() + const control = (await database.query( + "SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'" + ))[0]! + if (state === 'missing') { + await database.query('DELETE FROM relay_region_rehome_control') + } else { + await database.query( + "UPDATE relay_region_rehome_control SET enabled = ?, not_before = ? WHERE control_id = 'global'", + [state === 'disabled' ? 0 : 1, safety.observedAt + (state === 'future' ? 1 : 0)] + ) + } + const query = vi.spyOn(database, 'query') + const transaction = vi.spyOn(database, 'transaction') + for (let tick = 0; tick < 3; tick++) { + query.mockClear() + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) + expect(query).toHaveBeenCalledTimes(1) + expect(query.mock.calls[0]![0]).toMatch(/^SELECT .*FROM relay_region_rehome_control/s) + expect(transaction).not.toHaveBeenCalled() + } + if (state === 'missing') { + const columns = Object.keys(control) + await database.query( + `INSERT INTO relay_region_rehome_control (${columns.join(',')}) VALUES (${columns.map(() => '?').join(',')})`, + Object.values(control) + ) + } else { + await database.query( + "UPDATE relay_region_rehome_control SET enabled = 1, not_before = ? WHERE control_id = 'global'", + [safety.observedAt] + ) + } + query.mockClear() + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1) + expect(query.mock.calls.length).toBeGreaterThan(1) + await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'") + query.mockClear() + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) + expect(query).toHaveBeenCalledTimes(1) + } + ) + + it.each([10, 11])('reserves source activity plus assignment at target capacity %i', async (capacity) => { + const { store, database, safety, request } = await setup() + // Model three source activity units and seven units already reserved at the target. + await database.query( + 'UPDATE relay_assignment_activity_leases SET request_units = 3 WHERE user_id = ? AND relay_host_id = ?', + [identity.userId, identity.relayHostId] + ) + await database.query("UPDATE relay_cells SET reserved_requests = 4 WHERE cell_id = 'source'") + await database.query( + "UPDATE relay_cells SET reserved_requests = 7, capacity_requests = ? WHERE cell_id = 'target'", + [capacity] + ) + const candidates = await store.selectIdleRegionalRehomeCandidates(safety) + expect(candidates).toHaveLength(capacity === 11 ? 1 : 0) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ + outcome: capacity === 11 ? 'committed' : 'deferred' + }) + const [target] = await database.query("SELECT reserved_requests FROM relay_cells WHERE cell_id = 'target'") + expect(Number(target!.reserved_requests)).toBe(capacity === 11 ? 11 : 7) + expect(await store.resolve(identity)).toMatchObject({ + cellId: capacity === 11 ? 'target' : 'source', + assignmentEpoch: capacity === 11 ? 2 : 1 + }) + }) + + it('progresses past a full page of busy candidates without writing eligibility state', async () => { + const { store, database, safety } = await setup() + for (const table of [ + 'relay_assignments', + 'relay_assignment_activity_leases', + 'relay_control_capabilities', + 'relay_region_decisions' + ]) { + const template = ( + await database.query(`SELECT * FROM ${table} WHERE user_id = ? AND relay_host_id = ?`, [ + identity.userId, + identity.relayHostId + ]) + )[0]! + const columns = Object.keys(template) + for (let index = 0; index < 100; index++) { + const values = columns.map((column) => + column === 'user_id' || column === 'relay_host_id' ? '?' : column + ) + await database.query( + `INSERT INTO ${table} (${columns.join(', ')}) SELECT ${values.join(', ')} FROM ${table} + WHERE user_id = ? AND relay_host_id = ?`, + [ + `idle-store-test-${String(index).padStart(3, '0')}`, + `pagehost${String(index).padStart(8, '0')}`, + identity.userId, + identity.relayHostId + ] + ) + } + } + const first = await store.selectIdleRegionalRehomeCandidates(safety) + const next = await store.selectIdleRegionalRehomeCandidates(safety) + expect(first).toHaveLength(100) + expect(next).toHaveLength(1) + expect(next[0]!.relayHostId).toBe('pagehost00000099') + const restarted = new RelayAssignmentStore(database, () => safety.observedAt, { + regionalRehomeCohortPercent: 100 + }) + expect(await restarted.selectIdleRegionalRehomeCandidates(safety)).toEqual(first) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + const decisions = await database.query('SELECT last_considered_at FROM relay_region_decisions') + expect(decisions.every((decision) => Number(decision.last_considered_at) === 0)).toBe(true) + }) + + it.runIf(Boolean(process.env.ORCA_IDLE_REHOME_POSTGRES_URL))( + 'rechecks generation when replacement wins after the initial authority lookup', + async () => { + const { store, safety, request, database } = await setup() + const held = holdStatement(database, 'SELECT * FROM relay_region_rehome_control') + const commit = store.commitIdleRegionalRehome(request, safety) + await held.entered + try { + await store.activateControl(identity, { + cellId: 'source', + assignmentEpoch: 1, + generation: 8, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + } finally { + held.release() + } + expect(await commit).toEqual({ outcome: 'deferred' }) + expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + } + ) + + it('rejects source replacement when the cutover already holds assignment authority', async () => { + const { store, safety, request, database } = await setup() + const held = holdStatement(database, 'UPDATE relay_assignments SET cell_id') + const commit = store.commitIdleRegionalRehome(request, safety) + await held.entered + const replacement = store.activateControl(identity, { + cellId: 'source', + assignmentEpoch: 1, + generation: 8, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + const rejected = expect(replacement).rejects.toThrow('wrong_assignment') + held.release() + expect(await commit).toEqual({ outcome: 'committed' }) + await rejected + expect(await store.resolve(identity)).toMatchObject({ cellId: 'target', assignmentEpoch: 2 }) + }) + + it('finds the committed attempt after its database reply is lost', async () => { + const { store, safety, request, database } = await setup() + const transaction = database.transaction.bind(database) + const intercepted = vi + .spyOn(database, 'transaction') + .mockImplementation(async (operation, options) => { + let changed = false + const result = await transaction( + async (tx) => + operation( + new Proxy(tx, { + get(target, key) { + if (key === 'query') + return async (sql: string, params?: unknown[]) => { + if (sql.includes('INSERT INTO relay_region_rehome_attempts')) changed = true + return target.query(sql, params) + } + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } + }) + ), + options + ) + if (changed) throw new Error('simulated_commit_reply_lost') + return result + }) + await expect(store.commitIdleRegionalRehome(request, safety)).rejects.toThrow( + 'simulated_commit_reply_lost' + ) + intercepted.mockRestore() + expect(await store.reconcileIdleRegionalRehome(request)).toBe('committed') + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'committed' }) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toHaveLength(1) + }) + + it('commits the requested move once and records its outcome without source retention', async () => { + const { store, database, safety, request } = await setup() + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'committed' }) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'committed' }) + expect(await store.resolve(identity)).toMatchObject({ cellId: 'target', assignmentEpoch: 2 }) + const attempts = await database.query('SELECT * FROM relay_region_rehome_attempts') + expect(attempts).toHaveLength(1) + expect(attempts[0]!.attempt_id).toBe(request.attemptId) + expect(Number(attempts[0]!.source_generation)).toBe(7) + }) + + it('rejects a replaced control and never substitutes a different target', async () => { + const { store, safety, request } = await setup() + expect( + await store.commitIdleRegionalRehome({ ...request, targetCellId: 'missing' }, safety) + ).toEqual({ outcome: 'deferred' }) + await store.activateControl(identity, { + cellId: 'source', + assignmentEpoch: 1, + generation: 8, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'stale' }) + expect(await store.resolve(identity)).toMatchObject({ cellId: 'source', assignmentEpoch: 1 }) + }) + + it('does not commit without process safety or cohort authorization', async () => { + const { store, safety, request, database } = await setup() + expect(await store.commitIdleRegionalRehome(request)).toEqual({ outcome: 'deferred' }) + expect(await store.commitIdleRegionalRehome(request, safety, 0)).toEqual({ + outcome: 'deferred' + }) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + }) + + it('selects read-only with stable identity and the control generation, not probe generation', async () => { + const { store, safety, database } = await setup() + const before = await database.query('SELECT * FROM relay_assignments') + const candidates = await store.selectIdleRegionalRehomeCandidates(safety) + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatchObject({ + sourceGeneration: 7, + sourceCellId: 'source', + targetCellId: 'target' + }) + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual(candidates) + expect(await database.query('SELECT * FROM relay_assignments')).toEqual(before) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + }) +}) + +function holdStatement(database: RelayDatabase, fragment: string) { + let entered!: () => void + let release!: () => void + const arrival = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + let held = false + const transaction = database.transaction.bind(database) + vi.spyOn(database, 'transaction').mockImplementation((operation, options) => + transaction(async (tx) => { + return operation( + new Proxy(tx, { + get(target, key) { + if (key === 'query' || key === 'queryLocked') + return async (sql: string, params?: unknown[], lockOptions?: RelayLockOptions) => { + if (!held && sql.includes(fragment)) { + held = true + entered() + await gate + } + return key === 'queryLocked' + ? target.queryLocked(sql, params, lockOptions) + : target.query(sql, params) + } + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } + }) + ) + }, options) + ) + return { entered: arrival, release } +} diff --git a/cloud/apps/relay/src/idle-regional-rehome-test-database.ts b/cloud/apps/relay/src/idle-regional-rehome-test-database.ts new file mode 100644 index 00000000000..a541e7d1126 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-test-database.ts @@ -0,0 +1,40 @@ +import { randomUUID } from 'node:crypto' +import pg from 'pg' +import { openInMemoryRelayDatabase, openRelayDatabase, type RelayDatabase } from './database.js' + +export async function openIdleRehomeTestDatabase(): Promise { + const configured = process.env.ORCA_IDLE_REHOME_POSTGRES_URL + if (!configured) return openInMemoryRelayDatabase() + const url = new URL(configured) + if (url.port !== '55440' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { + throw new Error('idle_rehome_tests_require_local_postgres_55440') + } + const schema = `idle_rehome_${randomUUID().replaceAll('-', '')}` + const admin = new pg.Client({ connectionString: configured }) + await admin.connect() + try { + await admin.query(`CREATE SCHEMA ${schema}`) + url.searchParams.set('options', `-c search_path=${schema}`) + const database = await openRelayDatabase({ databaseUrl: url.toString(), dataDir: '' }) + const close = database.close.bind(database) + database.close = async () => { + try { + await close() + } finally { + try { + await admin.query(`DROP SCHEMA ${schema} CASCADE`) + } finally { + await admin.end() + } + } + } + return database + } catch (error) { + try { + await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + } finally { + await admin.end() + } + throw error + } +} diff --git a/cloud/apps/relay/src/idle-regional-rehome-worker.test.ts b/cloud/apps/relay/src/idle-regional-rehome-worker.test.ts new file mode 100644 index 00000000000..9b3f6d7fa07 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-worker.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RelayAssignmentStore } from './assignment-store.js' +import type { RelayConfig } from './config.js' +import { startRegionalRehomeWorker } from './regional-rehome-worker.js' + +const candidate = { + v: 1, + attemptId: '11111111-1111-4111-8111-111111111111', + userId: 'private-user', + relayHostId: 'abcdefghijklmnop', + sourceCellId: 'source', + sourceCellUrl: 'https://source.example.test', + sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', + sourceAssignmentEpoch: 7, + sourceGeneration: 3, + targetCellId: 'target' +} +const config = { + role: 'director', + regionCorrectionCohortPercent: 100, + rehomeAudience: 'https://relay.example.test/v1/admin/host-drain', + rehomeDirectorServiceAccount: 'director@example.test' +} as RelayConfig + +function setup(fetch: typeof globalThis.fetch) { + const selectIdleRegionalRehomeCandidates = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValue([candidate]) + const claimRegionalRehome = vi.fn() + const recordRegionalRehomeDispatchFailure = vi.fn() + const worker = startRegionalRehomeWorker( + config, + { + selectIdleRegionalRehomeCandidates, + claimRegionalRehome, + recordRegionalRehomeDispatchFailure + } as unknown as RelayAssignmentStore, + { + safetySnapshot: () => ({ observedAt: 100 }) as never, + intervalMs: 60_000, + identityToken: async () => 'private-token', + fetch + } + )! + return { + worker, + selectIdleRegionalRehomeCandidates, + claimRegionalRehome, + recordRegionalRehomeDispatchFailure + } +} + +describe('idle regional worker dispatch', () => { + afterEach(() => vi.restoreAllMocks()) + it('sends an idle request without claiming an assignment first', async () => { + const fetch = vi.fn(async () => + Response.json({ v: 1, outcome: 'committed' }) + ) + const c = setup(fetch) + await vi.waitFor(() => expect(c.selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()) + await c.worker.run() + c.worker.stop() + expect(c.claimRegionalRehome).not.toHaveBeenCalled() + expect(fetch).toHaveBeenCalledOnce() + const [url, init] = fetch.mock.calls[0]! + expect(String(url)).toBe('https://source.example.test/v1/admin/host-idle-rehome') + const { sourceCellUrl: _, ...request } = candidate + expect(JSON.parse(String(init?.body))).toEqual({ + ...request, + cohortPercent: 100, + directorSafety: { observedAt: 100 } + }) + }) + it('progresses past busy hosts without charging a dispatch failure', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ v: 1, outcome: 'busy' })) + .mockResolvedValueOnce(Response.json({ v: 1, outcome: 'committed' })) + const c = setup(fetch) + await vi.waitFor(() => expect(c.selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()) + c.selectIdleRegionalRehomeCandidates.mockResolvedValue([ + candidate, + { + ...candidate, + relayHostId: 'ponmlkjihgfedcba', + attemptId: '33333333-3333-4333-8333-333333333333' + } + ]) + await c.worker.run() + c.worker.stop() + expect(fetch).toHaveBeenCalledTimes(2) + expect(c.recordRegionalRehomeDispatchFailure).not.toHaveBeenCalled() + }) + it('does not charge a lost response as a claimed migration failure', async () => { + const fetch = vi.fn(async () => { + throw new Error('response lost') + }) + const c = setup(fetch) + await vi.waitFor(() => expect(c.selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()) + await c.worker.run() + c.worker.stop() + expect(c.recordRegionalRehomeDispatchFailure).not.toHaveBeenCalled() + }) +}) diff --git a/cloud/apps/relay/src/index.ts b/cloud/apps/relay/src/index.ts index 541884362c2..8e7b6a56941 100644 --- a/cloud/apps/relay/src/index.ts +++ b/cloud/apps/relay/src/index.ts @@ -2,6 +2,7 @@ import { formatAssignmentInventorySnapshot, readAssignmentInventorySnapshot } from './assignment-inventory-snapshot.js' +import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js' import { RelayAssignmentStore } from './assignment-store.js' import { loadRelayConfig } from './config.js' import { startCellHeartbeat } from './cell-heartbeat-client.js' @@ -71,6 +72,13 @@ const migrationInventoryTimer = roleOwnsAssignmentMaintenance(config.role) void runRelayBackgroundOperation(async () => { const inventory = await readRegisteredMigrationInventory(database, Date.now()) for (const line of formatRegisteredMigrationInventory(inventory)) console.warn(line) + console.log( + JSON.stringify({ + event: 'orca_relay_region_correction_outcomes', + observedAt: Date.now(), + outcomes: await readRegionCorrectionOutcomes(database, Date.now()) + }) + ) }, '[orca-relay] migration inventory failed') }, 5 * 60_000) : null diff --git a/cloud/apps/relay/src/region-correction-outcomes.ts b/cloud/apps/relay/src/region-correction-outcomes.ts new file mode 100644 index 00000000000..d3a6f8d3be3 --- /dev/null +++ b/cloud/apps/relay/src/region-correction-outcomes.ts @@ -0,0 +1,29 @@ +import type { RelayDatabase } from './database.js' + +export async function readRegionCorrectionOutcomes(database: RelayDatabase, now: number) { + const rows = await database.query( + `SELECT attempt.source_cell_id, attempt.target_cell_id, + CASE WHEN attempt.aborted_at IS NOT NULL THEN 'aborted' + WHEN attempt.completed_at IS NOT NULL THEN 'completed' + WHEN migration.target_registered_at IS NOT NULL THEN 'registered' ELSE 'registering' END AS state, + COUNT(*) AS count, + COALESCE(MAX(CASE WHEN attempt.completed_at IS NULL AND attempt.aborted_at IS NULL + THEN ? - attempt.created_at ELSE 0 END), 0) AS oldest_open_ms, + COALESCE(SUM(CASE WHEN attempt.completed_at IS NULL AND attempt.aborted_at IS NULL + THEN migration.target_reserved_units ELSE 0 END), 0) AS target_reserved_units + FROM relay_region_rehome_attempts attempt + JOIN relay_assignment_migrations migration ON migration.user_id = attempt.user_id + AND migration.relay_host_id = attempt.relay_host_id AND migration.assignment_epoch = attempt.assignment_epoch + GROUP BY attempt.source_cell_id, attempt.target_cell_id, state + ORDER BY attempt.source_cell_id, attempt.target_cell_id, state`, + [now] + ) + return rows.map((row) => ({ + sourceCellId: String(row.source_cell_id), + targetCellId: String(row.target_cell_id), + state: String(row.state), + count: Number(row.count), + oldestOpenMs: Number(row.oldest_open_ms), + targetReservedUnits: Number(row.target_reserved_units) + })) +} diff --git a/cloud/apps/relay/src/region-correction-preview.ts b/cloud/apps/relay/src/region-correction-preview.ts new file mode 100644 index 00000000000..c120dbb2272 --- /dev/null +++ b/cloud/apps/relay/src/region-correction-preview.ts @@ -0,0 +1,157 @@ +import type { RelayDatabase, SqlRow } from './database.js' +import { REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS } from './database.js' +import { + REGIONAL_REHOME_CONCURRENT_LIMIT, + REGION_DECISION_TTL_MS +} from './region-correction-state.js' + +export type RegionCorrectionPreview = { + observedAt: number + newClaimsEnabled: boolean + cohortPercent: number + openMigrations: number + availableMigrationSlots: number + globalSafetyFailure: string | null + counts: Record +} + +export async function previewRegionalRehomeEligibility(input: { + database: RelayDatabase + now: number + heartbeatTtlMs: number + cohortPercent: number + globalSafetyFailure: string | null + connectionHeadroom: ReadonlyMap + cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean +}): Promise { + const { database, now } = input + const [hosts, cells, runtimeRows, capabilityRows, safetyRows, controls, migrations] = + await Promise.all([ + database.query( + `SELECT assignment.cell_id, assignment.assignment_epoch, + decision.generation, decision.assignment_epoch AS decision_epoch, decision.expires_at, + decision.incumbent_region, decision.preferred_region, decision.outcome, decision.policy_version, + decision.observed_at, decision.cohort_bucket, + (SELECT MAX(attempt.created_at) FROM relay_region_rehome_attempts attempt + WHERE attempt.user_id = assignment.user_id AND attempt.relay_host_id = assignment.relay_host_id) AS last_attempt_at, + (SELECT COUNT(*) FROM relay_assignment_migrations migration + WHERE migration.user_id = assignment.user_id AND migration.relay_host_id = assignment.relay_host_id + AND migration.completed_at IS NULL AND migration.aborted_at IS NULL) AS open_migrations, + (SELECT COALESCE(SUM(lease.request_units),0) FROM relay_assignment_activity_leases lease + WHERE lease.user_id = assignment.user_id AND lease.relay_host_id = assignment.relay_host_id + AND lease.cell_id = assignment.cell_id) AS source_units, + (SELECT COUNT(*) FROM relay_control_capabilities host_capability + JOIN relay_assignment_activity_leases lease ON lease.user_id = host_capability.user_id + AND lease.relay_host_id = host_capability.relay_host_id AND lease.activity_id = host_capability.activity_id + JOIN relay_cell_runtime runtime ON runtime.cell_id = host_capability.cell_id + AND runtime.cell_incarnation = host_capability.cell_incarnation + WHERE host_capability.user_id = assignment.user_id AND host_capability.relay_host_id = assignment.relay_host_id + AND host_capability.cell_id = assignment.cell_id AND host_capability.assignment_epoch = assignment.assignment_epoch + AND host_capability.idle_regional_rehome = 1 AND lease.activity_kind = 'control' + AND lease.activity_id NOT LIKE 'control-pending:%' AND lease.expires_at > ? + AND lease.updated_at >= runtime.started_at) AS capable_controls + FROM relay_assignments assignment LEFT JOIN relay_region_decisions decision + ON decision.user_id = assignment.user_id AND decision.relay_host_id = assignment.relay_host_id`, + [now] + ), + database.query(`SELECT cell.*, region.region, admission.admission_state FROM relay_cells cell + LEFT JOIN relay_cell_regions region ON region.cell_id = cell.cell_id + LEFT JOIN relay_cell_admission admission ON admission.cell_id = cell.cell_id`), + database.query(`SELECT * FROM relay_cell_runtime`), + database.query(`SELECT * FROM relay_cell_capabilities`), + database.query(`SELECT * FROM relay_cell_rehome_safety`), + database.query(`SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'`), + database.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE completed_at IS NULL AND aborted_at IS NULL` + ) + ]) + const byCell = (rows: SqlRow[]) => new Map(rows.map((row) => [String(row.cell_id), row])) + const runtimes = byCell(runtimeRows) + const capabilities = byCell(capabilityRows) + const safety = byCell(safetyRows) + const inventory = byCell(cells) + const control = controls[0] + const cooldown = Number(control?.host_cooldown_ms ?? REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS) + const maxAge = Number(control?.preference_max_age_ms ?? REGION_DECISION_TTL_MS) + const openMigrations = Number(migrations[0]?.count ?? 0) + const counts: Record = {} + const count = (reason: string) => { + counts[reason] = (counts[reason] ?? 0) + 1 + } + const available = (cell: SqlRow): boolean => { + const id = String(cell.cell_id) + const runtime = runtimes.get(id) + const capability = capabilities.get(id) + return ( + Number(cell.enabled) === 1 && + cell.admission_state === 'general' && + cell.region != null && + runtime !== undefined && + Number(runtime.ready) === 1 && + Number(runtime.last_heartbeat_at) > now - input.heartbeatTtlMs && + capability !== undefined && + capability.cell_incarnation === runtime.cell_incarnation && + Number(capability.regional_rehome_protocol) >= 3 + ) + } + for (const host of hosts) { + let reason: string | null = null + const source = inventory.get(String(host.cell_id)) + if (host.generation == null) reason = 'no-verified-decision' + else if (Number(host.expires_at) <= now || Number(host.observed_at) < now - maxAge) + reason = 'expired' + else if ( + Number(host.decision_epoch) !== Number(host.assignment_epoch) || + host.incumbent_region !== source?.region + ) + reason = 'basis-changed' + else if ( + host.outcome !== 'conclusive' || + Number(host.policy_version) !== 1 || + host.preferred_region == null + ) + reason = 'inconclusive-or-insufficient-improvement' + else if (Number(host.cohort_bucket) >= input.cohortPercent) reason = 'outside-cohort' + else if (Number(host.open_migrations) > 0) reason = 'migration-open' + else if (host.last_attempt_at != null && Number(host.last_attempt_at) > now - cooldown) + reason = 'host-cooldown' + else if (!source || !available(source)) reason = 'source-ineligible' + else if (Number(host.capable_controls) === 0) reason = 'source-control-unsupported-or-inactive' + else if ( + !input.cellIsClean(safety.get(String(host.cell_id)), runtimes.get(String(host.cell_id))!, now) + ) + reason = 'source-unclean' + if (reason) { + count(reason) + continue + } + const targets = cells.filter( + (cell) => + cell.cell_id !== host.cell_id && cell.region === host.preferred_region && available(cell) + ) + const clean = targets.filter((cell) => + input.cellIsClean(safety.get(String(cell.cell_id)), runtimes.get(String(cell.cell_id))!, now) + ) + const capacity = clean.filter( + (cell) => + input.connectionHeadroom.get(String(cell.cell_id)) !== false && + Number(cell.reserved_requests) + Number(host.source_units) + 1 <= + Number(cell.capacity_requests) + ) + if (targets.length === 0) count('no-eligible-target') + else if (clean.length === 0) count('target-unclean') + else if (capacity.length === 0) count('no-target-headroom') + else if (input.globalSafetyFailure) count('global-safety-blocked') + else if (openMigrations >= REGIONAL_REHOME_CONCURRENT_LIMIT) count('concurrent-migration-cap') + else count(`eligible:${host.incumbent_region}-to-${host.preferred_region}`) + } + return { + observedAt: now, + newClaimsEnabled: Number(control?.enabled ?? 0) === 1, + cohortPercent: input.cohortPercent, + openMigrations, + availableMigrationSlots: Math.max(0, REGIONAL_REHOME_CONCURRENT_LIMIT - openMigrations), + globalSafetyFailure: input.globalSafetyFailure, + counts + } +} diff --git a/cloud/apps/relay/src/region-correction-restart.test.ts b/cloud/apps/relay/src/region-correction-restart.test.ts new file mode 100644 index 00000000000..02f315ab218 --- /dev/null +++ b/cloud/apps/relay/src/region-correction-restart.test.ts @@ -0,0 +1,119 @@ +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 { RelayAssignmentStore } from './assignment-store.js' +import { openRelayDatabase, type RelayDatabase } from './database.js' + +const identity = { userId: 'restart-test-user', relayHostId: 'abcdefghijklmnop' } +const paths: string[] = [] +const databases = new Set() +afterEach(async () => { + for (const database of databases) await database.close() + databases.clear() + for (const path of paths.splice(0)) await rm(path, { recursive: true, force: true }) +}) + +async function setup() { + const dataDir = await mkdtemp(join(tmpdir(), 'relay-region-restart-')) + paths.push(dataDir) + let now = 1_000_000_000 + const open = async () => { + const database = await openRelayDatabase({ dataDir }) + databases.add(database) + return { database, store: new RelayAssignmentStore(database, () => now) } + } + const first = await open() + const cell = { + id: 'restart-us', + url: 'https://restart-us.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + } + await first.store.reconcileCells([cell]) + await first.store.setCellEnabled(cell.id, true) + await first.store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + region: cell.region, + startedAt: now - 1_000, + ready: true, + observedRequests: 0 + }) + const assignment = await first.store.assign(identity) + const issue = (store: RelayAssignmentStore) => + store.exchangeRegionCorrection(identity, { v: 1, action: 'issue-window' }, assignment.assignmentEpoch) + const window = (await issue(first.store)).window! + const report = { + v: 1 as const, + action: 'report' as const, + generation: window.generation, + assignmentEpoch: window.assignmentEpoch, + policyVersion: 1 as const, + outcome: 'conclusive' as const, + measurements: { 'us-central1': 200, 'asia-east2': 40 } + } + const restart = async () => { + await first.database.close() + databases.delete(first.database) + return open() + } + return { + ...first, window, report, issue, restart, + setNow: (value: number) => { now = value } + } +} + +describe('persisted region decisions across director restart', () => { + it('keeps tombstones and fixed expiry, then invalidates the prior generation after restart', async () => { + const context = await setup() + const epoch = context.window.assignmentEpoch + await context.store.exchangeRegionCorrection(identity, { + v: 1, action: 'report', generation: context.window.generation, + assignmentEpoch: epoch, policyVersion: 1, outcome: 'inconclusive', reason: 'jitter' + }, epoch) + const restarted = await context.restart() + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, epoch)) + .toMatchObject({ reportStatus: 'duplicate' }) + const row = (await restarted.database.query('SELECT * FROM relay_region_decisions'))[0]! + expect(row.outcome).toBe('inconclusive') + expect(Number(row.expires_at)).toBe(context.window.expiresAt) + const successor = (await context.issue(restarted.store)).window! + expect(successor.generation).toBe(context.window.generation + 1) + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, epoch)) + .toMatchObject({ reportStatus: 'stale' }) + }) + + it('uses server expiry after a restart regardless of an old client report', async () => { + const context = await setup() + context.setNow(context.window.expiresAt) + const restarted = await context.restart() + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) + .toMatchObject({ reportStatus: 'expired' }) + expect(await restarted.store.previewRegionCorrection()).toEqual({ expired: 1 }) + }) + + it('does not interpret a persisted future-policy window using the old policy after rollback', async () => { + const context = await setup() + await context.database.query('UPDATE relay_region_decisions SET policy_version = 2') + const restarted = await context.restart() + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) + .toMatchObject({ reportStatus: 'stale' }) + const row = (await restarted.database.query('SELECT * FROM relay_region_decisions'))[0]! + expect(row.outcome).toBe('pending') + expect(row.preferred_region).toBeNull() + expect(row.report_json).toBeNull() + }) + + it('keeps generation ordering when the server clock moves backwards across restart', async () => { + const context = await setup() + context.setNow(1_000_000_000 - 60_000) + const restarted = await context.restart() + const successor = (await context.issue(restarted.store)).window! + expect(successor.generation).toBe(context.window.generation + 1) + expect(successor.expiresAt).toBe(context.window.expiresAt - 60_000) + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) + .toMatchObject({ reportStatus: 'stale' }) + }) +}) diff --git a/cloud/apps/relay/src/region-correction-state.ts b/cloud/apps/relay/src/region-correction-state.ts new file mode 100644 index 00000000000..a1b15520b1c --- /dev/null +++ b/cloud/apps/relay/src/region-correction-state.ts @@ -0,0 +1,158 @@ +import { relayHostLogDigest } from './relay-host-log-digest.js' +import { createHash } from 'node:crypto' +import type { + RegionCorrectionRequest, + RegionCorrectionResponse, + RelayRegion +} from '@orca-cloud/relay-contract' +import type { RelayDatabase } from './database.js' + +type Identity = { userId: string; relayHostId: string } +export const REGION_DECISION_TTL_MS = 24 * 60 * 60_000 +export const REGIONAL_REHOME_CONCURRENT_LIMIT = 8 + +export async function exchangeRegionCorrection( + database: RelayDatabase, + identity: Identity, + request: RegionCorrectionRequest, + assignmentEpoch: number, + now: number +): Promise { + const result: RegionCorrectionResponse = await database.transaction(async (transaction) => { + const assignment = ( + await transaction.queryLocked( + `SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + )[0] + const region = + assignment && + ( + await transaction.query(`SELECT region FROM relay_cell_regions WHERE cell_id = ?`, [ + assignment.cell_id + ]) + )[0] + if (!assignment || !region || Number(assignment.assignment_epoch) !== assignmentEpoch) { + return { v: 1, reportStatus: 'basis-changed' } + } + const prior = ( + await transaction.queryLocked( + `SELECT * FROM relay_region_decisions WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + )[0] + if (request.action === 'issue-window') { + const generation = Number(prior?.generation ?? 0) + 1 + if (!Number.isSafeInteger(generation)) throw new Error('region_generation_exhausted') + const expiresAt = now + REGION_DECISION_TTL_MS + const cohortBucket = + createHash('sha256') + .update(JSON.stringify([identity.userId, identity.relayHostId])) + .digest() + .readUInt32BE(0) % 100 + await transaction.query( + `INSERT INTO relay_region_decisions + (user_id, relay_host_id, generation, expires_at, assignment_epoch, incumbent_region, + policy_version, outcome, preferred_region, observed_at, report_json, cohort_bucket) + VALUES (?, ?, ?, ?, ?, ?, 1, 'pending', NULL, ?, NULL, ?) + ON CONFLICT (user_id, relay_host_id) DO UPDATE SET + generation = excluded.generation, expires_at = excluded.expires_at, + assignment_epoch = excluded.assignment_epoch, incumbent_region = excluded.incumbent_region, + policy_version = 1, outcome = 'pending', preferred_region = NULL, + observed_at = excluded.observed_at, report_json = NULL, cohort_bucket = excluded.cohort_bucket`, + [ + identity.userId, + identity.relayHostId, + generation, + expiresAt, + assignmentEpoch, + region.region, + now, + cohortBucket + ] + ) + return { + v: 1, + window: { + generation, + expiresAt, + assignmentEpoch, + incumbentRegion: region.region as RelayRegion, + policyVersion: 1 + } + } + } + if (!prior || Number(prior.generation) !== request.generation) + return { v: 1, reportStatus: 'stale' } + if (Number(prior.policy_version) !== request.policyVersion) + return { v: 1, reportStatus: 'stale' } + if (Number(prior.expires_at) <= now) return { v: 1, reportStatus: 'expired' } + if ( + request.assignmentEpoch !== assignmentEpoch || + Number(prior.assignment_epoch) !== assignmentEpoch || + prior.incumbent_region !== region.region + ) { + return { v: 1, reportStatus: 'basis-changed' } + } + // The first report wins, including an inconclusive tombstone. + if (prior.outcome !== 'pending') return { v: 1, reportStatus: 'duplicate' } + let preferredRegion: RelayRegion | null = null + if (request.outcome === 'conclusive') { + const incumbent = request.measurements[region.region as RelayRegion] + const target: RelayRegion = region.region === 'us-central1' ? 'asia-east2' : 'us-central1' + const targetRtt = request.measurements[target] + if (incumbent - targetRtt >= 25 && targetRtt <= incumbent * 0.8) preferredRegion = target + } + await transaction.query( + `UPDATE relay_region_decisions SET outcome = ?, preferred_region = ?, report_json = ? + WHERE user_id = ? AND relay_host_id = ? AND generation = ?`, + [ + request.outcome, + preferredRegion, + JSON.stringify(request), + identity.userId, + identity.relayHostId, + request.generation + ] + ) + return { v: 1, reportStatus: 'accepted' } + }) + if (request.action === 'report' && result.reportStatus === 'accepted') { + const digest = relayHostLogDigest(identity.relayHostId) + // Stable sampling includes unchanged hosts for before/after comparisons. + if (Number.parseInt(digest.slice(0, 8), 16) % 10 === 0) { + console.log( + JSON.stringify({ + event: 'orca_relay_region_comparison', + relayHostIdDigest: digest, + assignmentEpoch, + generation: request.generation, + policyVersion: request.policyVersion, + outcome: request.outcome, + ...(request.outcome === 'conclusive' ? { measurements: request.measurements } : {}) + }) + ) + } + } + return result +} + +export async function previewRegionCorrection( + database: RelayDatabase, + now: number +): Promise> { + const rows = await database.query( + `SELECT CASE WHEN decision.expires_at <= ? THEN 'expired' + WHEN decision.assignment_epoch <> assignment.assignment_epoch THEN 'basis-changed' + WHEN decision.outcome = 'pending' THEN 'pending' + WHEN decision.preferred_region IS NULL THEN 'ineligible' + ELSE decision.incumbent_region || '-to-' || decision.preferred_region END AS reason, + COUNT(*) AS count + FROM relay_region_decisions decision + JOIN relay_assignments assignment ON assignment.user_id = decision.user_id + AND assignment.relay_host_id = decision.relay_host_id + GROUP BY reason`, + [now] + ) + return Object.fromEntries(rows.map((row) => [String(row.reason), Number(row.count)])) +} diff --git a/cloud/apps/relay/src/region-correction-store.test.ts b/cloud/apps/relay/src/region-correction-store.test.ts new file mode 100644 index 00000000000..11af7b3c5bd --- /dev/null +++ b/cloud/apps/relay/src/region-correction-store.test.ts @@ -0,0 +1,359 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openInMemoryRelayDatabase, openRelayDatabase, type RelayDatabase } from './database.js' + +const identity = { userId: 'region-correction-test-user', relayHostId: 'abcdefghijklmnop' } +const cells = [ + { + id: 'decision-us', + url: 'https://decision-us.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + }, + { + id: 'decision-asia', + url: 'https://decision-asia.example.test', + region: 'asia-east2' as const, + capacityRequests: 100 + } +] +const incarnations = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' +] +const opened: RelayDatabase[] = [] +afterEach(async () => { + for (const database of opened.splice(0)) { + if (database.dialect === 'postgres') await cleanupPostgres(database) + await database.close() + } +}) + +async function cleanupPostgres(database: RelayDatabase) { + for (const table of [ + 'relay_control_connection_reservations', + 'relay_region_decisions', + 'relay_control_capabilities', + 'relay_assignment_activity_leases', + 'relay_assignment_migrations', + 'relay_assignment_migration_incarnations', + 'relay_assignment_region_preferences', + 'relay_region_rehome_attempts', + 'relay_assignments' + ]) { + await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [identity.userId]) + } + for (const table of [ + 'relay_cell_rehome_safety', + 'relay_cell_capabilities', + 'relay_cell_connection_snapshots', + 'relay_cell_connection_runtime', + 'relay_cell_runtime', + 'relay_cell_connection_limits', + 'relay_cell_admission', + 'relay_cell_regions', + 'relay_cells' + ]) { + await database.query( + `DELETE FROM ${table} WHERE cell_id IN (?, ?)`, + cells.map((cell) => cell.id) + ) + } +} + +async function setup() { + const database = + process.env.ORCA_REGION_CORRECTION_POSTGRES === '1' + ? await openRelayDatabase({ + databaseUrl: requiredPostgresUrl(), + dataDir: '/tmp/orca-region-correction-unused' + }) + : await openInMemoryRelayDatabase() + opened.push(database) + if (database.dialect === 'postgres') await cleanupPostgres(database) + let clock = 100_000_000 + const store = new RelayAssignmentStore(database, () => clock, { + regionalRehomeCohortPercent: 100 + }) + await store.reconcileCells(cells) + for (const cell of cells) await store.setCellEnabled(cell.id, true) + for (const [index, cell] of cells.entries()) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt: clock - 1_000, + ready: true, + observedRequests: 0 + }) + } + const assignment = await store.assign(identity, undefined, 'us-central1') + const activityId = await store.activateControl(identity, { + cellId: cells[0]!.id, + assignmentEpoch: assignment.assignmentEpoch, + generation: 7, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + return { + database, + store, + assignment, + activityId, + now: () => clock, + advance: (ms: number) => { + clock += ms + } + } +} + +function requiredPostgresUrl(): string { + const url = process.env.ORCA_RELAY_TEST_POSTGRES_URL + if (!url || new URL(url).port !== '55440') + throw new Error('PostgreSQL tests require configured port 55440') + return url +} + +async function window(context: Awaited>) { + const result = await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + context.assignment.assignmentEpoch + ) + return result.window! +} + +async function regionalMigration(context: Awaited>) { + const migration = await context.store.startEvacuation(identity, cells[1]!.id) + const attemptId = '33333333-3333-4333-8333-333333333333' + await context.database.query( + `INSERT INTO relay_region_rehome_attempts + (attempt_id,user_id,relay_host_id,preferred_region,source_cell_id,source_cell_incarnation, + target_cell_id,target_cell_incarnation,previous_epoch,assignment_epoch,drain_grace_ms,send_attempts,created_at,updated_at) + VALUES (?,?,?,'asia-east2',?,?,?,?,?,?,60000,1,?,?)`, + [ + attemptId, + identity.userId, + identity.relayHostId, + cells[0]!.id, + incarnations[0], + cells[1]!.id, + incarnations[1], + migration.previousEpoch, + migration.assignmentEpoch, + context.now(), + context.now() + ] + ) + return { migration } +} + +describe('ordered region decisions and migration outcomes', () => { + it('reports aggregate migration lifecycle and reservations without identity disclosure or writes', async () => { + const context = await setup() + const { migration } = await regionalMigration(context) + context.advance(1_000) + const before = await context.database.query('SELECT * FROM relay_region_rehome_attempts') + const outcomes = await context.store.regionCorrectionOutcomes() + expect(outcomes).toEqual([ + expect.objectContaining({ + sourceCellId: cells[0]!.id, + targetCellId: cells[1]!.id, + state: 'registering', + count: 1, + oldestOpenMs: 1_000 + }) + ]) + expect(outcomes[0]!.targetReservedUnits).toBeGreaterThan(0) + expect(JSON.stringify(outcomes)).not.toContain(identity.relayHostId) + expect(JSON.stringify(outcomes)).not.toContain(identity.userId) + expect(await context.database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual( + before + ) + await context.store.activateControl(identity, { + cellId: cells[1]!.id, + assignmentEpoch: migration.assignmentEpoch, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: cells[1]!.id, + assignmentEpoch: migration.assignmentEpoch + }) + expect(await context.store.regionCorrectionOutcomes()).toEqual([ + expect.objectContaining({ state: 'registered' }) + ]) + await context.store.releaseActivity(identity, context.activityId) + expect(await context.store.completeReadyRegionalRehomes()).toBe(1) + expect(await context.store.regionCorrectionOutcomes()).toEqual([ + expect.objectContaining({ + state: 'completed', + targetReservedUnits: 0, + oldestOpenMs: 0 + }) + ]) + }) + + it('supersedes prior windows and keeps an inconclusive tombstone immutable', async () => { + const context = await setup() + const first = await window(context) + const second = await window(context) + expect(second.generation).toBe(first.generation + 1) + const report = { + v: 1 as const, + action: 'report' as const, + assignmentEpoch: first.assignmentEpoch, + policyVersion: 1 as const, + outcome: 'conclusive' as const, + measurements: { 'us-central1': 200, 'asia-east2': 40 } + } + expect( + await context.store.exchangeRegionCorrection( + identity, + { ...report, generation: first.generation }, + first.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'stale' }) + expect( + await context.store.exchangeRegionCorrection( + identity, + { ...report, generation: second.generation, outcome: 'inconclusive', reason: 'jitter' }, + second.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'accepted' }) + expect( + await context.store.exchangeRegionCorrection( + identity, + { ...report, generation: second.generation }, + second.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'duplicate' }) + expect(await context.store.previewRegionCorrection()).toEqual({ ineligible: 1 }) + }) + + it('previews the uncapped fleet without writes, claims, or locked reads', async () => { + const context = await setup() + const query = context.database.query.bind(context.database) + const transaction = context.database.transaction.bind(context.database) + const queryLocked = context.database.queryLocked.bind(context.database) + context.database.query = async (sql, params) => { + expect(sql.trim()).toMatch(/^(SELECT|WITH)/i) + return query(sql, params) + } + context.database.transaction = async () => { + throw new Error('preview_must_not_open_mutating_transaction') + } + context.database.queryLocked = async () => { + throw new Error('preview_must_not_lock') + } + try { + const preview = await context.store.previewRegionalRehomeEligibility() + expect(preview.counts['no-verified-decision']).toBeGreaterThanOrEqual(1) + expect(preview.globalSafetyFailure).toBe('process-safety-unavailable') + expect(JSON.stringify(preview)).not.toContain(identity.relayHostId) + expect(JSON.stringify(preview)).not.toContain(identity.userId) + } finally { + context.database.query = query + context.database.transaction = transaction + context.database.queryLocked = queryLocked + } + }) + + it('allocates distinct ordered generations for concurrent window issuers', async () => { + const context = await setup() + const replies = await Promise.all([window(context), window(context), window(context)]) + expect(replies.map((reply) => reply.generation).sort((a, b) => a - b)).toEqual([1, 2, 3]) + const older = replies.find((reply) => reply.generation === 2)! + expect( + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: older.generation, + assignmentEpoch: older.assignmentEpoch, + policyVersion: 1, + outcome: 'inconclusive', + reason: 'delayed' + }, + older.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'stale' }) + }) + + it('compares with assigned region, preserves hints, and never extends a window on report', async () => { + const context = await setup() + await context.store.assign(identity, 'asia-east2') + const issued = await window(context) + expect(issued.incumbentRegion).toBe('us-central1') + context.advance(50) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.generation, + assignmentEpoch: issued.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 110, 'asia-east2': 90 } + }, + issued.assignmentEpoch + ) + expect(await context.store.previewRegionCorrection()).toEqual({ ineligible: 1 }) + const row = (await context.database.query(`SELECT * FROM relay_region_decisions`))[0]! + expect(Number(row.expires_at)).toBe(issued.expiresAt) + const hint = ( + await context.database.query( + `SELECT preferred_region FROM relay_assignment_region_preferences WHERE user_id = ?`, + [identity.userId] + ) + )[0] + expect(hint?.preferred_region).toBe('asia-east2') + context.advance(24 * 60 * 60_000) + expect( + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.generation, + assignmentEpoch: issued.assignmentEpoch, + policyVersion: 1, + outcome: 'inconclusive', + reason: 'late' + }, + issued.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'expired' }) + }) + + it('rejects stale assignment basis and requires both thresholds', async () => { + const context = await setup() + const issued = await window(context) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.generation, + assignmentEpoch: issued.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 150, 'asia-east2': 100 } + }, + issued.assignmentEpoch + ) + expect(await context.store.previewRegionCorrection()).toEqual({ + 'us-central1-to-asia-east2': 1 + }) + await context.store.startEvacuation(identity, cells[1]!.id) + expect( + await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + issued.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'basis-changed' }) + }) +}) diff --git a/cloud/apps/relay/src/regional-host-drain-app.test.ts b/cloud/apps/relay/src/regional-host-drain-app.test.ts index e2a33a07bb0..cf7e3798155 100644 --- a/cloud/apps/relay/src/regional-host-drain-app.test.ts +++ b/cloud/apps/relay/src/regional-host-drain-app.test.ts @@ -5,7 +5,9 @@ vi.mock('./admin-token-verifier.js', () => ({ createAdminTokenVerifier: () => async (token: string, route?: string) => token === 'deploy-token' || (token === 'monitor-token' && - (!route || route === '/v1/admin/regional-rehome-control')), + (!route || + route === '/v1/admin/regional-rehome-control' || + route === '/v1/admin/regional-rehome-preview')), createReadOnlyAdminTokenVerifier: () => async () => false, createRegionalRehomeControlApplyTokenVerifier: () => async (token: string) => token === 'deploy-token', @@ -41,7 +43,83 @@ const request = { graceMs: 60_000 } +describe('idle regional cutover endpoint', () => { + it('authenticates and fences the source before invoking a cutover', async () => { + const idleRehome = vi.fn(async () => ({ outcome: 'busy' })) + const app = createRelayApp(config(), { + store: {} as never, + assignments: {} as never, + drain: vi.fn(), + idleRehome, + cellIncarnation, + ready: vi.fn(async () => true) + } as Parameters[1]) + const input = { + v: 1, + attemptId: request.attemptId, + userId: request.userId, + relayHostId: request.relayHostId, + sourceCellId: request.sourceCellId, + sourceCellIncarnation: cellIncarnation, + sourceAssignmentEpoch: 7, + sourceGeneration: 1, + targetCellId: 'target-cell', + cohortPercent: 100, + directorSafety: { + observedAt: 100, sqlFailures: 0, reconnects: 0, controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, databasePoolWaitersMax: 0, databasePoolWaitMsMax: 0 + } + } + const path = '/v1/admin/host-idle-rehome' + expect((await postPath(app, path, 'runtime-token', input)).status).toBe(401) + expect( + ( + await postPath(app, path, 'rehome-token', { + ...input, + sourceCellIncarnation: '33333333-3333-4333-8333-333333333333' + }) + ).status + ).toBe(409) + expect(idleRehome).not.toHaveBeenCalled() + const response = await postPath(app, path, 'rehome-token', input) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ v: 1, outcome: 'busy' }) + expect(idleRehome).toHaveBeenCalledExactlyOnceWith(input) + }) +}) + describe('regional host drain endpoint', () => { + it('exposes aggregate preview to monitors without a mutation path', async () => { + const preview = { counts: { 'eligible:asia-east2-to-us-central1': 2 } } + const safety = { observedAt: 100 } + const previewRegionalRehomeEligibility = vi.fn(async () => preview) + const app = createRelayApp(config({ role: 'director', cellId: 'director' }), { + store: {} as never, + assignments: { + previewRegionalRehomeEligibility, + regionCorrectionOutcomes: async () => [] + } as never, + regionalRehomeSafetySnapshot: () => safety as never, + drain: vi.fn(), + ready: vi.fn(async () => true) + }) + const path = '/v1/admin/regional-rehome-preview' + expect((await app.request(path)).status).toBe(401) + expect(previewRegionalRehomeEligibility).not.toHaveBeenCalled() + const response = await app.request(path, { headers: { authorization: 'Bearer monitor-token' } }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ v: 1, preview, outcomes: [] }) + expect(previewRegionalRehomeEligibility).toHaveBeenCalledExactlyOnceWith(safety) + expect( + ( + await app.request(path, { + method: 'POST', + headers: { authorization: 'Bearer deploy-token' } + }) + ).status + ).toBe(404) + }) + it('accepts only the dedicated identity and exact cell generation', async () => { const drainHost = vi.fn(() => 'accepted' as const) const app = createRelayApp(config(), { @@ -60,14 +138,66 @@ describe('regional host drain endpoint', () => { expect((await post(app, 'deploy-token', request)).status).toBe(401) expect( - (await post(app, 'rehome-token', { - ...request, - sourceCellIncarnation: '33333333-3333-4333-8333-333333333333' - })).status + ( + await post(app, 'rehome-token', { + ...request, + sourceCellIncarnation: '33333333-3333-4333-8333-333333333333' + }) + ).status ).toBe(409) expect(drainHost).toHaveBeenCalledOnce() }) + it('waits for an asynchronous drain operation before acknowledging', async () => { + let grant!: (value: 'accepted') => void + let entered!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const drainHost = vi.fn(() => { + entered() + return new Promise<'accepted'>((resolve) => { + grant = resolve + }) + }) + const app = createRelayApp(config(), { + store: {} as never, + assignments: {} as never, + drain: vi.fn(), + drainHost, + cellIncarnation, + ready: vi.fn(async () => true) + }) + const pending = post(app, 'rehome-token', request) + let acknowledged = false + void pending.then(() => { + acknowledged = true + }) + await started + await Promise.resolve() + expect(acknowledged).toBe(false) + grant('accepted') + const response = await pending + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ v: 1, outcome: 'accepted' }) + }) + + it('rejects a failed asynchronous drain instead of acknowledging it', async () => { + const app = createRelayApp(config(), { + store: {} as never, + assignments: {} as never, + drain: vi.fn(), + drainHost: async () => { + throw new Error('activity_cell_not_authoritative') + }, + cellIncarnation, + ready: vi.fn(async () => true) + }) + const response = await post(app, 'rehome-token', request) + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ error: 'activity_cell_not_authoritative' }) + }) + it('rejects malformed identities before touching the session registry', async () => { const drainHost = vi.fn(() => 'accepted' as const) const app = createRelayApp(config(), { @@ -224,7 +354,7 @@ describe('regional rehome director controls', () => { v: 1, cellId: 'production-gce-c7', cellIncarnation, - regionalRehomeProtocol: 1, + regionalRehomeProtocol: 2, safety: { observedAt: 100, sqlFailures: 0, @@ -235,12 +365,7 @@ describe('regional rehome director controls', () => { databasePoolWaitMsMax: 0 } } - const response = await postPath( - app, - '/v1/admin/cell-rehome-status', - 'runtime-token', - body - ) + const response = await postPath(app, '/v1/admin/cell-rehome-status', 'runtime-token', body) expect(response.status).toBe(200) expect(recordCellRegionalRehomeStatus).toHaveBeenCalledWith(body) @@ -266,18 +391,13 @@ describe('regional rehome director controls', () => { v: 1, cellId: 'production-gce-c7', cellIncarnation, - regionalRehomeProtocol: 1, + regionalRehomeProtocol: 2, safety: { ...observability.regionalRehomeRuntimeSafety(), ...emptyPostgresPoolPressureCounts() } } - const response = await postPath( - app, - '/v1/admin/cell-rehome-status', - 'runtime-token', - body - ) + const response = await postPath(app, '/v1/admin/cell-rehome-status', 'runtime-token', body) expect(response.status).toBe(200) expect(recordCellRegionalRehomeStatus).toHaveBeenCalledWith(body) @@ -301,12 +421,14 @@ describe('regional rehome director controls', () => { drain: vi.fn(), ready: vi.fn(async () => true) }) - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - { v: 1, action: 'inspect' } - )).status).toBe(200) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', { + v: 1, + action: 'inspect' + }) + ).status + ).toBe(200) const apply = { v: 1, action: 'apply', @@ -319,39 +441,35 @@ describe('regional rehome director controls', () => { drainGraceMs: 60_000, confirmation: 'ENABLE_REGIONAL_REHOMING' } - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - apply - )).status).toBe(200) + expect( + (await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', apply)).status + ).toBe(200) expect(applyRegionalRehomeControl).toHaveBeenCalledOnce() - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'monitor-token', - { v: 1, action: 'inspect' } - )).status).toBe(200) - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'monitor-token', - apply - )).status).toBe(403) - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - { ...apply, confirmation: 'DISABLE_REGIONAL_REHOMING' } - )).status).toBe(400) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', { + v: 1, + action: 'inspect' + }) + ).status + ).toBe(200) + expect( + (await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', apply)).status + ).toBe(403) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', { + ...apply, + confirmation: 'DISABLE_REGIONAL_REHOMING' + }) + ).status + ).toBe(400) // The per-host cooldown is part of the durable shape an operator must state. const { hostCooldownMs: _omitted, ...withoutCooldown } = apply - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - withoutCooldown - )).status).toBe(400) + expect( + (await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', withoutCooldown)) + .status + ).toBe(400) }) it('probes dedicated trust twice and returns only aggregate proof', async () => { @@ -382,12 +500,11 @@ describe('regional rehome director controls', () => { }) as typeof fetch, ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c7', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c7', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(200) const responseBody = await response.json() @@ -448,12 +565,11 @@ describe('regional rehome director controls', () => { ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c27', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c27', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ proven: true }) @@ -481,12 +597,11 @@ describe('regional rehome director controls', () => { ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c27', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c27', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(409) expect(sourceFetch).not.toHaveBeenCalled() @@ -504,18 +619,17 @@ describe('regional rehome director controls', () => { sourceCellId: 'production-gce-c7', sourceCellIncarnation: cellIncarnation } - expect((await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'monitor-token', - body - )).status).toBe(401) - expect((await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { ...body, unexpected: true } - )).status).toBe(400) + expect( + (await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'monitor-token', body)).status + ).toBe(401) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + ...body, + unexpected: true + }) + ).status + ).toBe(400) }) it('fails closed when the source rejects the dedicated identity', async () => { @@ -529,9 +643,9 @@ describe('regional rehome director controls', () => { regionalRehomeProtocol: 1 } }) - const sourceFetch = vi.fn().mockResolvedValue( - Response.json({ error: 'invalid_token' }, { status: 401 }) - ) + const sourceFetch = vi + .fn() + .mockResolvedValue(Response.json({ error: 'invalid_token' }, { status: 401 })) const app = createRelayApp(config({ role: 'director', cellId: 'director' }), { store: {} as never, assignments: { cellDeploymentStatus } as never, @@ -540,12 +654,11 @@ describe('regional rehome director controls', () => { regionalRehomeFetch: sourceFetch, ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c7', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c7', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(409) expect(sourceFetch).toHaveBeenCalledOnce() diff --git a/cloud/apps/relay/src/regional-rehome-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-postgres.test.ts index fdefda54401..07307707124 100644 --- a/cloud/apps/relay/src/regional-rehome-postgres.test.ts +++ b/cloud/apps/relay/src/regional-rehome-postgres.test.ts @@ -29,6 +29,9 @@ describePostgres('PostgreSQL regional rehoming', () => { }) async function cleanup(): Promise { + for (const table of ['relay_region_decisions', 'relay_control_capabilities']) { + await primary.query(`DELETE FROM ${table} WHERE user_id LIKE 'pg-rehome-user-%'`) + } await primary.query( `DELETE FROM relay_region_rehome_attempts WHERE user_id LIKE 'pg-rehome-user-%'` ) @@ -69,6 +72,81 @@ describePostgres('PostgreSQL regional rehoming', () => { } } + it('defaults to a closed correction cohort even with enabled durable control', async () => { + const context = await fixture() + const closed = new RelayAssignmentStore(primary, context.now, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + expect(await cutover(closed, context.now())).toBeNull() + const preview = await closed.previewRegionalRehomeEligibility({ + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + expect(preview.cohortPercent).toBe(0) + expect(preview.counts['outside-cohort']).toBe(1) + expect(await attemptAndMigrationCounts(context.identity)).toEqual({ + attempts: 0, + migrations: 0 + }) + }) + + it('counts existing generic migrations against the optimization cap and preview', async () => { + const context = await fixture() + const safety = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const before = await context.store.previewRegionalRehomeEligibility(safety) + expect(before.counts['eligible:us-central1-to-asia-east2']).toBe(1) + for (let index = 0; index < 8; index++) { + const identity = { + userId: `pg-rehome-user-budget-${sequence}-${index}`, + relayHostId: `budgethost${String(index).padStart(6, '0')}` + } + await context.store.assign(identity, undefined, 'us-central1') + await context.store.startEvacuation(identity, context.target.id) + } + const preview = await context.store.previewRegionalRehomeEligibility(safety) + expect(preview.openMigrations).toBe(8) + expect(preview.availableMigrationSlots).toBe(0) + expect(preview.counts['concurrent-migration-cap']).toBe(1) + expect(await cutover(context.store, context.now())).toBeNull() + expect(await attemptAndMigrationCounts(context.identity)).toEqual({ + attempts: 0, + migrations: 0 + }) + }) + + it('preview excludes request capacity exhaustion before a claim', async () => { + const context = await fixture() + await primary.query( + `UPDATE relay_cells SET capacity_requests = reserved_requests + 1 WHERE cell_id = ?`, + [context.target.id] + ) + const preview = await context.store.previewRegionalRehomeEligibility({ + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + expect(preview.counts['no-target-headroom']).toBe(1) + expect(await cutover(context.store, context.now())).toBeNull() + }) + it('claims through ambient per-cell sql retry noise', async () => { const context = await fixture() await primary.query( @@ -78,27 +156,31 @@ describePostgres('PostgreSQL regional rehoming', () => { [context.source.id, context.target.id] ) - expect(await context.store.claimRegionalRehome()).not.toBeNull() + expect(await cutover(context.store, context.now())).not.toBeNull() }) it('moves a us-central1 host onto a cell in its preferred asia-east2 region', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() + const attempt = await cutover(context.store, context.now()) expect(attempt).toMatchObject({ preferredRegion: 'asia-east2', sourceCellId: context.source.id, targetCellId: context.target.id }) - expect(await primary.query( - `SELECT preferred_region, source_cell_id, target_cell_id + expect( + await primary.query( + `SELECT preferred_region, source_cell_id, target_cell_id FROM relay_region_rehome_attempts WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ - preferred_region: 'asia-east2', - source_cell_id: context.source.id, - target_cell_id: context.target.id - }]) + [context.identity.userId] + ) + ).toEqual([ + { + preferred_region: 'asia-east2', + source_cell_id: context.source.id, + target_cell_id: context.target.id + } + ]) }) it('moves an asia-east2 host back onto a cell in its preferred us-central1 region', async () => { @@ -107,32 +189,37 @@ describePostgres('PostgreSQL regional rehoming', () => { targetRegion: 'us-central1' }) - const attempt = await context.store.claimRegionalRehome() + const attempt = await cutover(context.store, context.now()) expect(attempt).toMatchObject({ preferredRegion: 'us-central1', sourceCellId: context.source.id, targetCellId: context.target.id }) // The durable attempt row must accept the reverse direction too. - expect(await primary.query( - `SELECT preferred_region, source_cell_id, target_cell_id + expect( + await primary.query( + `SELECT preferred_region, source_cell_id, target_cell_id FROM relay_region_rehome_attempts WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ - preferred_region: 'us-central1', - source_cell_id: context.source.id, - target_cell_id: context.target.id - }]) - expect(await primary.query( - `SELECT cell_id FROM relay_assignments WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ cell_id: context.target.id }]) + [context.identity.userId] + ) + ).toEqual([ + { + preferred_region: 'us-central1', + source_cell_id: context.source.id, + target_cell_id: context.target.id + } + ]) + expect( + await primary.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [ + context.identity.userId + ]) + ).toEqual([{ cell_id: context.target.id }]) }) it('leaves a host whose preference already matches its own region', async () => { const context = await fixture({ preferredRegion: 'us-central1' }) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true @@ -146,16 +233,12 @@ describePostgres('PostgreSQL regional rehoming', () => { it('leaves a host whose preference is older than the configured max age', async () => { const context = await fixture() await primary.query( - `UPDATE relay_assignment_region_preferences SET observed_at = ? + `UPDATE relay_region_decisions SET observed_at = ? WHERE user_id = ? AND relay_host_id = ?`, - [ - context.now() - 24 * 60 * 60_000 - 1, - context.identity.userId, - context.identity.relayHostId - ] + [context.now() - 24 * 60 * 60_000 - 1, context.identity.userId, context.identity.relayHostId] ) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true @@ -190,7 +273,7 @@ describePostgres('PostgreSQL regional rehoming', () => { ] ) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true, @@ -206,7 +289,7 @@ describePostgres('PostgreSQL regional rehoming', () => { `UPDATE relay_region_rehome_attempts SET created_at = ? WHERE user_id = ?`, [context.now() - 3 * 24 * 60 * 60_000, context.identity.userId] ) - await expect(context.store.claimRegionalRehome()).resolves.toMatchObject({ + await expect(cutover(context.store, context.now())).resolves.toMatchObject({ sourceCellId: context.source.id, targetCellId: context.target.id }) @@ -217,7 +300,7 @@ describePostgres('PostgreSQL regional rehoming', () => { // where no later rehome could move it out again. const context = await fixture({ targetProtocol: 0 }) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true @@ -237,119 +320,39 @@ describePostgres('PostgreSQL regional rehoming', () => { [context.target.id] ) - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await cutover(context.store, context.now())).toBeNull() expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 1, enabled: true }) - expect(await primary.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - )).toEqual([{ next_dispatch_at: String(context.now() + 6_000) }]) + expect(await attemptAndMigrationCounts(context.identity)).toEqual({ + attempts: 0, + migrations: 0 + }) }) it('lets only one director claim a host', async () => { const context = await fixture() const claims = await Promise.all([ - context.store.claimRegionalRehome(), - context.competingStore.claimRegionalRehome() + cutover(context.store, context.now()), + cutover(context.competingStore, context.now()) ]) - expect(claims.filter(Boolean)).toHaveLength(1) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_region_rehome_attempts + expect(claims.filter(Boolean).length).toBeGreaterThanOrEqual(1) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_region_rehome_attempts WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '1' }]) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations + [context.identity.userId] + ) + ).toEqual([{ count: '1' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ? AND completed_at IS NULL AND aborted_at IS NULL`, - [context.identity.userId] - )).toEqual([{ count: '1' }]) - }) - - it('serializes an enable with a budget-exhausting failure without retries', async () => { - const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - const locked = Promise.withResolvers() - const release = Promise.withResolvers() - const primaryTransaction = primary.transaction.bind(primary) - const secondaryTransaction = secondary.transaction.bind(secondary) - let enableTransactions = 0 - let failureTransactions = 0 - let enablePid = 0 - let failurePid = 0 - const enableSpy = vi.spyOn(primary, 'transaction').mockImplementation((operation, options) => - primaryTransaction(async (transaction) => { - enableTransactions++ - enablePid = Number((await transaction.query('SELECT pg_backend_pid() AS pid'))[0]!.pid) - return await operation({ - dialect: 'postgres', - query: transaction.query.bind(transaction), - queryLocked: async (sql, params, lockOptions) => { - const rows = await transaction.queryLocked(sql, params, lockOptions) - if (sql.includes('FROM relay_region_rehome_control')) { - locked.resolve() - await release.promise - } - return rows - }, - transaction: transaction.transaction.bind(transaction), - close: transaction.close.bind(transaction) - }) - }, options) - ) - const failureSpy = vi.spyOn(secondary, 'transaction').mockImplementation((operation, options) => - secondaryTransaction(async (transaction) => { - failureTransactions++ - failurePid = Number((await transaction.query('SELECT pg_backend_pid() AS pid'))[0]!.pid) - return await operation(transaction) - }, options) - ) - const enable = context.store.applyRegionalRehomeControl({ - expectedGeneration: 1, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - }) - let failure: Promise | undefined - let outcomes: PromiseSettledResult[] = [] - try { - await Promise.race([ - locked.promise, - enable.then(() => { - throw new Error('enable completed before the control lock') - }) - ]) - failure = context.competingStore.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - // Observe the actual PostgreSQL wait before letting enable acquire the worker row. - await vi.waitFor(async () => { - expect(failurePid).not.toBe(0) - const rows = await primary.query('SELECT pg_blocking_pids(?) AS blockers', [failurePid]) - expect(rows[0]!.blockers).toContain(enablePid) - }, { interval: 10, timeout: 800 }) - } finally { - release.resolve() - outcomes = await Promise.allSettled([enable, ...(failure ? [failure] : [])]) - enableSpy.mockRestore() - failureSpy.mockRestore() - } - expect(outcomes.map((outcome) => outcome.status)).toEqual(['fulfilled', 'fulfilled']) - expect({ enableTransactions, failureTransactions }).toEqual({ - enableTransactions: 1, - failureTransactions: 1 - }) - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: true - }) - expect(await primary.query( - `SELECT consecutive_failures, paused_until FROM relay_region_rehome_worker_state` - )).toEqual([{ consecutive_failures: '1', paused_until: '0' }]) + [context.identity.userId] + ) + ).toEqual([{ count: '1' }]) }) it('increments the disable generation once across competing directors', async () => { @@ -366,24 +369,6 @@ describePostgres('PostgreSQL regional rehoming', () => { }) }) - it('records one receipt across competing directors', async () => { - const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - const receipts = await Promise.all([ - context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted'), - context.competingStore.recordRegionalRehomeDrainReceipt( - attempt!.attemptId, - 'accepted' - ) - ]) - - expect(receipts.sort()).toEqual([false, true]) - expect(await primary.query( - `SELECT drain_outcome FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attempt!.attemptId] - )).toEqual([{ drain_outcome: 'accepted' }]) - }) - it('rechecks a preference changed while the assignment row is locked', async () => { const context = await fixture() let unlock!: () => void @@ -399,9 +384,9 @@ describePostgres('PostgreSQL regional rehoming', () => { await unlockPromise }) await lockedPromise - const claim = context.store.claimRegionalRehome() + const claim = cutover(context.store, context.now()) await primary.query( - `UPDATE relay_assignment_region_preferences SET preferred_region = 'us-central1', + `UPDATE relay_region_decisions SET preferred_region = 'us-central1', observed_at = ? WHERE user_id = ? AND relay_host_id = ?`, [context.now(), context.identity.userId, context.identity.relayHostId] ) @@ -409,23 +394,26 @@ describePostgres('PostgreSQL regional rehoming', () => { await held await expect(claim).resolves.toBeNull() - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '0' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ count: '0' }]) }) it('rechecks fleet safety under locks before mutating a candidate', async () => { const context = await fixture() + const [request] = await context.store.selectIdleRegionalRehomeCandidates(safety(context.now())) + expect(request).toBeDefined() let unlock!: () => void let locked!: () => void const lockedPromise = new Promise((resolve) => (locked = resolve)) const unlockPromise = new Promise((resolve) => (unlock = resolve)) const held = secondary.transaction(async (transaction) => { - await transaction.queryLocked( - `SELECT * FROM relay_cell_rehome_safety WHERE cell_id = ?`, - [context.target.id] - ) + await transaction.queryLocked(`SELECT * FROM relay_cell_rehome_safety WHERE cell_id = ?`, [ + context.target.id + ]) await transaction.query( `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, [context.target.id] @@ -434,62 +422,50 @@ describePostgres('PostgreSQL regional rehoming', () => { await unlockPromise }) await lockedPromise - const claim = context.store.claimRegionalRehome() + const claim = context.store.commitIdleRegionalRehome(request!, safety(context.now())) unlock() await held - await expect(claim).resolves.toBeNull() + await expect(claim).resolves.toEqual({ outcome: 'deferred' }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, enabled: false }) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '0' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ count: '0' }]) }) it('pauses when one required cell exceeds the reconnect limit', async () => { const context = await fixture() - await primary.query( - `UPDATE relay_cell_rehome_safety SET reconnects = ? WHERE cell_id = ?`, - [REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT + 1, context.source.id] - ) + const [request] = await context.store.selectIdleRegionalRehomeCandidates(safety(context.now())) + expect(request).toBeDefined() + await primary.query(`UPDATE relay_cell_rehome_safety SET reconnects = ? WHERE cell_id = ?`, [ + REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT + 1, + context.source.id + ]) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect( + context.store.commitIdleRegionalRehome(request!, safety(context.now())) + ).resolves.toEqual({ outcome: 'deferred' }) await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 2, enabled: false }) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '0' }]) - }) - - it('does not retry a drain against a replacement source incarnation', async () => { - const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - context.advance(31_000) - await heartbeat( - context.store, - context.source, - '33333333-3333-4333-8333-333333333333', - 1, - context.now() - ) - - await expect(context.competingStore.claimRegionalRehome()).resolves.toBeNull() - expect(await primary.query( - `SELECT send_attempts FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attempt!.attemptId] - )).toEqual([{ send_attempts: '1' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ count: '0' }]) }) it('makes concurrent completion and expiry cleanup idempotent', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await cutover(context.store, context.now()) const targetControl = await context.store.activateControl(context.identity, { cellId: context.target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -528,17 +504,18 @@ describePostgres('PostgreSQL regional rehoming', () => { context.competingStore.abortExpiredRegionalRehomes() ]) expect(outcomes).toEqual(expect.arrayContaining([0, 1])) - expect(await primary.query( - `SELECT completed_at IS NOT NULL AS completed, aborted_at IS NOT NULL AS aborted + expect( + await primary.query( + `SELECT completed_at IS NOT NULL AS completed, aborted_at IS NOT NULL AS aborted FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ completed: true, aborted: false }]) + [context.identity.userId] + ) + ).toEqual([{ completed: true, aborted: false }]) }) it('will not complete against a replacement target incarnation', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await cutover(context.store, context.now()) await context.store.activateControl(context.identity, { cellId: context.target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -559,15 +536,17 @@ describePostgres('PostgreSQL regional rehoming', () => { ) await expect(context.store.completeReadyRegionalRehomes()).resolves.toBe(0) - expect(await primary.query( - `SELECT completed_at, aborted_at FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ completed_at: null, aborted_at: null }]) + expect( + await primary.query( + `SELECT completed_at, aborted_at FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ completed_at: null, aborted_at: null }]) }) it('does not roll an unregistered target back to a stale regional source', async () => { const context = await fixture() - await context.store.claimRegionalRehome() + await cutover(context.store, context.now()) context.advance(6 * 60_000) await heartbeat( context.store, @@ -580,16 +559,17 @@ describePostgres('PostgreSQL regional rehoming', () => { await expect(context.store.refreshRegionalRehomeLeases()).resolves.toBe(0) await expect(context.store.abortExpiredEvacuations()).resolves.toBe(0) - expect(await primary.query( - `SELECT cell_id, assignment_epoch FROM relay_assignments WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ cell_id: context.target.id, assignment_epoch: '2' }]) + expect( + await primary.query( + `SELECT cell_id, assignment_epoch FROM relay_assignments WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ cell_id: context.target.id, assignment_epoch: '2' }]) }) it('completes after the drained host re-resolves through the director', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await cutover(context.store, context.now()) // The drain recovery lands while both controls are still live. await context.store.assign(context.identity, 'asia-east2') expect(await controlAccounting(context.identity)).toEqual({ @@ -609,11 +589,13 @@ describePostgres('PostgreSQL regional rehoming', () => { await context.store.releaseActivity(context.identity, context.sourceControl) await expect(context.store.completeReadyRegionalRehomes()).resolves.toBe(1) - expect(await primary.query( - `SELECT completed_at IS NOT NULL AS completed FROM relay_assignment_migrations + expect( + await primary.query( + `SELECT completed_at IS NOT NULL AS completed FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ completed: true }]) + [context.identity.userId] + ) + ).toEqual([{ completed: true }]) expect(await controlAccounting(context.identity)).toEqual({ reservedControls: 1, controlLeases: 1 @@ -622,7 +604,7 @@ describePostgres('PostgreSQL regional rehoming', () => { it('repairs a skewed control counter before completing the rehome', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() + const attempt = await cutover(context.store, context.now()) await context.store.activateControl(context.identity, { cellId: context.target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -634,10 +616,9 @@ describePostgres('PostgreSQL regional rehoming', () => { }) await context.store.releaseActivity(context.identity, context.sourceControl) // Damage already written by a pre-fix sticky grant. - await primary.query( - `UPDATE relay_assignments SET reserved_controls = 0 WHERE user_id = ?`, - [context.identity.userId] - ) + await primary.query(`UPDATE relay_assignments SET reserved_controls = 0 WHERE user_id = ?`, [ + context.identity.userId + ]) await expect(context.store.completeReadyRegionalRehomes()).resolves.toBe(1) expect(await controlAccounting(context.identity)).toEqual({ @@ -646,6 +627,22 @@ describePostgres('PostgreSQL regional rehoming', () => { }) }) + async function cutover(store: RelayAssignmentStore, now: number) { + const [request] = await store.selectIdleRegionalRehomeCandidates(safety(now)) + if (!request) return null + const result = await store.commitIdleRegionalRehome(request, safety(now)) + if (result.outcome !== 'committed') return null + const [attempt] = await primary.query( + `SELECT preferred_region, assignment_epoch FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [request.attemptId] + ) + return { + ...request, + preferredRegion: String(attempt!.preferred_region), + assignmentEpoch: Number(attempt!.assignment_epoch) + } + } + async function attemptAndMigrationCounts(identity: { userId: string relayHostId: string @@ -711,18 +708,12 @@ describePostgres('PostgreSQL regional rehoming', () => { drainGraceMs: 60_000 }) await store.reconcileCells([source, target]) - await heartbeat( - store, - source, - '11111111-1111-4111-8111-111111111111', - 1, - 900_000 - ) + await heartbeat(store, source, '11111111-1111-4111-8111-111111111111', 3, 900_000) await heartbeat( store, target, '22222222-2222-4222-8222-222222222222', - options.targetProtocol ?? 1, + options.targetProtocol ?? 3, 900_000 ) const identity = { @@ -733,9 +724,32 @@ describePostgres('PostgreSQL regional rehoming', () => { const sourceControl = await store.activateControl(identity, { cellId: source.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: '11111111-1111-4111-8111-111111111111' }) await store.assign(identity, preferredRegion) + const issued = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { + 'us-central1': preferredRegion === 'us-central1' ? 50 : 150, + 'asia-east2': preferredRegion === 'asia-east2' ? 50 : 150 + } + }, + assignment.assignmentEpoch + ) return { preferredRegion, store, @@ -753,6 +767,7 @@ describePostgres('PostgreSQL regional rehoming', () => { }) const storeOptions = { + regionalRehomeCohortPercent: 100, requireLiveCells: true, heartbeatTtlMs: 45_000 } @@ -816,3 +831,15 @@ async function heartbeat( } }) } + +function safety(now: number) { + return { + observedAt: now, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } +} diff --git a/cloud/apps/relay/src/regional-rehome-store.test.ts b/cloud/apps/relay/src/regional-rehome-store.test.ts index 662876ef66e..e4a355699da 100644 --- a/cloud/apps/relay/src/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/regional-rehome-store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { - RelayAssignmentStore, + RelayAssignmentStore as BaseRelayAssignmentStore, + type RegionalRehomeAttempt, REGIONAL_REHOME_QUARANTINE_FAILURES, REGIONAL_REHOME_QUARANTINE_MS, REGIONAL_REHOME_REDRAIN_SEND_LIMIT @@ -37,14 +38,115 @@ const sourceIncarnation = '11111111-1111-4111-8111-111111111111' const targetIncarnation = '22222222-2222-4222-8222-222222222222' describe('regional rehome assignment state', () => { + it('advances past a full candidate page whose destination lacks capacity', async () => { + const context = await setup() + for (let i = 0; i < 10; i++) { + await activatePreferredSource(context, { + userId: `blocked-${i}`, + relayHostId: 'abcdefghijklmnop' + }) + } + context.advance(1) + const reverse = { userId: 'healthy-reverse', relayHostId: 'abcdefghijklmnop' } + await activateReversePreferredSource(context, reverse) + await context.database.query( + 'UPDATE relay_cells SET capacity_requests = reserved_requests WHERE cell_id = ?', + [target.id] + ) + expect(await context.store.tryIdleRehome()).toMatchObject({ + userId: reverse.userId, + sourceCellId: target.id, + targetCellId: source.id + }) + await context.database.close() + }) + + it('defaults optional correction off even with enabled durable control', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'cohort', relayHostId: 'abcdefghijklmnop' }) + const defaultStore = new IdleRehomeTestStore(context.database, context.now, { + requireLiveCells: true + }) + expect(await defaultStore.tryIdleRehome()).toBeNull() + expect( + await context.database.query('SELECT attempt_id FROM relay_region_rehome_attempts') + ).toEqual([]) + expect(await context.store.tryIdleRehome()).not.toBeNull() + await context.database.close() + }) + + it('counts pre-existing generic migrations against the eight-migration cap', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'cap', relayHostId: 'abcdefghijklmnop' }) + for (let i = 0; i < 8; i++) { + await context.database.query( + `INSERT INTO relay_assignment_migrations + (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch, + source_request_units, target_reserved_units, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, 2, 1, 1, ?, ?, ?)`, + [ + 'generic', + `synthetic-migration-${i}`, + source.id, + target.id, + context.now() + 60_000, + context.now(), + context.now() + ] + ) + } + expect(await context.store.tryIdleRehome()).toBeNull() + await context.database.query( + `UPDATE relay_assignment_migrations SET completed_at = ? + WHERE user_id = 'generic' AND relay_host_id = 'synthetic-migration-0'`, + [context.now()] + ) + expect(await context.store.tryIdleRehome()).not.toBeNull() + const open = await context.database + .query(`SELECT COUNT(*) AS count FROM relay_assignment_migrations + WHERE completed_at IS NULL AND aborted_at IS NULL`) + expect(Number(open[0]?.count)).toBe(8) + await context.database.close() + }) + + it('does not let legacy hints certify a move', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'legacy', relayHostId: 'abcdefghijklmnop' }) + await context.database.query('DELETE FROM relay_region_decisions') + expect(await context.store.tryIdleRehome()).toBeNull() + await context.database.close() + }) + + it('refreshes later open attempts when an older attempt occupies the first page', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'page-1', relayHostId: 'abcdefghijklmnop' }) + const first = await context.store.tryIdleRehome() + context.advance(10_000) + await freshHeartbeats(context) + await activatePreferredSource(context, { userId: 'page-2', relayHostId: 'abcdefghijklmnop' }) + const second = await context.store.tryIdleRehome() + expect(second).not.toBeNull() + context.advance(1_000) + expect(await context.store.refreshRegionalRehomeLeases(1)).toBe(1) + context.advance(1_000) + expect(await context.store.refreshRegionalRehomeLeases(1)).toBe(1) + const rows = await context.database.query( + `SELECT attempt_id, updated_at FROM relay_region_rehome_attempts + WHERE attempt_id = ?`, + [second!.attemptId] + ) + expect(Number(rows[0]?.updated_at)).toBe(context.now()) + await context.database.close() + }) + it('does not open a transaction while the worker is disabled', async () => { const delegate = await openInMemoryRelayDatabase() const database = new TransactionCountingDatabase(delegate) - const store = new RelayAssignmentStore(database, () => 1_000_000) + const store = new IdleRehomeTestStore(database, () => 1_000_000) await store.inspectRegionalRehomeControl() database.transactionCalls = 0 - await expect(store.claimRegionalRehome()).resolves.toBeNull() + await expect(store.tryIdleRehome()).resolves.toBeNull() expect(database.transactionCalls).toBe(0) await database.close() }) @@ -52,9 +154,9 @@ describe('regional rehome assignment state', () => { it('initializes a missing control row without opening a transaction', async () => { const delegate = await openInMemoryRelayDatabase() const database = new TransactionCountingDatabase(delegate) - const store = new RelayAssignmentStore(database, () => 1_000_000) + const store = new IdleRehomeTestStore(database, () => 1_000_000) - await expect(store.claimRegionalRehome()).resolves.toBeNull() + await expect(store.tryIdleRehome()).resolves.toBeNull() expect(database.transactionCalls).toBe(0) await expect(store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 0, @@ -75,24 +177,28 @@ describe('regional rehome assignment state', () => { generation: 2, enabled: false }) - await expect(context.store.applyRegionalRehomeControl({ - expectedGeneration: 1, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - })).rejects.toThrow('regional_rehome_generation_mismatch') - await expect(context.store.applyRegionalRehomeControl({ - expectedGeneration: 2, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - })).resolves.toMatchObject({ generation: 3, enabled: true }) + await expect( + context.store.applyRegionalRehomeControl({ + expectedGeneration: 1, + enabled: true, + notBefore: context.now(), + ratePerMinute: 10, + preferenceMaxAgeMs: 24 * 60 * 60_000, + hostCooldownMs: 7 * 24 * 60 * 60_000, + drainGraceMs: 60_000 + }) + ).rejects.toThrow('regional_rehome_generation_mismatch') + await expect( + context.store.applyRegionalRehomeControl({ + expectedGeneration: 2, + enabled: true, + notBefore: context.now(), + ratePerMinute: 10, + preferenceMaxAgeMs: 24 * 60 * 60_000, + hostCooldownMs: 7 * 24 * 60 * 60_000, + drainGraceMs: 60_000 + }) + ).resolves.toMatchObject({ generation: 3, enabled: true }) await context.database.close() }) @@ -103,7 +209,7 @@ describe('regional rehome assignment state', () => { const sourceControl = await activatePreferredSource(context, identity) await activateSource(context, neighbor) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(attempt).toMatchObject({ userId: identity.userId, relayHostId: identity.relayHostId, @@ -118,11 +224,11 @@ describe('regional rehome assignment state', () => { expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id }) expect(await context.store.completeReadyRegionalRehomes()).toBe(0) expect( - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - ).toBe(true) - expect( - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - ).toBe(false) + await context.database.query( + 'SELECT drain_outcome FROM relay_region_rehome_attempts WHERE attempt_id = ?', + [attempt!.attemptId] + ) + ).toEqual([{ drain_outcome: 'accepted' }]) const targetControl = await context.store.activateControl(identity, { cellId: target.id, @@ -142,23 +248,27 @@ describe('regional rehome assignment state', () => { assignmentEpoch: 2 }) expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id }) - expect(await context.database.query( - `SELECT completed_at, aborted_at FROM relay_assignment_migrations + expect( + await context.database.query( + `SELECT completed_at, aborted_at FROM relay_assignment_migrations WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - )).toEqual([{ completed_at: context.now(), aborted_at: null }]) - expect(await context.database.query( - `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` - )).toEqual([{ completed_at: context.now(), aborted_at: null }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ completed_at: context.now(), aborted_at: null }]) + expect( + await context.database.query( + `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` + ) + ).toEqual([{ completed_at: context.now(), aborted_at: null }]) expect(targetControl).toMatch(/^control:/) await context.database.close() }) - it('completes from durable activity when the drain response was lost', async () => { + it('completes from durable activity with the source-owned receipt', async () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -171,10 +281,12 @@ describe('regional rehome assignment state', () => { await context.store.releaseActivity(identity, sourceControl) expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - expect(await context.database.query( - `SELECT drain_receipt_at, completed_at, aborted_at + expect( + await context.database.query( + `SELECT drain_receipt_at, completed_at, aborted_at FROM relay_region_rehome_attempts` - )).toEqual([{ drain_receipt_at: null, completed_at: context.now(), aborted_at: null }]) + ) + ).toEqual([{ drain_receipt_at: context.now(), completed_at: context.now(), aborted_at: null }]) await context.database.close() }) @@ -184,23 +296,22 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) it('fails fleet safety closed until source and target telemetry is fresh', async () => { const context = await setup() - await context.database.query( - `DELETE FROM relay_cell_rehome_safety WHERE cell_id = ?`, - [target.id] - ) + await context.database.query(`DELETE FROM relay_cell_rehome_safety WHERE cell_id = ?`, [ + target.id + ]) expect(await context.store.regionalRehomeFleetSafety()).toMatchObject({ requiredCells: 2, missingCells: 1, observedAt: 0 }) - await heartbeat(context.store, source, sourceIncarnation, 1, 2, { + await heartbeat(context.store, source, sourceIncarnation, 3, 2, { observedAt: context.now(), sqlFailures: 0, reconnects: 2, @@ -209,7 +320,7 @@ describe('regional rehome assignment state', () => { databasePoolWaitersMax: 0, databasePoolWaitMsMax: 0 }) - await heartbeat(context.store, target, targetIncarnation, 1, 2, { + await heartbeat(context.store, target, targetIncarnation, 3, 2, { observedAt: context.now(), sqlFailures: 1, reconnects: 3, @@ -237,7 +348,7 @@ describe('regional rehome assignment state', () => { requiredCells: 1, missingCells: 0 }) - await heartbeat(context.store, target, targetIncarnation, 1, 2) + await heartbeat(context.store, target, targetIncarnation, 3, 2) expect(await context.store.regionalRehomeFleetSafety()).toMatchObject({ requiredCells: 2, missingCells: 0 @@ -256,14 +367,14 @@ describe('regional rehome assignment state', () => { databasePoolWaitersMax: 2, databasePoolWaitMsMax: 1 } - await heartbeat(context.store, source, sourceIncarnation, 1, 2, baseline) - await heartbeat(context.store, target, targetIncarnation, 1, 2, baseline) + await heartbeat(context.store, source, sourceIncarnation, 3, 2, baseline) + await heartbeat(context.store, target, targetIncarnation, 3, 2, baseline) await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) - expect(await context.store.claimRegionalRehome()).toMatchObject({ + expect(await context.store.tryIdleRehome()).toMatchObject({ sourceCellId: source.id, targetCellId: target.id }) @@ -279,6 +390,17 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const safety: RegionalRehomeSafetySnapshot = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(safety) + expect(candidate).toBeDefined() await context.database.query( `UPDATE relay_cell_rehome_safety SET reconnects = 251 WHERE cell_id = ?`, [source.id] @@ -286,7 +408,9 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ + outcome: 'deferred' + }) } finally { warnings.restore() } @@ -306,6 +430,17 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const safety: RegionalRehomeSafetySnapshot = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(safety) + expect(candidate).toBeDefined() await context.database.query( `UPDATE relay_cell_rehome_safety SET database_pool_waiters_max = 17 WHERE cell_id = ?`, [target.id] @@ -313,9 +448,11 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ + outcome: 'deferred' + }) // Already disabled: the next tick returns before the gate and stays silent. - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } @@ -339,7 +476,7 @@ describe('regional rehome assignment state', () => { `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT}` ) - expect(await context.store.claimRegionalRehome()).not.toBeNull() + expect(await context.store.tryIdleRehome()).not.toBeNull() await context.database.close() }) @@ -348,7 +485,7 @@ describe('regional rehome assignment state', () => { const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } await activateReversePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(attempt).toMatchObject({ userId: identity.userId, relayHostId: identity.relayHostId, @@ -366,11 +503,13 @@ describe('regional rehome assignment state', () => { `SELECT preferred_region, source_cell_id, target_cell_id FROM relay_region_rehome_attempts` ) - ).toEqual([{ - preferred_region: 'us-central1', - source_cell_id: target.id, - target_cell_id: source.id - }]) + ).toEqual([ + { + preferred_region: 'us-central1', + source_cell_id: target.id, + target_cell_id: source.id + } + ]) expect(await context.store.resolve(identity)).toMatchObject({ cellId: source.id }) await context.database.close() }) @@ -389,21 +528,19 @@ describe('regional rehome assignment state', () => { const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } expect(warnings.entries).toEqual([]) expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) ).toEqual([{ next_dispatch_at: 0 }]) expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) - it('names the skip when the last target is lost between scan and claim', async () => { + it('does not migrate when the last target is lost between selection and commit', async () => { const database = await openInMemoryRelayDatabase() const context = await setup({ database, @@ -419,15 +556,8 @@ describe('regional rehome assignment state', () => { relayHostId: 'abcdefghijklmnop' }) - const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - warnings.restore() - } - expect(warnings.entries).toMatchObject([ - { skips: [{ reason: 'no_eligible_target', candidates: 1 }] } - ]) + expect(await context.store.tryIdleRehome()).toBeNull() + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 1, enabled: true @@ -444,11 +574,11 @@ describe('regional rehome assignment state', () => { // really does scan and the cooldown is the only thing holding this host. context.advance(10_000) // The desktop's region probe now says us-central1 again. - await context.store.assign(identity, 'us-central1') + await activateReversePreferredSource(context, identity) const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } @@ -462,9 +592,9 @@ describe('regional rehome assignment state', () => { cellId: target.id, expiresAt: context.now() + 90_000 }) - await context.store.assign(identity, 'us-central1') + await activateReversePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(attempt).toMatchObject({ preferredRegion: 'us-central1', sourceCellId: target.id, @@ -502,15 +632,8 @@ describe('regional rehome assignment state', () => { }) await activatePreferredSource(context, identity) - const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - warnings.restore() - } - expect(warnings.entries).toMatchObject([ - { skips: [{ reason: 'host_cooldown', candidates: 1 }] } - ]) + expect(await context.store.tryIdleRehome()).toBeNull() + expect(await database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await database.close() }) @@ -527,15 +650,13 @@ describe('regional rehome assignment state', () => { const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } expect(warnings.entries).toEqual([]) expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) ).toEqual([{ next_dispatch_at: 0 }]) expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() @@ -558,37 +679,16 @@ describe('regional rehome assignment state', () => { [target.id] ) - const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - warnings.restore() - } + expect(await context.store.tryIdleRehome()).toBeNull() expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 1, enabled: true }) - // The skip is visible and named, and both candidates blocked by the one - // unclean cell accumulate into a single entry. - expect(warnings.entries).toMatchObject([ - { - skips: [ - { - reason: 'target_unclean', - cellId: target.id, - sqlFailures: REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + 1, - candidates: 2 - } - ] - } - ]) - // A skipped tick is charged the dispatch interval: candidate scans stay - // rate-limited even when nothing claims. + + // Read-only selection does not spend the commit rate budget. expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) - ).toEqual([{ next_dispatch_at: context.now() + 6_000 }]) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) + ).toEqual([{ next_dispatch_at: 0 }]) expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) @@ -598,15 +698,13 @@ describe('regional rehome assignment state', () => { const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } expect(warnings.entries).toEqual([]) expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) ).toEqual([{ next_dispatch_at: 0 }]) await context.database.close() }) @@ -617,12 +715,25 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const safety: RegionalRehomeSafetySnapshot = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(safety) + expect(candidate).toBeDefined() await context.database.query( `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, [target.id] ) - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ + outcome: 'deferred' + }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, enabled: false @@ -631,80 +742,6 @@ describe('regional rehome assignment state', () => { await context.database.close() }) - it('rechecks locked fleet safety before retrying a drain dispatch', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - expect(await context.store.claimRegionalRehome()).not.toBeNull() - context.advance(31_000) - await context.database.query( - `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, - [target.id] - ) - - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: false - }) - await context.database.close() - }) - - it('latches off after three dispatch failures and resumes only through CAS', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - const first = await context.store.claimRegionalRehome() - for (let index = 0; index < 3; index++) { - await context.store.recordRegionalRehomeDispatchFailure(first!.attemptId) - } - context.advance(5 * 60_000 - 1) - expect(await context.store.claimRegionalRehome()).toBeNull() - context.advance(1) - await heartbeat(context.store, source, sourceIncarnation, 1, 2, { - observedAt: context.now(), - sqlFailures: 0, - reconnects: 0, - controlActivityRecoveryFailures: 0, - databasePoolWaiting: 0, - databasePoolWaitersMax: 0, - databasePoolWaitMsMax: 0 - }) - await heartbeat(context.store, target, targetIncarnation, 1, 2, { - observedAt: context.now(), - sqlFailures: 0, - reconnects: 0, - controlActivityRecoveryFailures: 0, - databasePoolWaiting: 0, - databasePoolWaitersMax: 0, - databasePoolWaitMsMax: 0 - }) - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: false - }) - await context.store.applyRegionalRehomeControl({ - expectedGeneration: 2, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - }) - const retry = await context.store.claimRegionalRehome() - expect(retry).toMatchObject({ attemptId: first!.attemptId, sendAttempts: 2 }) - expect(await context.database.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations` - )).toEqual([{ count: 1 }]) - await context.database.close() - }) - it('refreshes only the migration leases while source splices drain', async () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } @@ -714,7 +751,7 @@ describe('regional rehome assignment state', () => { kind: 'splice', cellId: source.id }) - await context.store.claimRegionalRehome() + await context.store.tryIdleRehome() const before = await context.database.query( `SELECT activity_id, expires_at FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? ORDER BY activity_id`, @@ -743,19 +780,21 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } await activatePreferredSource(context, identity) - await context.store.claimRegionalRehome() + await context.store.tryIdleRehome() context.advance(6 * 60_000) expect(await context.store.refreshRegionalRehomeLeases()).toBe(0) - await heartbeat(context.store, source, sourceIncarnation, 1, 2) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) expect(await context.store.abortExpiredEvacuations()).toBe(1) - expect(await context.store.reapRegionalRehomeAttempts()).toBe(1) + expect(await context.store.reapRegionalRehomeAttempts()).toBe(0) expect(await context.store.resolve(identity)).toMatchObject({ cellId: source.id, assignmentEpoch: 3 }) - expect(await context.database.query( - `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` - )).toEqual([{ completed_at: null, aborted_at: context.now() }]) + expect( + await context.database.query( + `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` + ) + ).toEqual([{ completed_at: null, aborted_at: context.now() }]) await context.database.close() }) @@ -763,9 +802,9 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } await activatePreferredSource(context, identity) - await context.store.claimRegionalRehome() + await context.store.tryIdleRehome() context.advance(6 * 60_000) - await heartbeat(context.store, target, targetIncarnation, 1, 2) + await heartbeat(context.store, target, targetIncarnation, 3, 2) expect(await context.store.refreshRegionalRehomeLeases()).toBe(0) expect(await context.store.abortExpiredEvacuations()).toBe(0) @@ -779,41 +818,6 @@ describe('regional rehome assignment state', () => { await context.database.close() }) - it('skips a rehome dispatch tick on a contended cell inventory', async () => { - const probe = new CellInventoryLockProbe() - const context = await setup({ wrap: (database) => probe.wrap(database) }) - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - probe.reset() - probe.failNoWait = true - const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') - - let attempt: unknown - try { - attempt = await context.store.claimRegionalRehome() - } finally { - busy.restore() - } - - expect(attempt).toBeNull() - expect(probe.locks).not.toEqual([]) - expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) - expect(busy.entries).toEqual([ - { - event: 'orca_relay_sweep_cell_inventory_busy', - sweep: 'claim-regional-rehome', - skipped: 1 - } - ]) - - probe.failNoWait = false - expect(await context.store.claimRegionalRehome()).toMatchObject({ - sourceCellId: source.id, - targetCellId: target.id - }) - await context.database.close() - }) - // Why: the redrain lane reaches the inventory through the fleet-safety read // rather than through candidate selection, so it needs its own coverage. // Why: one contended candidate must cost its own tick, not the whole page. The @@ -830,8 +834,7 @@ describe('regional rehome assignment state', () => { context.advance(60_000) await freshHeartbeats(context) const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -881,8 +884,7 @@ describe('regional rehome assignment state', () => { context.advance(60_000) await freshHeartbeats(context) const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -927,9 +929,7 @@ describe('regional rehome assignment state', () => { const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') try { - await expect(context.store.claimRegionalRehome()).rejects.toThrow( - 'relay_capacity_exhausted' - ) + await expect(context.store.tryIdleRehome()).rejects.toThrow('relay_capacity_exhausted') } finally { busy.restore() } @@ -940,87 +940,13 @@ describe('regional rehome assignment state', () => { // Why: the transaction dies at the first contended candidate, so every // candidate behind it is abandoned too. Reporting one would understate the tick. - it('reports every candidate the contended tick abandoned', async () => { - const probe = new CellInventoryLockProbe() - const context = await setup({ wrap: (database) => probe.wrap(database) }) - await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) - await activatePreferredSource(context, { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' }) - await activatePreferredSource(context, { userId: 'user-3', relayHostId: 'aaaabbbbccccdddd' }) - probe.reset() - probe.failNoWait = true - const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') - - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - busy.restore() - } - - expect(busy.entries).toEqual([ - { - event: 'orca_relay_sweep_cell_inventory_busy', - sweep: 'claim-regional-rehome', - skipped: 3 - } - ]) - await context.database.close() - }) - - it('skips a redrain tick on a contended cell inventory', async () => { - const probe = new CellInventoryLockProbe() - const context = await setup({ wrap: (database) => probe.wrap(database) }) - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - probe.reset() - probe.failNoWait = true - const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') - - let redrain: unknown - try { - redrain = await context.store.claimRegionalRehome() - } finally { - busy.restore() - } - - expect(redrain).toBeNull() - expect(probe.locks).not.toEqual([]) - expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) - expect(busy.entries).toEqual([ - { - event: 'orca_relay_sweep_cell_inventory_busy', - sweep: 'claim-regional-rehome', - skipped: 1 - } - ]) - - probe.failNoWait = false - expect(await context.store.claimRegionalRehome()).toMatchObject({ - attemptId: attempt!.attemptId, - sendAttempts: 2 - }) - await context.database.close() - }) it('skips a completion tick on a contended cell inventory without quarantining it', async () => { const probe = new CellInventoryLockProbe() const context = await setup({ wrap: (database) => probe.wrap(database) }) const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -1069,8 +995,7 @@ describe('regional rehome assignment state', () => { const context = await setup({ wrap: (database) => probe.wrap(database) }) const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -1083,7 +1008,7 @@ describe('regional rehome assignment state', () => { await context.store.releaseActivity(identity, sourceControl) await context.store.releaseActivity(identity, targetControl) context.advance(24 * 60 * 60_000) - await heartbeat(context.store, source, sourceIncarnation, 1, 2) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) probe.reset() probe.failNoWait = true const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') @@ -1118,11 +1043,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt( - attempt!.attemptId, - 'accepted' - ) + const attempt = await context.store.tryIdleRehome() const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -1135,7 +1056,7 @@ describe('regional rehome assignment state', () => { await context.store.releaseActivity(identity, sourceControl) await context.store.releaseActivity(identity, targetControl) context.advance(24 * 60 * 60_000) - await heartbeat(context.store, source, sourceIncarnation, 1, 2) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) expect(await context.store.abortExpiredRegionalRehomes()).toBe(1) expect(await context.store.resolve(identity)).toMatchObject({ cellId: source.id, @@ -1144,151 +1065,21 @@ describe('regional rehome assignment state', () => { await context.database.close() }) - it('redrains a receipted dual-homed attempt once its grace elapses', async () => { - const context = await setup() - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - - // Before grace elapses a receipted attempt is not re-dispatched. - context.advance(30 * 60_000) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - - context.advance(30 * 60_000 + 1) - await freshHeartbeats(context) - const redrain = await context.store.claimRegionalRehome() - expect(redrain).toMatchObject({ - attemptId: attempt!.attemptId, - drainGraceMs: 0, - sendAttempts: 2 - }) - // The per-dispatch receipt replaces the original without a mismatch. - await expect( - context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'host-not-connected') - ).resolves.toBe(true) - - // Redrains are spaced: nothing new inside the redrain interval. - context.advance(30_000) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - context.advance(30_001) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toMatchObject({ - attemptId: attempt!.attemptId, - drainGraceMs: 0, - sendAttempts: 3 - }) - - // Once the host actually leaves the source, completion wins over redrain. - await context.store.releaseActivity(identity, sourceControl) - context.advance(60_001) - await freshHeartbeats(context) - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 2 - }) - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - await context.database.close() - }) - - it('resets the failure budget on a repeated redrain receipt outcome', async () => { - const context = await setup() - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toMatchObject({ - attemptId: attempt!.attemptId, - drainGraceMs: 0 - }) - // The repeated outcome still proves the source answered. - expect( - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - ).toBe(false) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 1, - enabled: true - }) - await context.database.close() - }) - - it('does not redrain before the target registers or when the fleet is unsafe', async () => { - const context = await setup() - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - - // Past grace but the target never registered: force-closing the source - // would disconnect the host with nowhere proven to land. - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - await context.database.query( - `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, - [target.id] - ) - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - enabled: false - }) - await context.database.close() - }) - it('completes healthy candidates past a poisoned attempt and logs it', async () => { const context = await setup() const poisoned = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const healthy = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } const poisonedSource = await activatePreferredSource(context, poisoned) const healthySource = await activatePreferredSource(context, healthy) - const first = await context.store.claimRegionalRehome() + const first = await context.store.tryIdleRehome() context.advance(6_000) - const second = await context.store.claimRegionalRehome() + const second = await context.store.tryIdleRehome() expect(first!.userId).toBe(poisoned.userId) expect(second!.userId).toBe(healthy.userId) for (const [identity, attempt, sourceControl] of [ [poisoned, first, poisonedSource], [healthy, second, healthySource] ] as const) { - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1320,10 +1111,12 @@ describe('regional rehome assignment state', () => { reason: 'regional_rehome_assignment_mismatch' } ]) - expect(await context.database.query( - `SELECT completed_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [second!.attemptId] - )).toEqual([{ completed_at: context.now() }]) + expect( + await context.database.query( + `SELECT completed_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [second!.attemptId] + ) + ).toEqual([{ completed_at: context.now() }]) await context.database.close() }) @@ -1331,8 +1124,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const poisoned = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const source1 = await activatePreferredSource(context, poisoned) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(poisoned, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1363,9 +1155,7 @@ describe('regional rehome assignment state', () => { expect(warnings.entries).toHaveLength(REGIONAL_REHOME_QUARANTINE_FAILURES + 1) // A free-form error (never a slug) reaches the log only as 'redacted'. expect( - warnings.entries.every( - (entry) => entry.reason === 'regional_rehome_assignment_mismatch' - ) + warnings.entries.every((entry) => entry.reason === 'regional_rehome_assignment_mismatch') ).toBe(true) context.advance(REGIONAL_REHOME_QUARANTINE_MS + 1) const database = context.database @@ -1393,14 +1183,13 @@ describe('regional rehome assignment state', () => { const healthy = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } const poisonedSource = await activatePreferredSource(context, poisoned) const healthySource = await activatePreferredSource(context, healthy) - const first = await context.store.claimRegionalRehome() + const first = await context.store.tryIdleRehome() context.advance(6_000) - const second = await context.store.claimRegionalRehome() + const second = await context.store.tryIdleRehome() for (const [identity, attempt, sourceControl] of [ [poisoned, first, poisonedSource], [healthy, second, healthySource] ] as const) { - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1434,10 +1223,12 @@ describe('regional rehome assignment state', () => { reason: 'regional_rehome_assignment_mismatch' } ]) - expect(await context.database.query( - `SELECT aborted_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [second!.attemptId] - )).toEqual([{ aborted_at: context.now() }]) + expect( + await context.database.query( + `SELECT aborted_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [second!.attemptId] + ) + ).toEqual([{ aborted_at: context.now() }]) await context.database.close() }) @@ -1445,7 +1236,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(await controlAccounting(context, identity)).toEqual({ reservedControls: 2, controlLeases: 2 @@ -1475,11 +1266,13 @@ describe('regional rehome assignment state', () => { }) expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - expect(await context.database.query( - `SELECT completed_at FROM relay_assignment_migrations + expect( + await context.database.query( + `SELECT completed_at FROM relay_assignment_migrations WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - )).toEqual([{ completed_at: context.now() }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ completed_at: context.now() }]) expect(await cellReservations(context)).toEqual({ [source.id]: 0, [target.id]: 1 }) await context.database.close() }) @@ -1488,7 +1281,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1503,11 +1296,13 @@ describe('regional rehome assignment state', () => { ) await context.store.assign(identity, 'asia-east2') - expect(await context.database.query( - `SELECT activity_id FROM relay_assignment_activity_leases + expect( + await context.database.query( + `SELECT activity_id FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? AND activity_kind = 'control'`, - [identity.userId, identity.relayHostId] - )).toEqual([{ activity_id: `control:${target.id}:1` }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ activity_id: `control:${target.id}:1` }]) expect(await cellReservations(context)).toEqual({ [source.id]: 0, [target.id]: 2 }) await context.database.close() }) @@ -1516,7 +1311,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1539,11 +1334,13 @@ describe('regional rehome assignment state', () => { reservedControls: 1, controlLeases: 1 }) - expect(await context.database.query( - `SELECT migration_leases FROM relay_assignments + expect( + await context.database.query( + `SELECT migration_leases FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - )).toEqual([{ migration_leases: 0 }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ migration_leases: 0 }]) await context.database.close() }) @@ -1553,9 +1350,9 @@ describe('regional rehome assignment state', () => { const clean = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } const skewedSource = await activatePreferredSource(context, skewed) const cleanSource = await activatePreferredSource(context, clean) - const first = await context.store.claimRegionalRehome() + const first = await context.store.tryIdleRehome() context.advance(6_000) - const second = await context.store.claimRegionalRehome() + const second = await context.store.tryIdleRehome() for (const [identity, attempt, sourceControl] of [ [skewed, first, skewedSource], [clean, second, cleanSource] @@ -1599,7 +1396,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1646,7 +1443,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1684,102 +1481,6 @@ describe('regional rehome assignment state', () => { ]) await context.database.close() }) - - it('caps redrain dispatches at the send limit', async () => { - const context = await setup() - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - await context.database.query( - `UPDATE relay_region_rehome_attempts SET send_attempts = ? WHERE attempt_id = ?`, - [REGIONAL_REHOME_REDRAIN_SEND_LIMIT, attempt!.attemptId] - ) - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - await context.database.close() - }) - - it('clears a stale failure budget when the control is enabled again', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - const attempt = await context.store.claimRegionalRehome() - for (let index = 0; index < 3; index++) { - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - } - expect(await workerState(context)).toMatchObject({ consecutiveFailures: 3 }) - const latched = await context.store.inspectRegionalRehomeControl() - expect(latched).toMatchObject({ generation: 2, enabled: false }) - - await context.store.applyRegionalRehomeControl({ - expectedGeneration: latched.generation, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60 * 60_000 - }) - - // A budget spent under the previous enable is not evidence about this one. - expect(await workerState(context)).toMatchObject({ - consecutiveFailures: 0, - pausedUntil: 0 - }) - // One transient failure must not latch the fresh enable straight back off. - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 3, - enabled: true - }) - await context.database.close() - }) - - it('reports the durable disable when the failure budget latches the control off', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - const attempt = await context.store.claimRegionalRehome() - const warnings = collectEventWarnings( - 'orca_relay_regional_rehome_failure_budget_disabled' - ) - try { - for (let index = 0; index < 5; index++) { - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - } - } finally { - warnings.restore() - } - - // Only the transition is reported; later failures find the control already off. - expect(warnings.entries).toEqual([ - expect.objectContaining({ - event: 'orca_relay_regional_rehome_failure_budget_disabled', - controlGeneration: 2, - consecutiveFailures: 3 - }) - ]) - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: false - }) - await context.database.close() - }) }) class TransactionCountingDatabase implements RelayDatabase { @@ -1848,7 +1549,8 @@ async function setup( ) { let clock = 1_000_000 const database = options.database ?? (await openInMemoryRelayDatabase()) - const store = new RelayAssignmentStore(options.wrap?.(database) ?? database, () => clock, { + const store = new IdleRehomeTestStore(options.wrap?.(database) ?? database, () => clock, { + regionalRehomeCohortPercent: 100, requireLiveCells: true, heartbeatTtlMs: 45_000 }) @@ -1864,8 +1566,8 @@ async function setup( drainGraceMs: 60 * 60_000 }) await store.reconcileCells([source, target]) - await heartbeat(store, source, sourceIncarnation, options.sourceProtocol ?? 1) - await heartbeat(store, target, targetIncarnation, options.targetProtocol ?? 1) + await heartbeat(store, source, sourceIncarnation, options.sourceProtocol ?? 3) + await heartbeat(store, target, targetIncarnation, options.targetProtocol ?? 3) return { database, store, @@ -1950,9 +1652,7 @@ async function cellReservations(context: Context): Promise [String(row.cell_id), Number(row.reserved_requests)]) - ) + return Object.fromEntries(rows.map((row) => [String(row.cell_id), Number(row.reserved_requests)])) } async function freshHeartbeats(context: Context): Promise { @@ -1966,8 +1666,8 @@ async function freshHeartbeats(context: Context): Promise { databasePoolWaitMsMax: 0 } // The clock doubles as a strictly-increasing connection inclusion watermark. - await heartbeat(context.store, source, sourceIncarnation, 1, context.now(), safety) - await heartbeat(context.store, target, targetIncarnation, 1, context.now(), safety) + await heartbeat(context.store, source, sourceIncarnation, 3, context.now(), safety) + await heartbeat(context.store, target, targetIncarnation, 3, context.now(), safety) } async function activatePreferredSource( @@ -1978,9 +1678,29 @@ async function activatePreferredSource( const control = await context.store.activateControl(identity, { cellId: source.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: sourceIncarnation }) await context.store.assign(identity, 'asia-east2') + const issued = await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 150, 'asia-east2': 50 } + }, + assignment.assignmentEpoch + ) return control } @@ -1994,7 +1714,7 @@ function hookAfterCandidateScan( const decorate = (delegate: RelayDatabase): RelayDatabase => ({ query: async (sql, params) => { const rows = await delegate.query(sql, params) - if (!fired && sql.includes('FROM relay_assignment_region_preferences preference')) { + if (!fired && sql.includes('SELECT a.user_id, a.relay_host_id')) { fired = true await hook(delegate) } @@ -2017,7 +1737,7 @@ async function completeRehomeToTarget( identity: { userId: string; relayHostId: string } ): Promise { const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -2040,9 +1760,29 @@ async function activateReversePreferredSource( const control = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: targetIncarnation }) await context.store.assign(identity, 'us-central1') + const issued = await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 50, 'asia-east2': 150 } + }, + assignment.assignmentEpoch + ) return control } @@ -2059,7 +1799,7 @@ async function activateSource( } async function heartbeat( - store: RelayAssignmentStore, + store: IdleRehomeTestStore, cell: typeof source | typeof target, cellIncarnation: string, regionalRehomeProtocol: number, @@ -2152,3 +1892,46 @@ async function workerState( pausedUntil: Number(row.paused_until) } } + +class IdleRehomeTestStore extends BaseRelayAssignmentStore { + private readonly fixtureDatabase: RelayDatabase + private readonly fixtureNow: () => number + constructor(...args: ConstructorParameters) { + super(...args) + this.fixtureDatabase = args[0] + this.fixtureNow = args[1] ?? Date.now + } + async tryIdleRehome( + processSafety?: RegionalRehomeSafetySnapshot + ): Promise { + const safety = processSafety ?? { + observedAt: this.fixtureNow(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + for (const candidate of await this.selectIdleRegionalRehomeCandidates(safety)) { + const result = await this.commitIdleRegionalRehome(candidate, safety) + if (result.outcome !== 'committed') continue + const row = ( + await this.fixtureDatabase.query( + 'SELECT * FROM relay_region_rehome_attempts WHERE attempt_id = ?', + [candidate.attemptId] + ) + )[0]! + return { + ...candidate, + preferredRegion: row.preferred_region as RegionalRehomeAttempt['preferredRegion'], + targetCellIncarnation: String(row.target_cell_incarnation), + previousEpoch: Number(row.previous_epoch), + assignmentEpoch: Number(row.assignment_epoch), + drainGraceMs: Number(row.drain_grace_ms), + sendAttempts: Number(row.send_attempts) + } + } + return null + } +} diff --git a/cloud/apps/relay/src/regional-rehome-target-selection.test.ts b/cloud/apps/relay/src/regional-rehome-target-selection.test.ts index 493eaa50a61..89ae6f88544 100644 --- a/cloud/apps/relay/src/regional-rehome-target-selection.test.ts +++ b/cloud/apps/relay/src/regional-rehome-target-selection.test.ts @@ -25,6 +25,7 @@ async function setup() { let clock = 1_000_000 const database = await openInMemoryRelayDatabase() const store = new RelayAssignmentStore(database, () => clock, { + regionalRehomeCohortPercent: 100, requireLiveCells: true, heartbeatTtlMs: 45_000 }) @@ -83,11 +84,40 @@ async function setup() { await store.activateControl(identity, { cellId: source.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: incarnation(1) }) - await store.assign(identity, 'asia-east2') + const { window } = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + expect(window).toBeDefined() + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 180, 'asia-east2': 40 } + }, + assignment.assignmentEpoch + ) } - return { database, store, beat, activatePreferredSource } + const safety = () => ({ + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + return { database, store, beat, activatePreferredSource, safety } } const UNCLEAN = REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + 1 @@ -95,70 +125,78 @@ const UNCLEAN = REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + 1 describe('regional rehome target selection', () => { it('never selects a target without connection headroom, even at lowest load', async () => { const context = await setup() - await context.beat(source, 1, 1, { + await context.beat(source, 1, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: 0 }) // Lowest load but the connection hard cap is exhausted. - await context.beat(noHeadroom, 2, 1, { + await context.beat(noHeadroom, 2, 3, { observedRequests: 0, enforcedConnections: 999, sqlFailures: 0 }) - await context.beat(unclean, 3, 1, { + await context.beat(unclean, 3, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: UNCLEAN }) - await context.beat(highLoad, 4, 1, { + await context.beat(highLoad, 4, 3, { observedRequests: 50, enforcedConnections: 0, sqlFailures: 0 }) - await context.beat(lowLoad, 5, 1, { + await context.beat(lowLoad, 5, 3, { observedRequests: 10, enforcedConnections: 0, sqlFailures: 0 }) await context.activatePreferredSource() - const attempt = await context.store.claimRegionalRehome() + const candidates = await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + const attempt = candidates[0] expect(attempt?.targetCellId).toBe(lowLoad.id) + expect(await context.store.commitIdleRegionalRehome(attempt!, context.safety(), 100)).toEqual({ + outcome: 'committed' + }) await context.database.close() }) it('falls to the next clean target when the load winner goes unclean', async () => { const context = await setup() - await context.beat(source, 1, 1, { + await context.beat(source, 1, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: 0 }) - await context.beat(noHeadroom, 2, 1, { + await context.beat(noHeadroom, 2, 3, { observedRequests: 0, enforcedConnections: 999, sqlFailures: 0 }) - await context.beat(unclean, 3, 1, { + await context.beat(unclean, 3, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: UNCLEAN }) - await context.beat(highLoad, 4, 1, { + await context.beat(highLoad, 4, 3, { observedRequests: 50, enforcedConnections: 0, sqlFailures: 0 }) - await context.beat(lowLoad, 5, 1, { + await context.beat(lowLoad, 5, 3, { observedRequests: 10, enforcedConnections: 0, sqlFailures: UNCLEAN }) await context.activatePreferredSource() - const attempt = await context.store.claimRegionalRehome() + const candidates = await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + const attempt = candidates[0] expect(attempt?.targetCellId).toBe(highLoad.id) + expect(await context.store.commitIdleRegionalRehome(attempt!, context.safety(), 100)).toEqual({ + outcome: 'committed' + }) await context.database.close() }) }) diff --git a/cloud/apps/relay/src/regional-rehome-worker.test.ts b/cloud/apps/relay/src/regional-rehome-worker.test.ts index 33e7f01f737..640ae06b121 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.test.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.test.ts @@ -11,148 +11,37 @@ import { startRegionalRehomeWorker } from './regional-rehome-worker.js' describe('regional rehome worker', () => { afterEach(() => vi.restoreAllMocks()) - it('sends an incarnation- and source-epoch-bound drain without exposing identity', async () => { - let now = 0 - const attempt = { - attemptId: '11111111-1111-4111-8111-111111111111', - userId: 'private-user', - relayHostId: 'abcdefghijklmnop', - preferredRegion: 'asia-east2', - sourceCellId: 'production-gce-c7', - sourceCellUrl: 'https://c7.relay.example.test', - sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', - targetCellId: 'production-gce-c27', - targetCellIncarnation: '33333333-3333-4333-8333-333333333333', - previousEpoch: 7, - assignmentEpoch: 8, - drainGraceMs: 60_000, - sendAttempts: 1 + it('bounds empty polling to the six-second cadence and stops its timer', async () => { + vi.useFakeTimers() + const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([]) + const worker = startRegionalRehomeWorker(config(), { + selectIdleRegionalRehomeCandidates + } as unknown as RelayAssignmentStore, { + safetySnapshot: () => safety(Date.now()), + random: () => 0 + })! + try { + await vi.advanceTimersByTimeAsync(0) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(5_999) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(2) + worker.stop() + await vi.advanceTimersByTimeAsync(60_000) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(2) + } finally { + worker.stop() + vi.useRealTimers() } - const claimRegionalRehome = vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attempt) - const recordRegionalRehomeDrainReceipt = vi.fn().mockResolvedValue(true) - const assignments = { - claimRegionalRehome, - recordRegionalRehomeDrainReceipt - } as unknown as RelayAssignmentStore - const requests: Array<{ url: string; init?: RequestInit }> = [] - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const worker = startRegionalRehomeWorker(config(), assignments, { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000, - identityToken: async (audience) => { - expect(audience).toBe('https://relay.example.test/v1/admin/host-drain') - return 'secret-token' - }, - fetch: (async (url, init) => { - requests.push({ url: String(url), init }) - return Response.json({ v: 1, outcome: 'accepted' }) - }) as typeof fetch - })! - await settleWorker() - now = 1_000 - await worker.run() - worker.stop() - - expect(requests).toHaveLength(1) - expect(requests[0]!.url).toBe('https://c7.relay.example.test/v1/admin/host-drain') - expect(requests[0]!.url).not.toContain('secret-token') - expect(requests[0]!.init?.headers).toMatchObject({ - authorization: 'Bearer secret-token' - }) - expect(JSON.parse(String(requests[0]!.init?.body))).toEqual({ - v: 1, - attemptId: '11111111-1111-4111-8111-111111111111', - userId: 'private-user', - relayHostId: 'abcdefghijklmnop', - sourceCellId: 'production-gce-c7', - sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', - sourceAssignmentEpoch: 7, - graceMs: 60_000 - }) - expect(recordRegionalRehomeDrainReceipt).toHaveBeenCalledWith( - '11111111-1111-4111-8111-111111111111', - 'accepted' - ) - const logs = warn.mock.calls.map((call) => String(call[0])).join('\n') - expect(logs).not.toContain('private-user') - expect(logs).not.toContain('abcdefghijklmnop') - }) - - it('fails closed before the observation gate and records bounded dispatch failures', async () => { - let now = 0 - const attempt = { - attemptId: '11111111-1111-4111-8111-111111111111', - userId: 'private-user', - relayHostId: 'abcdefghijklmnop', - sourceCellId: 'source', - sourceCellUrl: 'https://source.example.test', - sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', - targetCellId: 'target', - previousEpoch: 1, - assignmentEpoch: 2, - drainGraceMs: 60_000, - sendAttempts: 1 - } - const claimRegionalRehome = vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attempt) - const assignments = { - claimRegionalRehome, - recordRegionalRehomeDispatchFailure: vi.fn().mockResolvedValue(undefined) - } as unknown as RelayAssignmentStore - const worker = startRegionalRehomeWorker(config(), assignments, { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000, - identityToken: async () => { - throw new Error('token unavailable') - } - })! - await settleWorker() - claimRegionalRehome.mockClear() - now = 100 - await worker.run() - worker.stop() - expect(assignments.recordRegionalRehomeDispatchFailure).toHaveBeenCalledWith( - '11111111-1111-4111-8111-111111111111' - ) - }) - - it('keeps a failed poll out of the durable dispatch-failure budget', async () => { - let now = 0 - const claimRegionalRehome = vi - .fn() - .mockResolvedValueOnce(null) - .mockRejectedValue(new Error('Connection terminated due to connection timeout')) - const recordRegionalRehomeDispatchFailure = vi.fn().mockResolvedValue(undefined) - const assignments = { - claimRegionalRehome, - recordRegionalRehomeDispatchFailure - } as unknown as RelayAssignmentStore - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const worker = startRegionalRehomeWorker(config(), assignments, { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000 - })! - await settleWorker() - now = 1_000 - await expect(worker.run()).resolves.toBeUndefined() - worker.stop() - - // The poll never claimed an attempt, so nothing was drained and nothing may - // be charged to the budget that latches the durable control off. - expect(recordRegionalRehomeDispatchFailure).not.toHaveBeenCalled() - expect(warn.mock.calls.map((call) => JSON.parse(String(call[0])).event)).toEqual([ - 'orca_relay_regional_rehome_poll_failed' - ]) }) it('passes unsafe process telemetry to the durable claim gate', async () => { let now = 0 let sqlFailures = 0 - const claimRegionalRehome = vi.fn().mockResolvedValue(null) + const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([]) const assignments = { - claimRegionalRehome + selectIdleRegionalRehomeCandidates } as unknown as RelayAssignmentStore const worker = startRegionalRehomeWorker(config(), assignments, { now: () => now, @@ -160,46 +49,40 @@ describe('regional rehome worker', () => { intervalMs: 60_000 })! await settleWorker() - claimRegionalRehome.mockClear() + selectIdleRegionalRehomeCandidates.mockClear() now = 100 sqlFailures = 1 await worker.run() worker.stop() - expect(claimRegionalRehome).toHaveBeenCalledWith( + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledWith( expect.objectContaining({ observedAt: 100, sqlFailures: 1 }) ) }) it('starts inert on directors so durable control can enable without a restart', async () => { let now = 0 - const claimRegionalRehome = vi.fn().mockResolvedValue(null) + const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([]) const assignments = { - claimRegionalRehome + selectIdleRegionalRehomeCandidates } as unknown as RelayAssignmentStore - const worker = startRegionalRehomeWorker( - config(), - assignments, - { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000 - } - ) + const worker = startRegionalRehomeWorker(config(), assignments, { + now: () => now, + safetySnapshot: () => safety(now), + intervalMs: 60_000 + }) expect(worker).not.toBeNull() await settleWorker() - claimRegionalRehome.mockClear() + selectIdleRegionalRehomeCandidates.mockClear() now = 100 await worker!.run() worker!.stop() - expect(claimRegionalRehome).toHaveBeenCalledOnce() + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce() expect( - startRegionalRehomeWorker( - config({ role: 'cell' }), - {} as RelayAssignmentStore, - { safetySnapshot: () => safety(1) } - ) + startRegionalRehomeWorker(config({ role: 'cell' }), {} as RelayAssignmentStore, { + safetySnapshot: () => safety(1) + }) ).toBeNull() }) @@ -208,16 +91,20 @@ describe('regional rehome worker', () => { const limit = cells * REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT const processSafety = { ...safety(100), reconnects: limit * 10 } const fleetSafety = { ...safety(100), reconnects: limit } - expect(regionalRehomeSafetyFailure( - combineRegionalRehomeSafety(processSafety, fleetSafety), - 100, - cells - )).toBeNull() - expect(regionalRehomeSafetyFailure( - combineRegionalRehomeSafety(processSafety, { ...fleetSafety, reconnects: limit + 1 }), - 100, - cells - )).toBe('elevated_reconnects') + expect( + regionalRehomeSafetyFailure( + combineRegionalRehomeSafety(processSafety, fleetSafety), + 100, + cells + ) + ).toBeNull() + expect( + regionalRehomeSafetyFailure( + combineRegionalRehomeSafety(processSafety, { ...fleetSafety, reconnects: limit + 1 }), + 100, + cells + ) + ).toBe('elevated_reconnects') }) }) diff --git a/cloud/apps/relay/src/regional-rehome-worker.ts b/cloud/apps/relay/src/regional-rehome-worker.ts index 4d8fa694afd..67d872b888a 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import { IdleRegionalRehomeResponseSchema } from '@orca-cloud/relay-contract' import type { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { googleMetadataIdentityToken } from './google-metadata-identity-token.js' @@ -20,13 +20,6 @@ export type RegionalRehomeWorker = { stop: () => void } -const RegionalHostDrainResponseSchema = z - .object({ - v: z.literal(1), - outcome: z.enum(['accepted', 'already-accepted', 'host-not-connected']) - }) - .strict() - export function startRegionalRehomeWorker( config: RelayConfig, assignments: RelayAssignmentStore, @@ -42,7 +35,6 @@ export function startRegionalRehomeWorker( } const audience = config.rehomeAudience const safetySnapshot = options.safetySnapshot - const now = options.now ?? Date.now const fetchImpl = options.fetch ?? fetch const tokenProvider = options.identityToken ?? @@ -52,64 +44,50 @@ export function startRegionalRehomeWorker( const run = async (): Promise => { if (stopped || inFlight) return inFlight = true - let attemptId: string | null = null try { - const processSafety = safetySnapshot() - const attempt = await assignments.claimRegionalRehome(processSafety) - if (!attempt) return - attemptId = attempt.attemptId + const candidates = await assignments.selectIdleRegionalRehomeCandidates(safetySnapshot()) + if (candidates.length === 0) return const token = await tokenProvider(audience) - const response = await fetchImpl( - new URL('/v1/admin/host-drain', attempt.sourceCellUrl), - { - method: 'POST', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json' - }, - body: JSON.stringify({ - v: 1, - attemptId: attempt.attemptId, - userId: attempt.userId, - relayHostId: attempt.relayHostId, - sourceCellId: attempt.sourceCellId, - sourceCellIncarnation: attempt.sourceCellIncarnation, - sourceAssignmentEpoch: attempt.previousEpoch, - graceMs: attempt.drainGraceMs - }), - signal: AbortSignal.timeout(options.requestTimeoutMs ?? 10_000) + for (const candidate of candidates) { + if (stopped) return + const { sourceCellUrl, ...request } = candidate + try { + const response = await fetchImpl(new URL('/v1/admin/host-idle-rehome', sourceCellUrl), { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + ...request, + cohortPercent: config.regionCorrectionCohortPercent ?? 0, + directorSafety: safetySnapshot() + }), + signal: AbortSignal.timeout(options.requestTimeoutMs ?? 10_000) + }) + if (!response.ok) throw new Error(`regional_rehome_source_${response.status}`) + const body = IdleRegionalRehomeResponseSchema.parse(await response.json()) + if (body.outcome === 'committed') { + console.warn( + JSON.stringify({ + event: 'orca_relay_idle_rehome_committed', + sourceCellId: candidate.sourceCellId, + targetCellId: candidate.targetCellId + }) + ) + return + } + } catch (error) { + // The source may have committed; its durable outcome owns recovery. + console.warn( + JSON.stringify({ + event: 'orca_relay_idle_rehome_request_failed', + reason: error instanceof Error ? error.message : 'unknown' + }) + ) } - ) - if (!response.ok) throw new Error(`regional_rehome_source_${response.status}`) - const body = RegionalHostDrainResponseSchema.safeParse(await response.json()) - if (!body.success) throw new Error('regional_rehome_source_invalid_response') - await assignments.recordRegionalRehomeDrainReceipt( - attempt.attemptId, - body.data.outcome - ) - console.warn( - JSON.stringify({ - event: 'orca_relay_regional_rehome_dispatched', - sourceCellId: attempt.sourceCellId, - targetCellId: attempt.targetCellId, - outcome: body.data.outcome, - sendAttempts: attempt.sendAttempts - }) - ) - } catch (error) { - // Only a claimed attempt was drained. A poll that failed before the claim - // - a pool timeout on the once-a-second control read - dispatched nothing, - // so it must not spend the budget that latches the durable control off. - if (attemptId) { - await assignments - .recordRegionalRehomeDispatchFailure(attemptId) - .catch(() => undefined) } + } catch (error) { console.warn( JSON.stringify({ - event: attemptId - ? 'orca_relay_regional_rehome_dispatch_failed' - : 'orca_relay_regional_rehome_poll_failed', + event: 'orca_relay_regional_rehome_poll_failed', reason: error instanceof Error ? error.message : 'unknown' }) ) @@ -119,7 +97,8 @@ export function startRegionalRehomeWorker( } const timer = setInterval( () => void run(), - options.intervalMs ?? jitteredSweepIntervalMs(1_000, options.random) + // Match the initial ten-moves/minute budget without replanning the join every second. + options.intervalMs ?? jitteredSweepIntervalMs(6_000, options.random) ) timer.unref() void run() diff --git a/cloud/apps/relay/src/relay-observability.test.ts b/cloud/apps/relay/src/relay-observability.test.ts index ea6734412be..605aacf6422 100644 --- a/cloud/apps/relay/src/relay-observability.test.ts +++ b/cloud/apps/relay/src/relay-observability.test.ts @@ -5,6 +5,7 @@ import { observeRelayDatabase } from './observed-relay-database.js' import { CONTROL_RTT_RESERVOIR_LIMIT, observedRelayRequests, + percentile, RelayObservability, type RelayProcessCounts } from './relay-observability.js' @@ -412,3 +413,181 @@ describe('relay observability', () => { expect(recordSql.mock.calls.map((call) => call[1])).toEqual([true, false, true, false]) }) }) + +// The pre-change implementation, kept verbatim as the differential oracle. Both +// ranks sorted their own copy and the maximum was a zero-seeded fold. +function legacyPercentile(values: number[], percentileRank: number): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((left, right) => left - right) + return sorted[Math.ceil(percentileRank * sorted.length) - 1] ?? 0 +} + +function legacyLatencySummary(samples: number[]): { p50: number; p95: number; max: number } { + const round = (value: number): number => Number(value.toFixed(3)) + return { + p50: round(legacyPercentile(samples, 0.5)), + p95: round(legacyPercentile(samples, 0.95)), + max: round(samples.reduce((highest, sample) => Math.max(highest, sample), 0)) + } +} + +// `-0` and `NaN` both survive a string round trip, unlike a bare equality check. +function describeNumber(value: number): string { + return Object.is(value, -0) ? '-0' : String(value) +} + +function expectSameNumber(actual: number, expected: number, label: string): void { + expect(`${label} = ${describeNumber(actual)}`).toBe(`${label} = ${describeNumber(expected)}`) +} + +function sparseWindow(size: number, filled: Record): number[] { + const values: number[] = new Array(size) + for (const [index, value] of Object.entries(filled)) values[Number(index)] = value + return values +} + +// Lehmer generator: stays inside the safe-integer range so the window is +// byte-identical on every engine the relay runs on. +function deterministicWindow(size: number): number[] { + let seed = 20_260_912 + return Array.from({ length: size }, () => { + seed = (seed * 48_271) % 2_147_483_647 + return (seed % 4_000_000) / 1_000 + }) +} + +const DENSE_WINDOWS: Array<{ name: string; values: number[] }> = [ + { name: 'empty', values: [] }, + { name: 'single', values: [7.5] }, + { name: 'single negative', values: [-7.5] }, + { name: 'ascending', values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }, + { name: 'descending', values: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] }, + { name: 'duplicates', values: [4, 4, 4, 4, 4] }, + // The trap: a sorted last element reads -1 here, the zero-seeded fold reads 0. + { name: 'all negative', values: [-5, -1, -9, -3, -2] }, + { name: 'mixed signs', values: [-2, 3, -7, 0, 11, -0.5] }, + { name: 'signed zero', values: [0, -0, -0, 0] }, + { name: 'negative then signed zero', values: [-3, -0, -1] }, + { name: 'nan leading', values: [NaN, 5, 1, 9] }, + { name: 'nan trailing', values: [5, 1, 9, NaN] }, + { name: 'nan interleaved', values: [5, NaN, 1, NaN, 9] }, + { name: 'all nan', values: [NaN, NaN, NaN] }, + { name: 'positive infinity', values: [Infinity, 3, 1] }, + { name: 'negative infinity', values: [-Infinity, -3, -1] }, + { name: 'both infinities', values: [Infinity, -Infinity, 3, -Infinity] }, + { name: 'infinities and nan', values: [Infinity, NaN, -Infinity, 0] }, + { name: 'sub-millisecond rounding', values: [0.00049, 0.0005, 0.00051, 0.9995] }, + { name: 'reservoir sized', values: deterministicWindow(CONTROL_RTT_RESERVOIR_LIMIT) } +] + +// Holes cannot reach the recorders, so they are exercised through `percentile` +// alone — the surface `host-session-registry` also calls. +const SPARSE_WINDOWS: Array<{ name: string; values: number[] }> = [ + { name: 'all holes', values: sparseWindow(4, {}) }, + { name: 'leading hole', values: sparseWindow(5, { 3: 8, 4: 2 }) }, + { name: 'trailing hole', values: sparseWindow(5, { 0: 8, 1: 2 }) }, + { name: 'interleaved holes', values: sparseWindow(6, { 0: 3, 2: -4, 5: 1 }) }, + { name: 'holes with nan', values: sparseWindow(5, { 1: NaN, 3: 6 }) } +] + +const PERCENTILE_RANKS = [0, 0.05, 0.5, 0.9, 0.95, 0.99, 1] + +type SortWork = { sorts: number; comparisons: number; copiedElements: number } + +// Every sorted array here is a fresh spread copy, so its length is the number of +// elements copied to produce it. +function countSortWork(run: () => void): SortWork { + const work: SortWork = { sorts: 0, comparisons: 0, copiedElements: 0 } + const original = Array.prototype.sort + const patched = Array.prototype as { sort: unknown } + patched.sort = function (this: T[], compare?: (left: T, right: T) => number): T[] { + work.sorts++ + work.copiedElements += this.length + return original.call(this, (left: T, right: T) => { + work.comparisons++ + return compare ? compare(left, right) : String(left) < String(right) ? -1 : 1 + }) + } + try { + run() + } finally { + patched.sort = original + } + return work +} + +function summaryThroughFlush(samples: number[]): { p50: number; p95: number; max: number } { + const entries: Array> = [] + const observability = new RelayObservability( + { role: 'cell', cellId: 'staging-c1', region: 'us-central1' }, + (entry) => entries.push(entry) + ) + for (const sample of samples) observability.recordControlRenewal(sample, 'renewed') + observability.flush(counts) + const entry = entries[0]! + return { + p50: entry.controlRenewalLatencyMsP50 as number, + p95: entry.controlRenewalLatencyMsP95 as number, + max: entry.controlRenewalLatencyMsMax as number + } +} + +describe('latency window summarisation', () => { + it('matches the pre-change percentile on every edge-case window', () => { + let compared = 0 + for (const { name, values } of [...DENSE_WINDOWS, ...SPARSE_WINDOWS]) { + for (const rank of PERCENTILE_RANKS) { + expectSameNumber( + percentile(values, rank), + legacyPercentile(values, rank), + `${name} @ p${rank}` + ) + compared++ + } + } + expect(compared).toBe((DENSE_WINDOWS.length + SPARSE_WINDOWS.length) * PERCENTILE_RANKS.length) + }) + + it('matches the pre-change p50, p95 and maximum through a flush', () => { + let compared = 0 + for (const { name, values } of DENSE_WINDOWS) { + const actual = summaryThroughFlush(values) + const expected = legacyLatencySummary(values) + expectSameNumber(actual.p50, expected.p50, `${name} p50`) + expectSameNumber(actual.p95, expected.p95, `${name} p95`) + // The zero-seeded fold, not the sorted last element: all-negative and NaN + // windows disagree between the two. + expectSameNumber(actual.max, expected.max, `${name} max`) + compared += 3 + } + expect(compared).toBe(DENSE_WINDOWS.length * 3) + // The trap, spelled out: the sorted window ends at -1 but the fold reports 0. + expect(summaryThroughFlush([-5, -1, -9, -3, -2]).max).toBe(0) + expect(Number.isNaN(summaryThroughFlush([5, NaN, 1]).max)).toBe(true) + }) + + it('sorts each latency window once instead of once per rank', () => { + const samples = deterministicWindow(CONTROL_RTT_RESERVOIR_LIMIT) + const before = countSortWork(() => legacyLatencySummary(samples)) + const after = countSortWork(() => summaryThroughFlush(samples)) + + expect(before.sorts).toBe(2) + expect(after.sorts).toBe(1) + expect(before.copiedElements).toBe(2 * CONTROL_RTT_RESERVOIR_LIMIT) + expect(after.copiedElements).toBe(CONTROL_RTT_RESERVOIR_LIMIT) + // Identical input and comparator, so the dropped sort is exactly half the + // comparator calls rather than an engine-specific constant. + expect(before.comparisons).toBeGreaterThan(CONTROL_RTT_RESERVOIR_LIMIT) + expect(after.comparisons).toBe(before.comparisons / 2) + }) + + it('never sorts an empty window and leaves the caller window untouched', () => { + const samples = [5, -1, NaN, 3, -0] + const before = samples.map(describeNumber) + expect(countSortWork(() => summaryThroughFlush([])).sorts).toBe(0) + expect(countSortWork(() => percentile([], 0.95)).sorts).toBe(0) + countSortWork(() => summaryThroughFlush(samples)) + percentile(samples, 0.5) + expect(samples.map(describeNumber)).toEqual(before) + }) +}) diff --git a/cloud/apps/relay/src/relay-observability.ts b/cloud/apps/relay/src/relay-observability.ts index 5e85758ca56..f5eabb60a4c 100644 --- a/cloud/apps/relay/src/relay-observability.ts +++ b/cloud/apps/relay/src/relay-observability.ts @@ -168,10 +168,18 @@ const emptyDeltas = (): RelayMetricDeltas => ({ controlActivityRecoveryFailures: 0 }) +function ascending(values: number[]): number[] { + return [...values].sort((left, right) => left - right) +} + +// Holes and NaN land past the requested rank, so the fallback still applies. +function nearestRank(sorted: number[], percentileRank: number): number { + return sorted[Math.ceil(percentileRank * sorted.length) - 1] ?? 0 +} + export function percentile(values: number[], percentileRank: number): number { if (values.length === 0) return 0 - const sorted = [...values].sort((left, right) => left - right) - return sorted[Math.ceil(percentileRank * sorted.length) - 1] ?? 0 + return nearestRank(ascending(values), percentileRank) } function roundMs(value: number): number { @@ -179,11 +187,15 @@ function roundMs(value: number): number { } // Spreading a window into Math.max blows the stack once a busy cell samples -// enough of it, so the maximum is folded instead. +// enough of it, so the maximum is folded instead. The fold is also not +// interchangeable with the sorted last element: it is seeded with zero, so an +// all-negative or NaN window reads differently. function latencySummary(samples: number[]): { p50: number; p95: number; max: number } { + // One sorted copy serves both ranks. + const sorted = samples.length === 0 ? samples : ascending(samples) return { - p50: roundMs(percentile(samples, 0.5)), - p95: roundMs(percentile(samples, 0.95)), + p50: roundMs(nearestRank(sorted, 0.5)), + p95: roundMs(nearestRank(sorted, 0.95)), max: roundMs(samples.reduce((highest, sample) => Math.max(highest, sample), 0)) } } diff --git a/cloud/apps/relay/src/relay-region-app.test.ts b/cloud/apps/relay/src/relay-region-app.test.ts index 30cf3bf3e26..54e8da670b8 100644 --- a/cloud/apps/relay/src/relay-region-app.test.ts +++ b/cloud/apps/relay/src/relay-region-app.test.ts @@ -40,6 +40,7 @@ describe('Relay region API', () => { ) expect(response.status).toBe(200) + expect(await response.clone().json()).not.toHaveProperty('regionCorrection') expect(assign).toHaveBeenCalledWith( { userId: 'user-1', relayHostId: 'asiahost00000001' }, 'asia-east2', @@ -83,6 +84,160 @@ describe('Relay region API', () => { ) }) + it('preserves the cold-start hint and binds a negotiated window after placement', async () => { + const assignment = { + userId: 'user-1', + relayHostId: 'abcdefghijklmnop', + cellId: 'asia-c1', + cellUrl: 'https://asia-c1.relay.example.test', + region: 'asia-east2', + assignmentEpoch: 7, + leaseExpiresAt: Date.now() + 300_000 + } + const assign = vi.fn(async () => assignment) + const window = { + generation: 2, + expiresAt: Date.now() + 86_400_000, + assignmentEpoch: 7, + incumbentRegion: 'asia-east2', + policyVersion: 1 + } + const exchangeRegionCorrection = vi.fn(async () => ({ v: 1, window })) + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, exchangeRegionCorrection } as never, + drain: vi.fn(), + ready: async () => true + }) + const regionCorrection = { v: 1, action: 'issue-window' } + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + preferredRegion: 'asia-east2', + regionCorrection + }) + ) + expect(response.status).toBe(200) + expect(assign).toHaveBeenCalledWith( + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + 'asia-east2', + 'asia-east2' + ) + expect(exchangeRegionCorrection).toHaveBeenCalledWith( + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + regionCorrection, + 7 + ) + expect(((await response.json()) as { regionCorrection: unknown }).regionCorrection).toEqual({ + v: 1, + window + }) + }) + + it('returns successful placement when optional window storage is unavailable', async () => { + const app = createRelayApp(config(), { + store: {} as never, + assignments: { + assign: async () => ({ + cellId: 'asia-c1', + region: 'asia-east2', + cellUrl: 'https://asia-c1.relay.example.test', + assignmentEpoch: 7 + }), + exchangeRegionCorrection: async () => { + throw new Error('database unavailable') + } + } as never, + drain: vi.fn(), + ready: async () => true + }) + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + preferredRegion: 'asia-east2', + regionCorrection: { v: 1, action: 'issue-window' } + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + cellUrl: 'https://asia-c1.relay.example.test', + assignmentEpoch: 7 + }) + }) + + it('does not place or write a legacy hint when reporting migration evidence', async () => { + const current = { + userId: 'user-1', + relayHostId: 'abcdefghijklmnop', + cellId: 'asia-c1', + cellUrl: 'https://asia-c1.relay.example.test', + region: 'asia-east2', + assignmentEpoch: 7, + leaseExpiresAt: Date.now() + 300_000 + } + const assign = vi.fn() + const resolve = vi.fn(async () => current) + const exchangeRegionCorrection = vi.fn(async () => ({ v: 1, reportStatus: 'accepted' })) + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, resolve, exchangeRegionCorrection } as never, + drain: vi.fn(), + ready: async () => true + }) + const regionCorrection = { + v: 1, + action: 'report', + generation: 2, + assignmentEpoch: 7, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 40, 'asia-east2': 180 } + } + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + preferredRegion: 'us-central1', + regionCorrection + }) + ) + expect(response.status).toBe(200) + expect(assign).not.toHaveBeenCalled() + expect(exchangeRegionCorrection).toHaveBeenCalledWith( + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + regionCorrection, + 7 + ) + expect(((await response.json()) as { assignmentEpoch: number }).assignmentEpoch).toBe(7) + }) + + it('does not manufacture an assignment for a report whose assignment disappeared', async () => { + const assign = vi.fn() + const exchangeRegionCorrection = vi.fn() + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, resolve: async () => null, exchangeRegionCorrection } as never, + drain: vi.fn(), + ready: async () => true + }) + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + regionCorrection: { + v: 1, + action: 'report', + generation: 2, + assignmentEpoch: 7, + policyVersion: 1, + outcome: 'inconclusive', + reason: 'timeout' + } + }) + ) + expect(response.status).toBe(409) + expect(assign).not.toHaveBeenCalled() + expect(exchangeRegionCorrection).not.toHaveBeenCalled() + }) + it('exposes only the store-provided healthy catalog from directors', async () => { const regionCatalog = vi.fn(async () => [ { region: 'us-central1' as const, probeOrigins: ['https://us.relay.example.test'] } diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index 77a15a1d259..7cee77e52de 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -20,14 +20,12 @@ import { createRelayApp } from './app.js' import { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { RelayCredentialStore } from './credential-store.js' -import type { RelayDatabase } from './database.js' +import { readRelayDatabasePoolPressure, type RelayDatabase } from './database.js' import { HostSessionRegistry } from './host-session-registry.js' import { observeRelayDatabase } from './observed-relay-database.js' import { RelayObservability } from './relay-observability.js' -import { - RelayConnectionLedger, - type RelayConnectionUpgrade -} from './relay-connection-ledger.js' +import { combineRegionalRehomeSafety } from './regional-rehome-safety.js' +import { RelayConnectionLedger, type RelayConnectionUpgrade } from './relay-connection-ledger.js' import { createRelayReadiness } from './relay-readiness.js' import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js' import { closeRelayWebSocket } from './relay-websocket-close.js' @@ -65,7 +63,7 @@ function guardSocketErrors(socket: WebSocket, kind: string): void { function admissionSource(request: IncomingMessage): string { const forwarded = request.headers['x-forwarded-for'] - const chain = (Array.isArray(forwarded) ? forwarded.join(',') : forwarded ?? '') + const chain = (Array.isArray(forwarded) ? forwarded.join(',') : (forwarded ?? '')) .split(',') .map((entry) => entry.trim()) .filter(Boolean) @@ -112,6 +110,7 @@ export function createRelayServer( const store = new RelayCredentialStore(observedDatabase, options.now) const assignments = new RelayAssignmentStore(observedDatabase, options.now, { requireLiveCells: config.role === 'director', + regionalRehomeCohortPercent: config.regionCorrectionCohortPercent ?? 0, recordControlRenewal: (durationMs, outcome) => observability.recordControlRenewal?.(durationMs, outcome) }) @@ -127,17 +126,35 @@ export function createRelayServer( queuedBytes, observability, options.now, - options.random + options.random, + cellIncarnation ) const app = createRelayApp(config, { store, assignments, drain: (graceMs) => sessions.drain(graceMs), drainHost: (input) => sessions.drainHost(input), + idleRehome: (input) => { + const now = (options.now ?? Date.now)() + if (input.directorSafety.observedAt > now || now - input.directorSafety.observedAt > 60_000) { + return Promise.resolve({ outcome: 'deferred' }) + } + return sessions.idleRehome(input, + () => assignments.commitIdleRegionalRehome(input, combineRegionalRehomeSafety( + input.directorSafety, + { ...observability.regionalRehomeRuntimeSafety(), ...readRelayDatabasePoolPressure(database) } + ), input.cohortPercent), + () => assignments.reconcileIdleRegionalRehome(input) + ) + }, regionalRehomeTrustProbeHostExists: (input) => sessions.get(input) !== null, cellIncarnation, isDraining: () => sessions.isDraining(), runtimeCounts: () => runtimeCounts(), + regionalRehomeSafetySnapshot: () => ({ + ...observability.regionalRehomeRuntimeSafety(), + ...readRelayDatabasePoolPressure(database) + }), ready, recordAssignmentAdmission: (outcome) => observability.recordAssignmentAdmission?.(outcome), recordAssignmentRejectionReason: (lane, reason) => @@ -339,7 +356,7 @@ export function createRelayServer( const identity = invite ? { userId: invite.userId, relayHostId: hostId } : null // Released combined-service invites gain their first durable cell assignment here. const assignment = identity - ? (await assignments.resolve(identity)) ?? (await assignments.assign(identity)) + ? ((await assignments.resolve(identity)) ?? (await assignments.assign(identity))) : null if (!invite || !assignment) { phoneAdmission?.hostData.release() diff --git a/cloud/apps/relay/src/relay-sweep-schedule.test.ts b/cloud/apps/relay/src/relay-sweep-schedule.test.ts index d5ef450cc43..b9965ae29ff 100644 --- a/cloud/apps/relay/src/relay-sweep-schedule.test.ts +++ b/cloud/apps/relay/src/relay-sweep-schedule.test.ts @@ -19,7 +19,7 @@ describe('sweep schedule jitter', () => { expect(SWEEP_JITTER_FRACTION).toBeGreaterThan(0) }) - it('jitters the regional rehome dispatch tick, which every director runs each second', () => { + it('jitters the six-second regional rehome dispatch tick across directors', () => { const timers: number[] = [] const setIntervalSpy = vi .spyOn(globalThis, 'setInterval') @@ -35,14 +35,14 @@ describe('sweep schedule jitter', () => { rehomeAudience: 'https://rehome.example.test', rehomeDirectorServiceAccount: 'rehome@example.test' } as never, - { claimRegionalRehome: async () => null } as never, + { selectIdleRegionalRehomeCandidates: async () => [] } as never, { random: () => 0.5, safetySnapshot: () => ({}) as never } ) } finally { setIntervalSpy.mockRestore() } - expect(timers).toEqual([1_100]) + expect(timers).toEqual([6_600]) }) // Why: index.ts boots a server on import, so its wiring can only be read. diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md new file mode 100644 index 00000000000..2ac4ad78391 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md @@ -0,0 +1,8 @@ +Exact relay contract snapshots from `027acb4efa2e6b226d40df266b86367423946d62`, `cloud/packages/relay-contract/src/`. Used to exercise the pre-correction strict wire parsers. Do not format or edit these baseline sources. + +```text +aba94e108a5cd0f1af8b38875429ad8636d24c43a728273e3df60d9a1a1d1b6d director-messages.ts +bd13b5a694a5d683a5b680c14e46ab33f4ef4a5bfedf040d046b09d540cb4c17 wire-scalars.ts +bc89116f884a2f20a6588f9b91219aa596bc2410d28b499a93a78350def109d5 relay-regions.ts +8fcae470a5fc72f2fcdde9d2f09cd20289c256356dd490484ac1cfa53839fbe4 control-messages.ts +``` diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts new file mode 100644 index 00000000000..0daf21e7c28 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts @@ -0,0 +1,146 @@ +import { z } from 'zod' +import { + Base6432ByteSchema, + Base64Raw24ByteSchema, + Base64Url32ByteSchema, + EpochMsSchema, + GenerationSchema, + OpaqueIdSchema, + PositiveDurationMsSchema, + RelayHostIdSchema +} from './wire-scalars.js' + +const AppVersionSchema = z.string().min(1).max(128) +const BoundedCiphertextSchema = z + .string() + .min(1) + .max(16 * 1024) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/) +const ConnectionKindSchema = z.enum(['invite', 'resume']) + +export const HostHelloSchema = z + .object({ + v: z.literal(1), + relayHostId: RelayHostIdSchema, + assignmentEpoch: GenerationSchema, + hostPublicKeyB64: Base6432ByteSchema, + appVersion: AppVersionSchema, + previousGeneration: GenerationSchema.optional(), + controlResumeSecret: Base64Url32ByteSchema.optional() + }) + .strict() + +export const HostChallengeSchema = z + .object({ + challengeId: OpaqueIdSchema, + relayEphemeralPublicKeyB64: Base6432ByteSchema, + nonceB64: Base64Raw24ByteSchema, + ciphertextB64: BoundedCiphertextSchema, + expiresAt: EpochMsSchema + }) + .strict() + +export const HostChallengeAckSchema = z + .object({ challengeId: OpaqueIdSchema, proofB64: Base6432ByteSchema }) + .strict() + +// Advertised on the control upgrade rather than in host-hello: HostHelloSchema +// is strict, so a new hello key is refused by every already-deployed cell. +export const RELAY_HOST_CAPABILITIES_HEADER = 'x-orca-host-capabilities' +// The host accepts kind/relayDeviceId on a pendingConns entry. A host that does +// not advertise this parses those entries strictly and would drop the whole ack. +export const RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS = 'pending-conn-details' + +export function parseRelayHostCapabilities( + header: string | string[] | undefined +): ReadonlySet { + const raw = Array.isArray(header) ? header.join(',') : (header ?? '') + return new Set( + raw + .split(',') + .map((token) => token.trim()) + .filter((token) => token.length > 0 && token.length <= 64) + .slice(0, 16) + ) +} + +// kind/relayDeviceId are optional so an entry stays readable by a host that +// predates them; the cell only emits them to a host that advertised support. +const PendingConnectionSchema = z + .object({ + connId: OpaqueIdSchema, + connTicket: Base64Url32ByteSchema, + kind: ConnectionKindSchema.optional(), + relayDeviceId: OpaqueIdSchema.optional() + }) + .strict() + +export const HostHelloAckSchema = z + .object({ + v: z.literal(1), + generation: GenerationSchema, + controlResumeSecret: Base64Url32ByteSchema, + leaseExpiresAt: EpochMsSchema, + activeConnIds: z.array(OpaqueIdSchema).max(8), + pendingConns: z.array(PendingConnectionSchema).max(8) + }) + .strict() + +export const ConnectionOpenSchema = z + .object({ + connId: OpaqueIdSchema, + connTicket: Base64Url32ByteSchema, + kind: ConnectionKindSchema, + relayDeviceId: OpaqueIdSchema, + attachDeadlineMs: PositiveDurationMsSchema + }) + .strict() + +export const HostDataAuthSchema = z + .object({ + v: z.literal(1), + connTicket: Base64Url32ByteSchema, + generation: GenerationSchema + }) + .strict() + +export const InviteCreateSchema = z + .object({ reqId: OpaqueIdSchema, relayDeviceId: OpaqueIdSchema }) + .strict() + +export const InviteCreatedSchema = z + .object({ + reqId: OpaqueIdSchema, + inviteToken: Base64Url32ByteSchema, + expiresAt: EpochMsSchema, + maxAttempts: z.number().int().positive().max(16) + }) + .strict() + +export const DeviceRevokeSchema = z + .object({ reqId: OpaqueIdSchema, relayDeviceId: OpaqueIdSchema }) + .strict() + +export const AuthRefreshSchema = z.object({ relayJwt: z.string().min(1).max(8 * 1024) }).strict() + +export const DrainSchema = z + .object({ + graceMs: z.number().int().nonnegative().max(60 * 60 * 1000), + recovery: z.literal('resolve-director') + }) + .strict() + +export const HeartbeatSchema = z.object({ t: EpochMsSchema }).strict() + +export type HostHello = z.infer +export type HostChallenge = z.infer +export type HostChallengeAck = z.infer +export type HostHelloAck = z.infer +export type ConnectionOpen = z.infer +export type HostDataAuth = z.infer +export type InviteCreate = z.infer +export type InviteCreated = z.infer +export type DeviceRevoke = z.infer +export type AuthRefresh = z.infer +export type Drain = z.infer +export type Heartbeat = z.infer diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts new file mode 100644 index 00000000000..e697135b68e --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import { + Base64Url32ByteSchema, + CanonicalHttpsOriginSchema, + EpochMsSchema, + GenerationSchema, + RelayHostIdSchema +} from './wire-scalars.js' +import { RelayRegionSchema } from './relay-regions.js' + +const SignedAssignmentLeaseSchema = z.string().min(1).max(8 * 1024) + +export const AssignmentRequestSchema = z + .object({ + v: z.literal(1), + relayHostId: RelayHostIdSchema, + // Client-declared reconnection; the director verifies it against the + // durable assignment before granting fast-lane admission. + reconnect: z.boolean().optional(), + preferredRegion: RelayRegionSchema.optional() + }) + .strict() + +export const AssignmentResponseSchema = z + .object({ + v: z.literal(1), + cellUrl: CanonicalHttpsOriginSchema, + assignmentEpoch: GenerationSchema, + lease: SignedAssignmentLeaseSchema + }) + .strict() + +export const ResolveRequestSchema = z + .object({ + v: z.literal(1), + relayHostId: RelayHostIdSchema, + resumeToken: Base64Url32ByteSchema + }) + .strict() + +export const ResolveResponseSchema = z + .object({ + v: z.literal(1), + cellUrl: CanonicalHttpsOriginSchema, + assignmentEpoch: GenerationSchema, + leaseExpiresAt: EpochMsSchema + }) + .strict() + +export const RelayMovedSchema = z + .object({ + v: z.literal(1), + cellUrl: CanonicalHttpsOriginSchema, + assignmentEpoch: GenerationSchema + }) + .strict() + +export function isTrustedNewerMove(input: { + sourceOrigin: string + configuredDirectorOrigin: string + currentAssignmentEpoch: number + move: z.infer +}): boolean { + // Why: cells and stale director responses must never redirect a credential-bearing client. + return ( + input.sourceOrigin === input.configuredDirectorOrigin && + input.move.assignmentEpoch > input.currentAssignmentEpoch + ) +} + +export type AssignmentRequest = z.infer +export type AssignmentResponse = z.infer +export type ResolveRequest = z.infer +export type ResolveResponse = z.infer +export type RelayMoved = z.infer diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts new file mode 100644 index 00000000000..6b8837829df --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts @@ -0,0 +1,71 @@ +import { z } from 'zod' + +export const RELAY_REGIONS = ['us-central1', 'asia-east2'] as const + +export const RelayRegionSchema = z.enum(RELAY_REGIONS) + +export type RelayRegion = z.infer + +export const RELAY_DEFAULT_REGION: RelayRegion = 'us-central1' + +// Field-name segment for the flat per-region runtime counters, spelled out rather than derived so +// the Terraform side can hold the same literal and a test can compare the two. `satisfies` makes a +// new region a compile error here, which is the point: a region with no segment would silently +// drop out of the region-skew alert's denominators. +export const RELAY_REGION_METRIC_SEGMENTS = { + 'us-central1': 'UsCentral1', + 'asia-east2': 'AsiaEast2' +} as const satisfies Record + +const RelayProbeOriginSchema = z.string().url().max(2_048).refine(isCanonicalHttpsOrigin) + +export const RelayRegionCatalogResponseSchema = z + .object({ + v: z.literal(1), + regions: z + .array( + z + .object({ + region: RelayRegionSchema, + probeOrigins: z.array(RelayProbeOriginSchema).min(1).max(2) + }) + .strict() + ) + .max(RELAY_REGIONS.length) + }) + .strict() + .superRefine((catalog, context) => { + const regions = new Set() + const origins = new Set() + for (const [regionIndex, entry] of catalog.regions.entries()) { + if (regions.has(entry.region)) { + context.addIssue({ + code: 'custom', + message: 'duplicate relay region', + path: ['regions', regionIndex, 'region'] + }) + } + regions.add(entry.region) + for (const [originIndex, origin] of entry.probeOrigins.entries()) { + if (origins.has(origin)) { + context.addIssue({ + code: 'custom', + message: 'duplicate relay probe origin', + path: ['regions', regionIndex, 'probeOrigins', originIndex] + }) + } + origins.add(origin) + } + } + }) + +export type RelayRegionCatalogResponse = z.infer + +function isCanonicalHttpsOrigin(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === 'https:' && url.origin === value + } catch { + return false + } +} diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts new file mode 100644 index 00000000000..27dd3a8b30f --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +export const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +export const Base64Url24ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{32}$/) +export const Base6432ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/) +export const Base64Raw24ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){8}$/) +export const RelayHostIdSchema = z.string().regex(/^[A-Za-z0-9_-]{16}$/) +export const OpaqueIdSchema = z.string().min(1).max(128) +export const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const GenerationSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const PositiveDurationMsSchema = z.number().int().positive().max(24 * 60 * 60 * 1000) + +export const CanonicalHttpsOriginSchema = z.string().max(2048).refine((value) => { + try { + const url = new URL(value) + return url.protocol === 'https:' && url.origin === value && url.pathname === '/' + } catch { + return false + } +}, 'must be a canonical HTTPS origin') diff --git a/cloud/apps/relay/tsconfig.build.json b/cloud/apps/relay/tsconfig.build.json index 489ddfd34d6..38eb0396cf2 100644 --- a/cloud/apps/relay/tsconfig.build.json +++ b/cloud/apps/relay/tsconfig.build.json @@ -6,5 +6,5 @@ "outDir": "dist", "rootDir": "src" }, - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/test-fixtures/**"] } diff --git a/cloud/dev/scripts/deploy-relay-blue-green.mjs b/cloud/dev/scripts/deploy-relay-blue-green.mjs index 88e4f8ccc60..ddfe2bce8fc 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.mjs @@ -10,6 +10,7 @@ export const DIRECTOR_REGIONAL_PLACEMENT_SECRET = 'orca-cloud-relay-regional-placement-enabled' export const DIRECTOR_REGIONAL_PLACEMENT_ENV = 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' +export const DIRECTOR_CORRECTION_COHORT_ENV = 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT' export const DIRECTOR_REHOME_IDENTITY_ENV = 'ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT' export const DIRECTOR_REHOME_AUDIENCE_ENV = 'ORCA_RELAY_REHOME_AUDIENCE' @@ -228,6 +229,13 @@ export function directorCellSetAddition(currentValue, desiredValue) { return { changed: additions.length > 0, value: JSON.stringify(desired) } } +export function correctionCohortPercent(value) { + if (!/^(?:[0-9]|[1-9][0-9]|100)$/.test(String(value))) { + throw new Error('region correction cohort must be an integer from 0 to 100') + } + return String(value) +} + export function directorDeploymentEnvironment(config) { const imageDigest = config.image?.match(/@(sha256:[a-f0-9]{64})$/)?.[1] if (config.image !== undefined && imageDigest === undefined) { @@ -238,6 +246,10 @@ export function directorDeploymentEnvironment(config) { ORCA_RELAY_ADMISSION_SELECTOR_VERSION: SELECTOR_REVISION_MARKER, ...(imageDigest === undefined ? {} : { ORCA_RELAY_IMAGE_DIGEST: imageDigest }) } + if (config['region-correction-cohort-percent'] !== undefined && + config['region-correction-cohort-percent'] !== 'preserve') { + environment[DIRECTOR_CORRECTION_COHORT_ENV] = correctionCohortPercent(config['region-correction-cohort-percent']) + } const serviceAccount = projectServiceAccount(config, 'capacity-service-account') const asiaProofServiceAccount = projectServiceAccount(config, 'asia-proof-service-account') const rehomeDirectorServiceAccount = projectServiceAccount( @@ -302,7 +314,8 @@ export function parseArguments(argv) { values['rehome-director-service-account'] !== undefined || values['rehome-audience'] !== undefined || values['expected-rehome-generation'] !== undefined || - values['rehome-control-origin'] !== undefined + values['rehome-control-origin'] !== undefined || + values['region-correction-cohort-percent'] !== undefined ) { throw new Error('director configuration arguments require --role director') } @@ -785,6 +798,14 @@ export async function deployDirector(config, tag, overrides = {}) { config['prune-revisions'] === 'true' ? CONNECTION_CAPACITY_PROTOCOL : undefined const currentEnvironment = revisionEnvironment(servingRevision) const deploymentEnvironment = directorDeploymentEnvironment(config) + deploymentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ??= correctionCohortPercent( + currentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ?? '0' + ) + if (config['region-correction-cohort-percent'] !== undefined && + config['region-correction-cohort-percent'] !== 'preserve' && + config['expected-rehome-generation'] === undefined) { + throw new Error('cohort changes require an exact disabled regional-rehome generation') + } const mutableEnvironment = { ...deploymentEnvironment, [DIRECTOR_REGIONAL_PLACEMENT_ENV]: '' diff --git a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs index 6e56676098b..a68ce50e912 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs @@ -4,6 +4,8 @@ import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { activeRevision, + correctionCohortPercent, + DIRECTOR_CORRECTION_COHORT_ENV, cloudRunTrafficTag, DIRECTOR_ADMISSION_ENVIRONMENT, DIRECTOR_REGIONAL_PLACEMENT_ENV, @@ -812,3 +814,43 @@ test('waits for authenticated target readiness without hiding other capacity err /forbidden/ ) }) + + +test('validates bounded correction cohorts and leaves unspecified values to serving inheritance', () => { + for (const value of ['0', '1', '100']) assert.equal(correctionCohortPercent(value), value) + for (const value of ['-1', '101', '1.5', '', '01', 'true', '1\n']) { + assert.throws(() => correctionCohortPercent(value), /integer from 0 to 100/) + } + assert.equal(directorDeploymentEnvironment({})[DIRECTOR_CORRECTION_COHORT_ENV], undefined) + assert.equal(directorDeploymentEnvironment({ 'region-correction-cohort-percent': 'preserve' })[DIRECTOR_CORRECTION_COHORT_ENV], undefined) + assert.equal(directorDeploymentEnvironment({ 'region-correction-cohort-percent': '1' })[DIRECTOR_CORRECTION_COHORT_ENV], '1') +}) + +test('inherits the cohort on candidate and rollback revisions without resetting an enabled cohort', async () => { + const harness = directorHarness() + harness.state.revisions.get('relay-00001-old').env[DIRECTOR_CORRECTION_COHORT_ENV] = '3' + await deployDirector({}, 'candidate-new', harness.operations) + for (const revision of ['relay-00002-new', 'relay-00003-new']) { + assert.equal(harness.state.revisions.get(revision).env[DIRECTOR_CORRECTION_COHORT_ENV], '3') + } +}) + +test('starts an unstamped cohort at zero and rejects a cohort change without disabled-control proof', async () => { + const harness = directorHarness() + await assert.rejects(deployDirector({ 'region-correction-cohort-percent': '1' }, + 'candidate-new', harness.operations), /exact disabled regional-rehome generation/) + assert.equal(harness.state.activeRevision, 'relay-00001-old') + assert.equal(harness.state.nextRevision, 2) + await deployDirector({}, 'candidate-new', harness.operations) + assert.equal(harness.state.revisions.get('relay-00003-new').env[DIRECTOR_CORRECTION_COHORT_ENV], '0') +}) + +test('sets a reviewed cohort only behind repeated disabled-control verification', async () => { + const harness = directorHarness() + let verified = 0 + const config = { 'region-correction-cohort-percent': '1', 'expected-rehome-generation': '7' } + await deployDirector(config, 'candidate-new', { ...harness.operations, + assertRegionalRehomeDisabled: async () => { verified++ } }) + assert.ok(verified >= 2) + assert.equal(harness.state.revisions.get('relay-00003-new').env[DIRECTOR_CORRECTION_COHORT_ENV], '1') +}) diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 2208131e58a..73ea44a40f7 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -38,7 +38,7 @@ export function parseRehomeTrustProbeArguments(argv, environment = process.env) export async function probeRehomeTrust(config, dependencies = {}) { const fetchImpl = dependencies.fetch ?? fetch - const response = await fetchAdminOnceMore( + const request = () => fetchAdminOnceMore( fetchImpl, `${config.directorOrigin}/v1/admin/regional-rehome-trust-probe`, { @@ -55,9 +55,26 @@ export async function probeRehomeTrust(config, dependencies = {}) { }, { wait: dependencies.wait } ) - const body = await response.json().catch(() => ({})) + let response = await request() + let body = await response.json().catch(() => ({})) + // The director wraps source HTTP failures in 409; retry only explicit transient statuses. + if (response.status === 409 && /^regional_rehome_trust_probe_source_(500|502|503|504)$/.test(body?.error ?? '')) { + await (dependencies.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(2_000) + response = await request() + body = await response.json().catch(() => ({})) + } if (!response.ok) { - throw new Error(`application-mediated rehome trust probe returned ${response.status}`) + const safeReasons = new Set([ + 'invalid_token', 'director_only', 'invalid_request', + 'regional_rehome_trust_not_configured', + 'regional_rehome_trust_probe_source_unavailable', + 'regional_rehome_trust_probe_source_invalid_response', + 'regional_rehome_trust_probe_not_proven', + ...[400, 401, 403, 404, 409, 429, 500, 502, 503, 504] + .map((status) => `regional_rehome_trust_probe_source_${status}`) + ]) + const reason = safeReasons.has(body?.error) ? body.error : 'unrecognized_error' + throw new Error(`application-mediated rehome trust probe returned ${response.status}: ${reason}`) } if ( body.v !== 1 || diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index 789d33c40b6..034fcd06c4f 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -131,3 +131,40 @@ test('approves the asia-east2 rehome sources and still rejects unlisted cells', ) } }) + +test('retries one director-wrapped source 503 without relaxing the proof', async () => { + let calls = 0 + const result = await probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), { + wait: async () => {}, + fetch: async () => ++calls === 1 + ? Response.json({ error: 'regional_rehome_trust_probe_source_503' }, { status: 409 }) + : Response.json(provenProbe) + }) + assert.equal(calls, 2) + assert.equal(result.proven, true) +}) + +test('reports safe trust reasons, keeps rejection final, and redacts arbitrary error text', async () => { + for (const reason of ['regional_rehome_trust_probe_source_403', 'secret-token-example']) { + let calls = 0 + await assert.rejects(probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), { + wait: async () => { throw new Error('must not retry') }, + fetch: async () => { calls++; return Response.json({ error: reason }, { status: 409 }) } + }), error => { + assert.match(error.message, /returned 409/) + assert.ok(!error.message.includes('secret-token-example')) + if (reason.endsWith('_403')) assert.match(error.message, /source_403/) + return true + }) + assert.equal(calls, 1) + } +}) + +test('stops after the second wrapped transient failure', async () => { + let calls = 0 + await assert.rejects(probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), { + wait: async () => {}, + fetch: async () => { calls++; return Response.json({ error: 'regional_rehome_trust_probe_source_503' }, { status: 409 }) } + }), /returned 409.*source_503/) + assert.equal(calls, 2) +}) diff --git a/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs b/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs index 80d40277e5a..796d0e9d91a 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs @@ -53,7 +53,7 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { try { service = run(gcloudArguments('services', input)) } catch (error) { - if (error?.code === 'NOT_FOUND') return { version: input.bootstrap_version } + if (error?.code === 'NOT_FOUND') return { version: input.bootstrap_version, cohort_percent: '0' } throw error } const serving = (service.status?.traffic ?? []).filter( @@ -67,12 +67,22 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { throw new Error('Relay director must have exactly one revision serving 100% traffic') } const revision = run(gcloudArguments('revisions', input, serving[0].revisionName)) + const cohortSettings = (revision.spec?.containers ?? []).flatMap((container) => + (container.env ?? []).filter((environment) => + environment.name === 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT') + ) + if (cohortSettings.length > 1 || (cohortSettings.length === 1 && + (typeof cohortSettings[0].value !== 'string' || + !/^(?:[0-9]|[1-9][0-9]|100)$/.test(cohortSettings[0].value)))) { + throw new Error('serving region correction cohort is invalid') + } + const cohort_percent = cohortSettings[0]?.value ?? '0' const references = (revision.spec?.containers ?? []).flatMap((container) => (container.env ?? []).filter( (environment) => environment.name === 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' ) ) - if (references.length === 0) return { version: input.bootstrap_version } + if (references.length === 0) return { version: input.bootstrap_version, cohort_percent } const reference = normalizeSecretReference(references[0]) if ( references.length !== 1 || @@ -81,7 +91,7 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { ) { throw new Error('serving regional placement secret reference is invalid') } - return { version: reference.version } + return { version: reference.version, cohort_percent } } // Why: the v2 API reports `valueSource.secretKeyRef.{secret,version}`, but diff --git a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs index 8043fc23e94..9c49d4127a5 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs @@ -67,7 +67,7 @@ test('reads the exact version from the sole traffic-serving revision', () => { } }) - assert.deepEqual(result, { version: '11' }) + assert.deepEqual(result, { version: '11', cohort_percent: '0' }) assert.equal(calls[1][3], 'relay-serving') }) @@ -78,7 +78,7 @@ test('reads the gcloud v1 secret reference shape by bare id and by full resource ]) { assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? serving() : v1Revision(name, '1') - }), { version: '1' }) + }), { version: '1', cohort_percent: '0' }) } }) @@ -100,12 +100,12 @@ test('falls back only when the service or setting is absent', () => { notFound.code = 'NOT_FOUND' assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: () => { throw notFound } - }), { version: '7' }) + }), { version: '7', cohort_percent: '0' }) assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? { status: { traffic: [{ revisionName: 'relay-serving', percent: 100 }] } } : { spec: { containers: [{ env: [] }] } } - }), { version: '7' }) + }), { version: '7', cohort_percent: '0' }) }) test('classifies real absent-service stderr without weakening revision failures', () => { @@ -136,3 +136,31 @@ test('rejects ambiguous traffic, malformed references, and read failures', () => run: () => { throw denied } }), denied) }) + + +test('preserves the serving cohort including explicit disable across later Terraform plans', () => { + for (const value of ['0', '1', '17', '100']) { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push({ name: 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT', value }) + assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), { version: '11', cohort_percent: value }) + } +}) + +test('fails closed on malformed, secret-backed or duplicate cohorts rather than resetting them', () => { + const name = 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT' + const cases = [ + [{ name, value: '101' }], [{ name, value: '-1' }], [{ name, value: '1.5' }], + [{ name, value: '' }], [{ name, value: '01' }], [{ name, value: 1 }], + [{ name, valueFrom: { secretKeyRef: { name: 'unexpected', key: '1' } } }], + [{ name, value: '1' }, { name, value: '2' }] + ] + for (const settings of cases) { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push(...settings) + assert.throws(() => readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), /cohort is invalid/) + } +}) diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index 6e84c1c9104..8caa68e7ff2 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -87,18 +87,21 @@ export function canaryAuthority(input) { } export function verifyCanaryAuthority(authority, expected, repositoryRoot) { + const selectorGeneration = Number(expected.selectorGeneration) if ( authority?.v !== 1 || !/^[0-9a-f]{40}$/.test(authority.commitSha ?? '') || authority.runId !== expected.runId || authority.targetDigest !== expected.targetDigest || authority.rollbackDigest !== expected.rollbackDigest || - authority.selectorGeneration !== Number(expected.selectorGeneration) || + !Number.isSafeInteger(authority.selectorGeneration) || + authority.selectorGeneration < 0 || + !Number.isSafeInteger(selectorGeneration) || + selectorGeneration < authority.selectorGeneration || authority.rehomeGeneration !== Number(expected.rehomeGeneration) || !SAME_CAP_CELLS.includes(authority.cellId) ) throw new Error('canary authority does not match this batch') - // The batch dispatch resolves main after the canary sealed, so bind to the same code, not the - // same SHA; every field above still pins this batch to that exact canary. + // Each cell checks exact live selector state; later batches may reuse this control epoch's canary. requireSameEvidenceCode({ sealedSha: authority.commitSha, currentSha: expected.commitSha, diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index d636c324b33..7377f7a7af3 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -109,6 +109,41 @@ test('seals and verifies canary authority for later batches', () => { }), /does not match/) }) +test('reuses a canary across selector advances only within the same control epoch', () => { + const authority = canaryAuthority({ + cellIds: 'production-gce-c7', targetDigest, rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c7`, + commitSha: 'c'.repeat(40), runId: '42', selectorGeneration: '11', rehomeGeneration: '4' + }) + const expected = { + commitSha: 'c'.repeat(40), runId: '42', targetDigest, rollbackDigest, + selectorGeneration: '21', rehomeGeneration: '4' + } + for (const generation of ['13', '14', '21', '29']) { + assert.equal(verifyCanaryAuthority(authority, { + ...expected, selectorGeneration: generation + }), authority) + } + for (const generation of ['12', '-1', 'NaN', 'Infinity', '13.5', '9007199254740992']) { + assert.throws(() => verifyCanaryAuthority(authority, { + ...expected, selectorGeneration: generation + }), /does not match/) + } + for (const generation of [-1, NaN, Infinity, 13.5, '13', Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => verifyCanaryAuthority({ + ...authority, selectorGeneration: generation + }, expected), /does not match/) + } + for (const mismatch of [ + { rehomeGeneration: '3' }, { rehomeGeneration: '5' }, + { targetDigest: rollbackDigest }, { rollbackDigest: targetDigest }, { runId: '43' } + ]) { + assert.throws(() => verifyCanaryAuthority(authority, { + ...expected, ...mismatch + }), /does not match/) + } +}) + function gitIn(root, ...args) { return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim() } @@ -158,7 +193,7 @@ test('a batch trusts a canary sealed by identical code at an ancestor commit', a runId: '42', targetDigest, rollbackDigest, - selectorGeneration: '13', + selectorGeneration: '21', rehomeGeneration: '4' }, repositoryRoot) assert.equal(verifyAt(repository.sameCode, repository.root).cellId, 'production-gce-c7') diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 7d5e4fee73e..3e5f0758028 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -77,14 +77,14 @@ function rollPlan({ cellId, cap, protocol }) { metadata_startup_script: startupScript({ cap, image: ROLLBACK_IMAGE, - trusted: protocol === 1 + trusted: protocol >= 1 }) }, after: { metadata_startup_script: startupScript({ cap, image: TARGET_IMAGE, - trusted: protocol === 1 + trusted: protocol >= 1 }), self_link: null }, @@ -178,11 +178,9 @@ describe('same-cap roll scripts accept every same-cap cell', () => { }) it('validates a correct plan for every wave cell at that cell\'s rehome protocol', () => { - for (const cellId of SAME_CAP_CELLS) { + for (const [cellId, protocol] of SAME_CAP_CELLS.flatMap((cell) => [[cell, 1], [cell, 3]])) { const [, cap] = resolveCellShape(cellId).stdout.trim().split(' ') - const protocol = REHOME_SOURCE_CELLS.has(cellId) ? 1 : 0 - // Every reviewed serving cell carries rehome trust now, in either region. - assert.equal(protocol, 1, cellId) + assert.equal(REHOME_SOURCE_CELLS.has(cellId), true, cellId) const config = { mode: 'same-cap-cell', cellId, @@ -204,7 +202,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.throws( () => validateCapacityPlan(plan, { ...config, - regionalRehomeProtocol: String(1 - protocol) + regionalRehomeProtocol: '0' }), /reviewed image and capacity/, cellId @@ -239,3 +237,11 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.doesNotMatch(capacityWorkflow, /--approved-cells/) }) }) + +// Both trusted versions must prove the same authenticated drain boundary. +it('proves rehome trust for protocol 3 on forward and rollback rolls', () => { + const step = workflow.split('name: Prove exact per-host trust and idempotent no-neighbor behavior')[1].split('\n - name:')[0] + assert.match(step, /inputs\.rollback-rehome-protocol != '0'/) + assert.match(step, /inputs\.target-rehome-protocol != '0'/) + assert.match(step, /probe-relay-rehome-trust\.mjs/) +}) diff --git a/cloud/dev/scripts/validate-relay-capacity-plan.mjs b/cloud/dev/scripts/validate-relay-capacity-plan.mjs index 294e85ae31d..34e84d51ead 100644 --- a/cloud/dev/scripts/validate-relay-capacity-plan.mjs +++ b/cloud/dev/scripts/validate-relay-capacity-plan.mjs @@ -9,7 +9,7 @@ const REHOME_CONFIG = // Only cells listed as regional rehome sources get rehome trust lines in their startup script. function rehomeProtocol({ regionalRehomeProtocol }) { - if (![0, 1, '0', '1'].includes(regionalRehomeProtocol)) { + if (![0, 1, 3, '0', '1', '3'].includes(regionalRehomeProtocol)) { throw new Error('same-cap Terraform plan has an invalid regional rehome protocol') } return Number(regionalRehomeProtocol) @@ -43,7 +43,7 @@ export function parseCapacityPlanArguments(argv) { (!values['rollback-image'] || !values['rehome-director-service-account'] || !values['rehome-audience'] || - !['0', '1'].includes(values['regional-rehome-protocol'])) + !['0', '1', '3'].includes(values['regional-rehome-protocol'])) ) throw new Error('same-cap validation requires rollback image and rehome trust config') if (values.mode !== 'same-cap-cell' && values['regional-rehome-protocol'] !== undefined) { throw new Error('--regional-rehome-protocol applies only to same-cap-cell validation') @@ -227,7 +227,7 @@ function requireDesiredStartupScript(script, config) { ` printf 'ORCA_RELAY_CAPACITY_SERVICE_ACCOUNT=%s\\n' '${config.capacityServiceAccount}'` ]) } - const rehomeTrusted = config.mode === 'same-cap-cell' && rehomeProtocol(config) === 1 + const rehomeTrusted = config.mode === 'same-cap-cell' && rehomeProtocol(config) >= 1 if (rehomeTrusted) { expected.push( [ diff --git a/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs b/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs index fb6ccb57e1c..d6886c58011 100644 --- a/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs @@ -756,6 +756,10 @@ test('the rehome protocol argument is required by same-cap-cell mode alone', () .regionalRehomeProtocol, '0' ) + assert.equal( + parseCapacityPlanArguments(sameCapArguments('--regional-rehome-protocol', '3')).regionalRehomeProtocol, + '3' + ) assert.throws( () => parseCapacityPlanArguments(sameCapArguments()), /requires rollback image and rehome trust config/ diff --git a/cloud/dev/scripts/verify-relay-capacity-transition.mjs b/cloud/dev/scripts/verify-relay-capacity-transition.mjs index b81ea15afb3..0c297fdad53 100644 --- a/cloud/dev/scripts/verify-relay-capacity-transition.mjs +++ b/cloud/dev/scripts/verify-relay-capacity-transition.mjs @@ -108,8 +108,8 @@ export function parseCapacityTransitionArguments(argv) { const regionalRehomeProtocol = values['regional-rehome-protocol'] === undefined ? undefined : integer(values['regional-rehome-protocol'], '--regional-rehome-protocol') - if (regionalRehomeProtocol !== undefined && ![0, 1].includes(regionalRehomeProtocol)) { - throw new Error('--regional-rehome-protocol must be 0 or 1') + if (regionalRehomeProtocol !== undefined && ![0, 1, 3].includes(regionalRehomeProtocol)) { + throw new Error('--regional-rehome-protocol must be 0, 1, or 3') } if (runtime === 'unavailable' && regionalRehomeProtocol !== undefined) { throw new Error('unavailable runtime cannot prove the regional rehome protocol') diff --git a/cloud/docs/orca-relay-operations.md b/cloud/docs/orca-relay-operations.md index cd989b58e94..4515048b29b 100644 --- a/cloud/docs/orca-relay-operations.md +++ b/cloud/docs/orca-relay-operations.md @@ -466,11 +466,16 @@ After a deployment traffic shift, preserve the old revision/tag until metrics an ## Regional rehoming -Rehoming moves a host to a general cell in the region its desktop last reported, in either -direction. Both roles need the drain protocol: a cell without it can be neither a source nor a -target, and it is not part of the fleet whose telemetry gates the worker. Until the asia-east2 -cells run `regionalRehomeProtocol` 1 they are none of the three, so no host is moved into or out -of Asia and an Asia cell in distress does not pause the worker. +Idle regional correction requires both source and target cells to advertise +`regionalRehomeProtocol >= 3`. PR #20105 introduced this capability version with +the idle handoff implementation. With that runtime, both rehome trust environment +settings must be configured to advertise 3; otherwise the cell advertises 0. +An older trusted runtime can advertise 1: configuring trust alone does not upgrade +its implementation. The separate `connectionCapacityProtocol: 2` health field does +not establish regional-correction readiness. Verify the live runtime version and +image, not only instance-template configuration, before rollout or enablement. +Incompatible cells are excluded from correction selection; enabling the cohort +cannot override this check. Director and cell deployments are separate operations. `host-cooldown-ms` is the minimum gap between two rehomes of one host. It bounds the damage from a desktop whose region probe flips: without it the host would be dragged back across the ocean on @@ -491,3 +496,64 @@ Run and record each scenario in staging before launch: - return a dormant host, overload a cell, kill a cell, evacuate active work, and exercise pre-registration rollback. The served black-box relay suite validates the protocol/state transitions used by these procedures. The physical-device and real-GFE canaries remain separate launch gates; unit/black-box success cannot replace them. + +## Optional measured region correction (deployment gated) + +New optimization claims require both the durable regional-rehome control and +`ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT` (integer 0–100, default **0**). +Turning either gate off stops new optional moves; ordinary migration cleanup and +recovery continue. Legacy preferred-region hints do not certify a correction. Both +cells must advertise regional protocol3 and the authenticated desktop control must +advertise idle-regional-rehome-v1. The source must have no actual client sockets or +pending admission/control work; a live control socket alone does not prevent a move. + +The monitor/deploy identity can read **GET `/v1/admin/regional-rehome-preview`**. +It returns full-population eligibility/exclusion counts, open-migration capacity, +process-safety gating and aggregate migration outcomes; it never claims a +host or changes the failure budget. This is advisory, with separately read state: +concurrent assignments, capacity changes, rate pauses and control changes can make +the next claim differ. Inspect the durable control separately before enabling. +Do not treat an unavailable/failed preview as zero eligible hosts. + +`orca_relay_region_correction_outcomes` reports attempts by source/target, +registration/completion/abort state, oldest open age and target +reservation units every five minutes. `orca_relay_region_comparison` samples a +stable 10% of accepted reports (including unchanged hosts), keyed by host digest, +assignment epoch and decision generation. Existing control RTT and client-accept +logs include assignment epoch, control generation and drain mode; join those for +matched before/after and unchanged-cohort comparisons. Client accept latency is +connection setup, not application command round trip. No application-latency +improvement has been demonstrated by probe differences alone. + +Quiet live connections count as work and defer optional correction indefinitely. +A returning client may race with the short admission gate and retry normally. No +optimization timer may close an established client. Investigate failed registration, +ambiguous authority, stuck reservations and reconnect/failure rates against agreed +limits. A database outage can keep the source fenced until locked reconciliation +establishes its authority; timeout alone is not permission to reopen admissions. + +All directors must run the reviewed idle worker before enabling. Record the tested +immutable source and rollback revisions, then verify the ordinary migration recovery +path before rollout. There is no retained-source table or renewal protocol. Deploying +supporting cells/desktops and enabling a cohort require separate rollout authorization +and explicit numerical stop criteria; this change enables neither. + +### Setting the correction cohort during a reviewed director rollout + +The existing **Deploy Relay Production Director** workflow accepts +`region-correction-cohort-percent`: `preserve` (default) or an integer0–100. +It carries the cohort onto both candidate and compatible rollback revisions and +verifies the environment before promotion. If the predecessor has no setting, +`preserve` stamps zero. An explicit change requires the exact disabled durable +rehome generation; configuring a nonzero cohort does not itself enable the sweep. +The usual image, identity, health and traffic checks remain in force. No workflow +was dispatched as part of implementation. + +Terraform reads the cohort from the same traffic-serving revision used to preserve +regional placement. A later apply therefore preserves a workflow-set cohort, +including explicit zero; only an absent service/setting bootstraps to0. Malformed +or ambiguous live settings fail the plan instead of silently resetting the cohort. +The audited director workflow owns subsequent changes. +Before the first nonzero cohort, verify compatible protocol2 cells, updated +cleanup workers, preview eligibility, both serving/rollback images and the +explicitly approved observation/stop criteria. diff --git a/cloud/docs/relay-incident-monitor.md b/cloud/docs/relay-incident-monitor.md index 696efb85296..a97e92949e8 100644 --- a/cloud/docs/relay-incident-monitor.md +++ b/cloud/docs/relay-incident-monitor.md @@ -112,7 +112,8 @@ durably marked consumed before mutation and cannot authorize another run. | Director instances | outside 5–6 | | Director CPU or memory | over 80% | | Director concurrency | over 64 | -| Unexpected director 5xx or auth 5xx in five minutes | over 0 | +| Unexpected director 5xx in five minutes (excludes 503) | over 3 | +| Auth 5xx in five minutes | over 0 | | Connections per cell process | over 500 | | Queued bytes per cell process | over 48 MiB | | Blocked or expired/unregistered migration | over 0 | @@ -276,3 +277,7 @@ without its segment is a compile error in relay-contract, not a silent gap. load the director's three-connection database pool. - Added private atomic state, idempotent JSONL checkpoints, and secret-safe Markdown evidence. - Added the manual production workflow. It has not been dispatched. + +### Director error allowance (2026-09-12) + +The serving-cell rollout observed three unexpected director 500 responses among approximately 33,600 responses in an hour, all two-second PostgreSQL connection timeouts. CPU remained near 30–37% and the zero-error bar repeatedly prevented any cell mutation. The five-minute allowance is now three non-503 director 5xx; four freezes. Auth errors, data freshness, active probes, SQL/pool pressure and other limits are unchanged. This is a bounded operational allowance, not a calibrated SLO or proof that intermittent failures are resolved; persistent low-frequency errors below this limit still require diagnosis. diff --git a/cloud/infra/terraform/relay.tf b/cloud/infra/terraform/relay.tf index 7a5124a00b1..5df7372ff07 100644 --- a/cloud/infra/terraform/relay.tf +++ b/cloud/infra/terraform/relay.tf @@ -169,6 +169,11 @@ resource "google_cloud_run_v2_service" "relay" { } } + env { + name = "ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT" + value = data.external.relay_serving_regional_placement_version.result.cohort_percent + } + ports { container_port = 8080 } diff --git a/cloud/packages/push-contract/src/send-messages.ts b/cloud/packages/push-contract/src/send-messages.ts index 57d1c2e2745..a8959c18b05 100644 --- a/cloud/packages/push-contract/src/send-messages.ts +++ b/cloud/packages/push-contract/src/send-messages.ts @@ -26,7 +26,8 @@ export const PushNotificationSchema = z agentState: PushAgentStateSchema.nullable(), title: z.string().min(1).max(PUSH_LIMITS.titleMaxChars), body: z.string().max(PUSH_LIMITS.bodyMaxChars), - worktreeId: z.string().min(1).max(2048).optional() + worktreeId: z.string().min(1).max(2048).optional(), + paneKey: z.string().min(1).max(2048).optional() }) .strict() .refine( diff --git a/cloud/packages/relay-contract/src/control-messages.ts b/cloud/packages/relay-contract/src/control-messages.ts index 0daf21e7c28..ebe9586407e 100644 --- a/cloud/packages/relay-contract/src/control-messages.ts +++ b/cloud/packages/relay-contract/src/control-messages.ts @@ -50,6 +50,7 @@ export const RELAY_HOST_CAPABILITIES_HEADER = 'x-orca-host-capabilities' // The host accepts kind/relayDeviceId on a pendingConns entry. A host that does // not advertise this parses those entries strictly and would drop the whole ack. export const RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS = 'pending-conn-details' +export const RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME = 'idle-regional-rehome-v1' export function parseRelayHostCapabilities( header: string | string[] | undefined @@ -121,11 +122,22 @@ export const DeviceRevokeSchema = z .object({ reqId: OpaqueIdSchema, relayDeviceId: OpaqueIdSchema }) .strict() -export const AuthRefreshSchema = z.object({ relayJwt: z.string().min(1).max(8 * 1024) }).strict() +export const AuthRefreshSchema = z + .object({ + relayJwt: z + .string() + .min(1) + .max(8 * 1024) + }) + .strict() export const DrainSchema = z .object({ - graceMs: z.number().int().nonnegative().max(60 * 60 * 1000), + graceMs: z + .number() + .int() + .nonnegative() + .max(60 * 60 * 1000), recovery: z.literal('resolve-director') }) .strict() diff --git a/cloud/packages/relay-contract/src/director-messages.ts b/cloud/packages/relay-contract/src/director-messages.ts index e697135b68e..0f57081014a 100644 --- a/cloud/packages/relay-contract/src/director-messages.ts +++ b/cloud/packages/relay-contract/src/director-messages.ts @@ -7,8 +7,15 @@ import { RelayHostIdSchema } from './wire-scalars.js' import { RelayRegionSchema } from './relay-regions.js' +import { + RegionCorrectionRequestSchema, + RegionCorrectionResponseSchema +} from './region-correction.js' -const SignedAssignmentLeaseSchema = z.string().min(1).max(8 * 1024) +const SignedAssignmentLeaseSchema = z + .string() + .min(1) + .max(8 * 1024) export const AssignmentRequestSchema = z .object({ @@ -17,7 +24,8 @@ export const AssignmentRequestSchema = z // Client-declared reconnection; the director verifies it against the // durable assignment before granting fast-lane admission. reconnect: z.boolean().optional(), - preferredRegion: RelayRegionSchema.optional() + preferredRegion: RelayRegionSchema.optional(), + regionCorrection: RegionCorrectionRequestSchema.optional() }) .strict() @@ -26,7 +34,8 @@ export const AssignmentResponseSchema = z v: z.literal(1), cellUrl: CanonicalHttpsOriginSchema, assignmentEpoch: GenerationSchema, - lease: SignedAssignmentLeaseSchema + lease: SignedAssignmentLeaseSchema, + regionCorrection: RegionCorrectionResponseSchema.optional() }) .strict() diff --git a/cloud/packages/relay-contract/src/idle-regional-rehome.ts b/cloud/packages/relay-contract/src/idle-regional-rehome.ts new file mode 100644 index 00000000000..9f9eff36593 --- /dev/null +++ b/cloud/packages/relay-contract/src/idle-regional-rehome.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { GenerationSchema, RelayHostIdSchema } from './wire-scalars.js' + +export const IdleRegionalRehomeRequestSchema = z + .object({ + v: z.literal(1), + attemptId: z.string().uuid(), + userId: z.string().min(1).max(256), + relayHostId: RelayHostIdSchema, + sourceCellId: z.string().min(1).max(128), + sourceCellIncarnation: z.string().uuid(), + sourceAssignmentEpoch: GenerationSchema.refine((value) => value > 0), + sourceGeneration: GenerationSchema.refine((value) => value > 0), + targetCellId: z.string().min(1).max(128) + }) + .strict() + +export const IdleRegionalRehomeResponseSchema = z + .object({ + v: z.literal(1), + outcome: z.enum(['busy', 'committed', 'deferred', 'stale']) + }) + .strict() + +export type IdleRegionalRehomeRequest = z.infer +export type IdleRegionalRehomeOutcome = z.infer['outcome'] diff --git a/cloud/packages/relay-contract/src/index.ts b/cloud/packages/relay-contract/src/index.ts index aab3b53b5f3..3b52ec503a1 100644 --- a/cloud/packages/relay-contract/src/index.ts +++ b/cloud/packages/relay-contract/src/index.ts @@ -13,3 +13,5 @@ export * from './resume-confirmation-contract.js' export * from './relay-regions.js' export * from './splice-state-machine.js' export * from './wire-scalars.js' +export * from './region-correction.js' +export * from './idle-regional-rehome.js' diff --git a/cloud/packages/relay-contract/src/region-correction.test.ts b/cloud/packages/relay-contract/src/region-correction.test.ts new file mode 100644 index 00000000000..8c0122341d4 --- /dev/null +++ b/cloud/packages/relay-contract/src/region-correction.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { AssignmentRequestSchema, AssignmentResponseSchema } from './director-messages.js' +import { DrainSchema } from './control-messages.js' +import { RegionCorrectionRequestSchema } from './region-correction.js' + +const report = { + v: 1, + action: 'report', + generation: 3, + assignmentEpoch: 7, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 40, 'asia-east2': 180 } +} +const retention = { + mode: 'finish-existing', + attemptId: '11111111-1111-4111-8111-111111111111', + sourceGeneration: 3, + sourceAssignmentEpoch: 7 +} + +describe('region correction wire boundaries', () => { + it('keeps legacy assignment shapes readable without negotiated fields', () => { + expect( + AssignmentRequestSchema.parse({ v: 1, relayHostId: 'abcdefghijklmnop' }) + ).not.toHaveProperty('regionCorrection') + expect( + AssignmentResponseSchema.parse({ + v: 1, + cellUrl: 'https://cell.example', + assignmentEpoch: 1, + lease: 'synthetic-lease' + }) + ).not.toHaveProperty('regionCorrection') + }) + + it('accepts complete comparison evidence and explicit inconclusive reports', () => { + expect(RegionCorrectionRequestSchema.safeParse(report).success).toBe(true) + const { measurements: _measurements, ...basis } = report + expect( + RegionCorrectionRequestSchema.safeParse({ + ...basis, + outcome: 'inconclusive', + reason: 'probe-unavailable' + }).success + ).toBe(true) + }) + + it.each([ + { measurements: { 'us-central1': 40 } }, + { measurements: { 'us-central1': -1, 'asia-east2': 10 } }, + { measurements: { 'us-central1': Infinity, 'asia-east2': 10 } }, + { measurements: { 'us-central1': 120_001, 'asia-east2': 10 } }, + { generation: Number.MAX_SAFE_INTEGER + 1 }, + { assignmentEpoch: 1.2 }, + { policyVersion: 2 }, + { outcome: 'inconclusive', reason: 'timeout' } + ])('rejects ambiguous or unbounded evidence: %j', (override) => { + expect(RegionCorrectionRequestSchema.safeParse({ ...report, ...override }).success).toBe(false) + }) + + it('rejects reporting and issuing a window in the same request', () => { + expect( + RegionCorrectionRequestSchema.safeParse({ + ...report, + action: 'issue-window' + }).success + ).toBe(false) + }) + + it('uses ordinary drain and rejects the superseded retention extension', () => { + const ordinary = { graceMs: 0, recovery: 'resolve-director' } + expect(DrainSchema.parse(ordinary)).toEqual(ordinary) + expect(DrainSchema.safeParse({ ...ordinary, retention }).success).toBe(false) + }) +}) diff --git a/cloud/packages/relay-contract/src/region-correction.ts b/cloud/packages/relay-contract/src/region-correction.ts new file mode 100644 index 00000000000..5fffca0ad8c --- /dev/null +++ b/cloud/packages/relay-contract/src/region-correction.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import { EpochMsSchema, GenerationSchema } from './wire-scalars.js' +import { RelayRegionSchema } from './relay-regions.js' + +const RttSchema = z.number().finite().nonnegative().max(120_000) +export const RegionMeasurementsSchema = z + .object({ + 'us-central1': RttSchema, + 'asia-east2': RttSchema + }) + .strict() + +export const RegionMeasurementWindowSchema = z + .object({ + generation: GenerationSchema, + expiresAt: EpochMsSchema, + assignmentEpoch: GenerationSchema, + incumbentRegion: RelayRegionSchema, + policyVersion: z.literal(1) + }) + .strict() + +const ReportBasis = { + v: z.literal(1), + action: z.literal('report'), + generation: GenerationSchema, + assignmentEpoch: GenerationSchema, + policyVersion: z.literal(1) +} + +export const RegionCorrectionRequestSchema = z.union([ + z.object({ v: z.literal(1), action: z.literal('issue-window') }).strict(), + z + .object({ + ...ReportBasis, + outcome: z.literal('conclusive'), + measurements: RegionMeasurementsSchema + }) + .strict(), + z + .object({ + ...ReportBasis, + outcome: z.literal('inconclusive'), + reason: z.string().min(1).max(64) + }) + .strict() +]) + +export const RegionCorrectionResponseSchema = z + .object({ + v: z.literal(1), + window: RegionMeasurementWindowSchema.optional(), + reportStatus: z.enum(['accepted', 'duplicate', 'stale', 'expired', 'basis-changed']).optional() + }) + .strict() + +export type RegionMeasurements = z.infer +export type RegionMeasurementWindow = z.infer +export type RegionCorrectionRequest = z.infer +export type RegionCorrectionResponse = z.infer diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 6b36938a3d1..8a5f8c27475 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -7,6 +7,7 @@ const { verifyPackagedDaemonEntryBoots } = require('./scripts/verify-packaged-daemon-entry.cjs') const { + assertPackagedNativeVariantsInstalled, createPackagedRuntimeNodeModuleResources, prunePackagedRuntimeNodeModules, verifyPackagedMainRuntimeDeps @@ -147,6 +148,16 @@ const rpmElectronRuntimeDependencies = [ // config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows. const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx'] +// Why: the config must load on a host-only install without resolving unused Windows addons. +// This is load-time tolerance only; beforePack enforces that the target's natives are installed. +// Why one package: @vscode/windows-process-tree is the only os: win32 npm addon; +// @orca/windows-registry is a workspace link present on every host, so its presence proves nothing. +const windowsRuntimeResources = existsSync( + join(__dirname, '..', 'node_modules', '@vscode', 'windows-process-tree', 'package.json') +) + ? createPackagedRuntimeNodeModuleResources('win32') + : [] + /** @type {import('electron-builder').Configuration} */ module.exports = { appId, @@ -278,6 +289,9 @@ module.exports = { verifyStaticAppImagePackage(file, arch) } }, + beforePack: (context) => { + assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch) + }, afterPack: async (context) => { const resourcesDir = context.electronPlatformName === 'darwin' @@ -319,9 +333,9 @@ module.exports = { // Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node // requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902). // Fail packaging if any bundled native binary exceeds the supported floor. - // Why after the prune: cross-builds intentionally install every optional - // native variant, so an arm64 slice still carries the x64 @parcel/watcher - // until prunePackagedRuntimeNodeModules drops it. + // Why after the prune: `pnpm install:release` widens the CPU set for cross-builds, + // so an arm64 slice can still carry the x64 @parcel/watcher until + // prunePackagedRuntimeNodeModules drops it. if (context.electronPlatformName === 'linux') { // Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary, // so a cross-built slice could ship the host's pty.node and only fail at runtime. @@ -418,7 +432,7 @@ module.exports = { ...(isWinDevChannel ? { verifyUpdateCodeSignature: false } : {}), extraResources: [ ...commonExtraResources, - ...createPackagedRuntimeNodeModuleResources('win32'), + ...windowsRuntimeResources, winSpeechNativeResource, { from: 'resources/win32/bin/orca.cmd', diff --git a/config/oxlint-code-quality-casting.json b/config/oxlint-code-quality-casting.json new file mode 100644 index 00000000000..3db01c3a04d --- /dev/null +++ b/config/oxlint-code-quality-casting.json @@ -0,0 +1,17 @@ +{ + "$schema": "../node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off", + "suspicious": "off", + "pedantic": "off", + "perf": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + "rules": { + "typescript/consistent-type-assertions": ["error", { "assertionStyle": "never" }] + }, + "ignorePatterns": ["**/node_modules", "**/dist", "**/out"] +} diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 1ee443f8288..30784e79f5c 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -35,7 +35,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ ] const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [ '@vscode/windows-process-tree', - 'windows-native-registry' + '@orca/windows-registry' ] const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = { @@ -436,7 +436,7 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electron } // Why: we package every installed @parcel/watcher- optional - // subpackage (supportedArchitectures fetches all), but each build only needs + // subpackage (pnpm install:release fetches every CPU), but each build only needs // its own platform/architecture binaries. Keep the core package and matching // native variants; drop the rest. const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName] @@ -511,6 +511,75 @@ function prunePackagedZodSources(resourcesDir) { rmSync(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true, force: true }) } +// Why: electron-builder only warns on a missing extraResources source, so a host-only +// install would silently ship a foreign-arch slice without its native addons. +function assertPackagedNativeVariantsInstalled(electronPlatformName, electronArch) { + const architecture = normalizeElectronArchitecture(electronArch) + const nodeModulesDir = join(projectDir, 'node_modules') + const isInstalled = (name) => existsSync(join(nodeModulesDir, name, 'package.json')) + const missing = [] + + const rootOptionalDependencies = + JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')).optionalDependencies ?? {} + // Why win32 is always x64: winSpeechNativeResource packages sherpa-onnx-win-x64 for every + // Windows target (there is no sherpa-onnx-win-arm64; it runs under emulation). + const sherpaName = + electronPlatformName === 'win32' + ? 'sherpa-onnx-win-x64' + : `sherpa-onnx-${electronPlatformName}-${architecture}` + if (sherpaName in rootOptionalDependencies && !isInstalled(sherpaName)) { + missing.push(sherpaName) + } + + // Why prefix, not equality: linux variants carry a libc suffix (watcher-linux-x64-glibc), + // mirroring what prunePackagedParcelWatcher keeps. + const watcherPrefix = `watcher-${electronPlatformName}-${architecture}` + const parcelDir = join(nodeModulesDir, '@parcel') + if (isInstalled('@parcel/watcher')) { + const watcherOptionalDependencies = Object.keys( + JSON.parse(readFileSync(join(parcelDir, 'watcher', 'package.json'), 'utf8')) + .optionalDependencies ?? {} + ) + const expectedVariants = watcherOptionalDependencies.filter((name) => + name.startsWith(`@parcel/${watcherPrefix}`) + ) + // Why not withFileTypes: pnpm links the variants, so isDirectory() is false for them. + const hasVariant = readdirSync(parcelDir).some( + (name) => name.startsWith(watcherPrefix) && isInstalled(`@parcel/${name}`) + ) + if (expectedVariants.length > 0 && !hasVariant) { + missing.push(...expectedVariants) + } + } + + // Why one package: @vscode/windows-process-tree is the only os: win32 npm addon; + // @orca/windows-registry is a workspace link present on every host, so its presence proves nothing. + const missingWindowsAddons = [] + if (electronPlatformName === 'win32' && !isInstalled('@vscode/windows-process-tree')) { + missingWindowsAddons.push('@vscode/windows-process-tree') + } + + if (missing.length === 0 && missingWindowsAddons.length === 0) { + return + } + // Why separate remedies: install:release widens only the CPU set, so the os: win32 addon + // never arrives on a non-Windows host and is compiled only by the Windows-only rebuild. + const remedies = [] + if (missing.length > 0) { + remedies.push('Run pnpm install:release to install another architecture.') + } + if (missingWindowsAddons.length > 0) { + remedies.push( + 'Windows packaging requires a Windows host: the Windows addons are installed only where ' + + 'os: win32 matches and compiled only by the Windows-only rebuild.' + ) + } + throw new Error( + `Packaging ${electronPlatformName}/${architecture} requires native variants that are not installed: ` + + `${[...new Set([...missing, ...missingWindowsAddons])].sort().join(', ')}. ${remedies.join(' ')}` + ) +} + function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) { const architecture = normalizeElectronArchitecture(electronArch) prunePackagedNodePty(resourcesDir, electronPlatformName, architecture) @@ -534,6 +603,7 @@ function pruneMatchingFiles(directory, shouldPrune) { module.exports = { PACKAGED_RUNTIME_PACKAGE_ROOTS, + assertPackagedNativeVariantsInstalled, createPackagedRuntimeNodeModuleResources, findAsarEntry, isPackagedExternalSpecifier, diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0ba70ddcf1e..bd402ad883c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-31", + "updatedAt": "2026-09-11", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -10,6 +10,153 @@ } }, "gates": [ + { + "id": "agent-session.journal-streaming-replay", + "title": "Journal replay bounds obsolete revision memory without changing recovery", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session-runtime", + "layer": "runtime-unit", + "surfaces": ["structured chat journal replay", "structured chat recovery"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Production SQLite and reducer tests on macOS. Execution-host-local storage behavior is shared by remote runtimes; no live SSH or Windows/Linux run. PTY, daemon, WSL process launch, transport framing and mobile rendering are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/native-chat/agent-session-journal/journal-open.ts" + ], + "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, repair precedence, read-only schema latching and cursor cleanup while retaining live items rather than all historical bodies.", + "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; preserve prefix and future-schema latching after a gap, malformed suffix repair precedence, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content and recovery.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + ], + "testFiles": [ + "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts", + "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts", + "assertions": [ + "releases superseded revision bodies while reducing a long journal", + "holds no read snapshot while reducing, so a checkpoint can pass mid-replay", + "keeps the prefix but latches read-only for a future row beyond a gap", + "keeps gap repair precedence when a later row is malformed", + "rejects an unanchored prefix before a later gap" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-11", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts", + "result": "passed", + "durationSeconds": 7.71, + "summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Unit fixtures; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local candidate validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Production-function AB/BA benchmark samples 132.6 MB live heap in baseline vs 88-92 KB with streaming on a 66.7 MB revision-heavy journal. The retained-heap unit test fails against the original code and passes with streaming; value and cursor assertions pass in both implementations." + }, + "performanceBudget": { + "required": true, + "evidence": "Paged SQLite reads (one completed statement per page) and immediate reduction retain reduced state plus one page of rows. No cursor or read snapshot outlives its statement, so a WAL checkpoint can pass mid-replay; no new polling, subprocess, provider call or wire change." + }, + "knownGaps": [ + "No Windows/Linux runtime execution or real remote-host validation.", + "Latest live message bodies still require memory proportional to their total size; this removes superseded-history retention, not live-history storage." + ], + "promotionCriteria": ["Retain red/green heap and value assertions and complete CI soak."], + "demotionRule": "Keep experimental until cross-platform and soak evidence; investigate failures without weakening content or memory assertions." + }, + { + "id": "runtime.connection-owned-host-status", + "title": "Host status recovers with its owning connection", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime", + "layer": "service-integration-and-e2e", + "surfaces": [ + "sidebar host status", + "desktop runtime connection", + "browser primary connection" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Real authenticated sockets plus isolated desktop and headless hosts with desktop and browser viewers; deterministic lifecycle tests cover stale results and reader deadlines.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/19163"], + "invariant": "Failed bootstrap and authenticated reconnect converge without UI triggers; one connection owner publishes verified status, with no independent healthy status polling.", + "oracle": "Observe automatic recovery, retained runtime identity on failure, ordered publications, exact request counts, isolated viewer outages, and retirement on disconnect.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/runtime-host-status-owner.test.ts src/main/ipc/runtime-environment-status-recovery.test.ts src/main/ipc/runtime-environment-status-connection.test.ts src/renderer/src/store/slices/runtime-status-snapshot.test.ts src/renderer/src/web/web-runtime-status-owner.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/shared/runtime-host-status-owner.test.ts", + "src/main/ipc/runtime-environment-status-recovery.test.ts", + "src/main/ipc/runtime-environment-status-connection.test.ts", + "src/renderer/src/store/slices/runtime-status-snapshot.test.ts", + "src/renderer/src/web/web-runtime-status-owner.test.ts", + "tests/e2e/runtime-host-status-recovery.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/runtime-environment-status-recovery.test.ts", + "assertions": [ + "recovers a saved host after its first status check fails, without another UI request" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "summary": "Desktop-host and headless-host journeys passed with desktop and browser viewers.", + "durationSeconds": 31.3 + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "Target excluding builds; measured p95 not established." + }, + "flakeHistory": { + "status": "soaking", + "evidence": "Local candidate runs passed; no sustained CI history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "First-status-failure oracle failed on main 58ff95becb40 (one request instead of two) and passes on the candidate. E2E verifies candidate recovery, not a baseline comparison." + }, + "performanceBudget": { + "required": false, + "evidence": "Deterministic tests assert one shared request and no healthy owner polling." + }, + "promotionCriteria": ["Collect repeated CI runs without unexplained flakes."], + "knownGaps": [ + "No live Linux, Windows, SSH, or mixed-version pair validation.", + "TCP interruption exercises reconnect, not a full real host process restart.", + "The outage begins on the first saved-host check, not by relaunching a preseeded desktop profile." + ], + "demotionRule": "Keep experimental until repeated runs establish reliability; preserve request-count and lifecycle assertions." + }, { "id": "mobile-push.headless-startup-and-policy", "title": "Headless push lifecycle and mobile delivery policy", @@ -2852,6 +2999,78 @@ ], "demotionRule": "Keep experimental or demote if assignment calls overlap, duplicate drain events bypass backoff, Retry-After is ignored, close resurrects work, or mixed-version request rate exceeds the reviewed director budget." }, + { + "id": "desktop-relay.region-correction-idle-cutover", + "title": "Regional correction moves only an idle relay source", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-runtime", + "layer": "cell-desktop-real-websocket", + "surfaces": ["regional correction", "idle source cutover", "desktop relay reconnect"], + "platforms": ["macos", "linux", "windows"], + "providers": ["cloud-relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["cloud-relay"], + "coverageNotes": "Real local WebSocket/control/proof/splice traffic and production SQLite store run with synthetic clock and synthetic token verification. Separate PostgreSQL16 suites validate SQL concurrency. This does not measure production network latency, physical phones, UI, or the production token issuer.", + "motivatingLinks": [ + "docs/relay-region-correction/RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md" + ], + "invariant": "Regional optimization never closes an established relay client. The source gates admissions only after actual work is idle, commits the exact assignment atomically, and releases its empty control. A failed target uses ordinary migration recovery; previously sent mutations are not replayed.", + "oracle": "Two real TCP WebSocket cells, the actual desktop origin pool, SQLite and an independent execution child verify busy phone/iPad deferral, idle movement, racing arrival rejection, definite-abort admission recovery and observed target-registration failure with ordinary rollback.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/relay-region-correction.unit.test.ts" + ], + "testFiles": ["tests/e2e/relay-region-correction.unit.test.ts"], + "assertionRefs": [ + { + "file": "tests/e2e/relay-region-correction.unit.test.ts", + "assertions": [ + "releases the empty source and recovers normally when the target never registers", + "rejects an arrival during cutover and restores admissions after a definite failed commit", + "defers for either connected device, then moves after both disconnect without replaying work" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-11", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/relay-region-correction.unit.test.ts", + "result": "passed", + "durationSeconds": 6.44, + "summary": "Three real TCP WebSocket cases passed after removing store retention. Log: .tmp/idle-cutover-review/transport-without-retention.log. Does not validate packaged or physical clients." + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "three local real-WebSocket scenarios with synthetic elapsed time for ordinary recovery" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local implementation validation; no CI soak history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Registry admission accounting negative control and five conflicting operation-identity regressions fail before their fixes and pass afterward. Locked database reconciliation has separate red/green evidence. Full cross-layer counterfactual remains unverified." + }, + "performanceBudget": { + "required": true, + "evidence": "No retained source lease or new mobile timer. Candidate selection is read-only; busy sources immediately defer. Idle cutover reuses normal desktop reconnect and existing migration recovery." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak.", + "Complete mixed-version and packaged-client validation.", + "Validate bounded rollout latency and reliability against reviewed numerical limits." + ], + "knownGaps": [ + "Production authentication verifier is mocked.", + "Synthetic elapsed time is not a wall-clock soak.", + "Physical phone lifecycle, packaged mixed versions, SSH execution and production network behavior require separate validation." + ], + "demotionRule": "Keep experimental or demote if optimization closes an established client, a gate reopens on ambiguous authority, a mutation is replayed, cleanup is lost, or eligible idle hosts starve." + }, + { "id": "git-worktree.refresh-event-semantics", "title": "Index-only Git metadata cannot trigger structural worktree refresh fanout", @@ -15397,6 +15616,93 @@ ], "demotionRule": "Cannot promote without metric artifacts and stable p95 runtime history." }, + { + "id": "terminal-performance.daemon-ndjson-wire-parity-and-serialization", + "title": "Daemon NDJSON preserves wire bytes within a serialization count budget", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-performance", + "layer": "daemon-provider-contract", + "surfaces": ["daemon stream", "NDJSON framing", "stream data batching"], + "platforms": ["macos", "linux", "windows"], + "providers": ["daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["daemon"], + "coverageNotes": "Deterministic writer, batcher, droppability and NDJSON suites run locally on macOS with mocked socket/process boundaries. An SSH-shaped session ID is a string fixture, not SSH transport evidence. No live daemon, platform integration or mixed-version client/host pair is exercised; folder and git workspaces are not distinguished by these stream contracts.", + "motivatingLinks": ["src/main/daemon/daemon-stream-data-split.ts"], + "invariant": "Reusing an encoded unsplit metadata-free frame must preserve the previous writer's exact wire bytes and chunk boundaries while reducing that path to one encode; oversized and metadata-bearing writes retain existing semantics and transformed writes remain uncapped single frames.", + "oracle": "Compare emitted lines to the previous writer algorithm across byte caps, escaped and Unicode payloads, session IDs, raw lengths, sequence numbers and transformed spans; assert exact newline framing, reconstruct split payloads, check surrogate boundaries and sequence spans, and count encodeNdjson calls.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/daemon-stream-data-split.test.ts src/main/daemon/daemon-stream-data-batcher.test.ts src/main/daemon/daemon-stream-droppable-membership.test.ts src/main/daemon/daemon-stream-droppability-lifecycle.test.ts src/main/daemon/ndjson.test.ts" + ], + "testFiles": [ + "src/main/daemon/daemon-stream-data-split.test.ts", + "src/main/daemon/daemon-stream-data-batcher.test.ts", + "src/main/daemon/daemon-stream-droppable-membership.test.ts", + "src/main/daemon/daemon-stream-droppability-lifecycle.test.ts", + "src/main/daemon/ndjson.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/daemon-stream-data-split.test.ts", + "assertions": [ + "encodes an unsplit metadata-free frame once: %j", + "reuses the encoded frame exactly at the inclusive byte cap", + "does not add a duplicate full-data sizing probe to oversized writes", + "keeps transformed writes at one encode without applying the ordinary byte cap", + "preserves exact frames, chunk boundaries and metadata across payloads and caps", + "keeps JSON escaping, Unicode and newline framing byte-for-byte", + "keeps split frames within the byte cap and preserves code points and sequence spans" + ] + }, + { + "file": "src/main/daemon/daemon-stream-data-batcher.test.ts", + "assertions": ["writes large stream data as parser-sized NDJSON events"] + }, + { + "file": "src/main/daemon/ndjson.test.ts", + "assertions": ["measures multibyte payloads in UTF-8 bytes, not characters"] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-11", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/daemon-stream-data-split.test.ts src/main/daemon/daemon-stream-data-batcher.test.ts src/main/daemon/daemon-stream-droppable-membership.test.ts src/main/daemon/daemon-stream-droppability-lifecycle.test.ts src/main/daemon/ndjson.test.ts", + "result": "passed", + "summary": "Five daemon suites passed: 70 tests total, including the new 12-test splitter suite. Vitest reported 14.76 seconds; no app or live transport validation was performed.", + "durationSeconds": 14.76 + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "Target budget for the five deterministic suites; one local Vitest run took 14.76 seconds, not an established p95. Native-runtime setup is excluded from the reported Vitest duration." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Fresh local five-suite run passed; no sustained CI soak history is established." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Historical report states serialization-count tests failed against the old writer. That baseline run was not repeated for this registration; the fresh candidate run passed all 70 tests. The previous-writer oracle checks byte parity, not live cross-version compatibility." + }, + "performanceBudget": { + "required": true, + "evidence": "Unsplit metadata-free frames, including the inclusive byte-cap boundary, require exactly one encodeNdjson call; oversized writes must not exceed the previous writer's encode count; transformed writes require one encode. These are deterministic call-count budgets, not measured throughput, CPU, heap or input-latency improvements." + }, + "promotionCriteria": [ + "Capture reproducible old-writer red and candidate green artifacts for the serialization-count assertions.", + "Collect CI soak evidence before promotion and validate Linux/Windows runtimes and live SSH/remote and mixed-version pairs before claiming those integrations." + ], + "knownGaps": [ + "No live daemon/Electron, Linux, Windows, WSL, SSH, remote-runtime or mixed-version client/host evidence.", + "The historical red run has not been independently reproduced for this registration; the parity oracle reuses current splitter/encoder helpers.", + "No sustained soak, measured p95, wall-clock performance benchmark or native socket backpressure guarantee.", + "Byte-cap assertions cover ordinary split frames at a viable cap; tiny caps and transformed frames retain legacy behavior rather than gaining a universal cap guarantee." + ], + "demotionRule": "Keep experimental until reproducible red/green and soak evidence exist; do not relax exact wire-byte, chunk-boundary or encode-count assertions to hide regressions." + }, { "id": "terminal-performance.daemon-stream-backpressure", "title": "Daemon terminal streams respect socket backpressure under output floods", diff --git a/config/scripts/agent-lineage-cycle-cleanup-benchmark.mjs b/config/scripts/agent-lineage-cycle-cleanup-benchmark.mjs new file mode 100644 index 00000000000..47407764e0f --- /dev/null +++ b/config/scripts/agent-lineage-cycle-cleanup-benchmark.mjs @@ -0,0 +1,90 @@ +// Run: node --expose-gc config/scripts/agent-lineage-cycle-cleanup-benchmark.mjs [baseline-ref] +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { performance } from 'node:perf_hooks' +import { transform } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const sourcePath = 'src/renderer/src/components/dashboard/agent-row-lineage-model.ts' +const baseline = process.argv[2] ?? '20ab9950654' +const beforeSource = execFileSync('git', ['show', `${baseline}:${sourcePath}`], { + encoding: 'utf8', + windowsHide: true +}) +async function load(source) { + const { code } = await transform(source, { loader: 'ts', format: 'esm' }) + return (await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)) + .buildAgentRowLineageTree +} +const before = await load(beforeSource) +const after = await load(await readFile(sourcePath, 'utf8')) + +function row(index, parent) { + return { + paneKey: `pane-${index}`, + entry: { + terminalHandle: `term-${index}`, + orchestration: parent === undefined ? undefined : { parentPaneKey: `pane-${parent}` } + } + } +} + +// Exercise duplicate keys, disconnected cycles, missing parents, and handle fallback. +let seed = 7391 +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max +} +for (let sample = 0; sample < 500; sample++) { + const rows = Array.from({ length: 40 }, () => { + const value = row(random(30), random(40)) + value.entry.orchestration.parentTerminalHandle = `term-${random(40)}` + value.entry.orchestration.coordinatorHandle = `term-${random(40)}` + return value + }) + if (sample % 2 === 0) { + rows.unshift(row('root', undefined)) + } + assert.deepEqual(after(rows), before(rows)) +} + +const results = [] +for (const [shape, count] of [ + ['flat', 1000], + ['all-cycles', 1000], + ['mixed-cycles', 100], + ['mixed-cycles', 500], + ['mixed-cycles', 1000] +]) { + const rows = Array.from({ length: count }, (_, index) => + row(index, shape === 'flat' ? undefined : index ^ 1) + ) + if (shape === 'mixed-cycles') { + rows.unshift(row('root', undefined)) + } + assert.deepEqual(after(rows), before(rows)) + for (let warmup = 0; warmup < 30; warmup++) { + before(rows) + after(rows) + } + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + global.gc?.() + const run = arm === 'before' ? before : after + const cpu = process.cpuUsage() + const start = performance.now() + for (let iteration = 0; iteration < 30; iteration++) { + run(rows) + } + const wallMs = (performance.now() - start) / 30 + const used = process.cpuUsage(cpu) + samples[arm].push({ wallMs, cpuMs: (used.user + used.system) / 30_000 }) + } + } + results.push({ shape, count, samples }) +} +console.log( + JSON.stringify({ baseline, node: process.version, parityGraphs: 500, results }, null, 2) +) diff --git a/config/scripts/agent-lineage-reachability-benchmark.mjs b/config/scripts/agent-lineage-reachability-benchmark.mjs new file mode 100644 index 00000000000..4122ac18dab --- /dev/null +++ b/config/scripts/agent-lineage-reachability-benchmark.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { transform } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +// git show :src/renderer/src/components/dashboard/agent-row-lineage-model.ts | node config/scripts/agent-lineage-reachability-benchmark.mjs +async function load(source) { + const { code } = await transform(source, { loader: 'ts', format: 'esm' }) + return (await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)) + .buildAgentRowLineageTree +} +const implementations = { + before: await load(readFileSync(0, 'utf8')), + after: await load( + readFileSync('src/renderer/src/components/dashboard/agent-row-lineage-model.ts', 'utf8') + ) +} +function orderedTree(tree) { + return { + roots: tree.rootRows, + children: [...tree.childrenByParentPaneKey], + childKeys: [...tree.childPaneKeys] + } +} + +let seed = 42 +const random = (max) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return Math.floor((seed / 2 ** 32) * max) +} +let differentialCases = 0 +for (let trial = 0; trial < 5000; trial += 1) { + const count = random(100) + const rows = Object.freeze( + Array.from({ length: count }, (_, index) => + Object.freeze({ + paneKey: `pane-${random(count + 4)}`, + index, + entry: Object.freeze({ + terminalHandle: random(2) ? `term-${random(count)}` : undefined, + orchestration: Object.freeze({ + parentPaneKey: random(3) ? `pane-${random(count + 4)}` : undefined, + parentTerminalHandle: random(2) ? `term-${random(count)}` : undefined, + coordinatorHandle: random(2) ? `term-${random(count)}` : undefined + }) + }) + }) + ) + ) + assert.deepEqual( + orderedTree(implementations.after(rows)), + orderedTree(implementations.before(rows)) + ) + differentialCases += 1 +} + +const results = [] +for (const count of [8, 32, 128, 512, 1024]) { + for (const shape of ['flat', 'fanout', 'balanced', 'chain']) { + const rows = Array.from({ length: count }, (_, index) => { + const parent = + shape === 'fanout' ? 0 : shape === 'balanced' ? Math.floor((index - 1) / 4) : index - 1 + return { + paneKey: `pane-${index}`, + entry: { + orchestration: + index > 0 && shape !== 'flat' ? { parentPaneKey: `pane-${parent}` } : undefined + } + } + }) + const expected = orderedTree(implementations.before(rows)) + assert.deepEqual(orderedTree(implementations.after(rows)), expected) + const iterations = Math.max(5, Math.floor(10_000 / count)) + for (let warmup = 0; warmup < 20; warmup += 1) { + implementations.before(rows) + implementations.after(rows) + } + /** @type {{ before: number[], after: number[] }} */ + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + let result + const started = performance.now() + for (let repeat = 0; repeat < iterations; repeat += 1) { + result = implementations[arm](rows) + } + samples[arm].push(performance.now() - started) + assert.deepEqual(orderedTree(result), expected) + } + } + results.push({ + count, + shape, + iterations, + meanMicrosecondsPerTree: Object.fromEntries( + Object.entries(samples).map(([arm, values]) => [ + arm, + (values.reduce((sum, ms) => sum + ms, 0) * 1000) / values.length / iterations + ]) + ), + before: summarizeBenchmarkSamples(samples.before), + after: summarizeBenchmarkSamples(samples.after) + }) + } +} +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases, results }, + null, + 2 + ) +) diff --git a/config/scripts/build-native-for-platform.mjs b/config/scripts/build-native-for-platform.mjs index d6294f4b5c7..c8817ab864b 100755 --- a/config/scripts/build-native-for-platform.mjs +++ b/config/scripts/build-native-for-platform.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' if (process.platform === 'win32') { @@ -13,21 +13,184 @@ if (process.platform !== 'darwin') { process.exit(0) } -runPnpmScript('build:computer-macos') -runPnpmScript('build:keyboard-layout-macos') -runPnpmScript('build:notification-status-macos') -process.exit(0) +// Each compiler tree needs its own group so cancellation reaches Swift descendants. +const children = new Map() +let externalSignal = null +let stopping = false +let outputFailed = false +// Status of the child whose failure started cancellation; siblings we stop are not failures. +let firstFailure = null +let forceTimer +const signalHandlers = new Map() + +process.on('SIGINT', handlerFor('SIGINT')) +process.on('SIGTERM', handlerFor('SIGTERM')) +// Own sessions do not see a terminal hangup; forward it so compilers do not outlive the shell. +process.on('SIGHUP', handlerFor('SIGHUP')) +for (const target of [process.stdout, process.stderr]) { + target.on('error', () => { + outputFailed = true + process.exitCode = 1 + stopBuilds() + }) +} + +const exitCodes = await Promise.all( + ['build:computer-macos', 'build:keyboard-layout-macos', 'build:notification-status-macos'].map( + (scriptName) => runPnpmScript(scriptName) + ) +) +clearTimeout(forceTimer) +for (const [signal, handler] of signalHandlers) { + process.removeListener(signal, handler) +} +if (externalSignal) { + process.kill(process.pid, externalSignal) +} else if (firstFailure?.signal) { + // Node ignores some signals (SIGPIPE); the build still failed if the re-raise is a no-op. + process.exitCode = 1 + process.kill(process.pid, firstFailure.signal) +} else { + process.exitCode = firstFailure?.code ?? Math.max(outputFailed ? 1 : 0, ...exitCodes) +} + +function handlerFor(signal) { + if (!signalHandlers.has(signal)) { + signalHandlers.set(signal, () => { + externalSignal ??= signal + stopBuilds(signal) + }) + } + return signalHandlers.get(signal) +} + +function stopBuilds(signal = 'SIGTERM') { + if (stopping) { + return + } + stopping = true + terminateAll(signal) + if (children.size > 0) { + forceTimer ??= setTimeout(() => terminateAll('SIGKILL'), 2_000) + } +} + +function terminateAll(signal) { + for (const [child, label] of children) { + if (!child.pid) { + continue + } + console.log(`[native-build] stopping ${label} (${signal})`) + try { + process.kill(-child.pid, signal) + } catch { + // group already gone + try { + child.kill(signal) + } catch {} + } + } +} function runPnpmScript(scriptName) { + if (stopping) { + return Promise.resolve(1) + } + const label = scriptName.replace(/^build:|-macos$/g, '') const { command, prefixArgs, shell } = resolvePnpmCliInvocation() - const result = spawnSync(command, [...prefixArgs, 'run', scriptName], { stdio: 'inherit', shell }) + const child = spawn(command, [...prefixArgs, 'run', scriptName], { + detached: true, + shell, + stdio: ['ignore', 'pipe', 'pipe'] + }) + children.set(child, scriptName) + pipePrefixed(child.stdout, label, process.stdout) + pipePrefixed(child.stderr, label, process.stderr) - if (result.signal) { - process.kill(process.pid, result.signal) - } - if (result.status !== 0 || result.error) { - process.exit(result.status ?? 1) - } + return new Promise((resolve) => { + let failed = false + child.on('error', (error) => { + failed = true + console.error(`[${label}] ${error.message}`) + if (!stopping) { + firstFailure = { code: 1, signal: null } + } + stopBuilds() + }) + let exited = false + let closeTimer + // A descendant that inherited the pipes must not hold the launcher open forever. + const armReap = () => { + clearTimeout(closeTimer) + // A backpressure pause also delays 'close'; only count time spent actually draining. + if (child.stdout.isPaused() || child.stderr.isPaused()) { + return + } + closeTimer = setTimeout(() => { + console.error(`[native-build] ${label} left descendants holding its output; reaping them`) + try { + process.kill(-child.pid, 'SIGKILL') + } catch {} + child.stdout.destroy() + child.stderr.destroy() + }, 2_000) + } + for (const stream of [child.stdout, child.stderr]) { + stream.on('pause', () => clearTimeout(closeTimer)) + stream.on('resume', () => { + if (exited) { + armReap() + } + }) + } + child.on('exit', (code, signal) => { + if (code !== 0 || signal) { + if (!stopping) { + firstFailure = { code: code ?? 1, signal } + } + stopBuilds() + } + exited = true + armReap() + }) + // Re-raise the parent's signal only after every child and its output pipes close. + child.on('close', (code, signal) => { + clearTimeout(closeTimer) + children.delete(child) + resolve(failed || signal ? 1 : (code ?? 1)) + }) + }) +} + +function pipePrefixed(stream, label, target) { + stream.setEncoding('utf8') + let buffer = '' + stream.on('data', (chunk) => { + if (target.destroyed) { + return + } + buffer += chunk + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + target.write(`[${label}] ${line}\n`) + } + if (target.writableNeedDrain) { + stream.pause() + const resume = () => { + target.off('drain', resume) + target.off('close', resume) + stream.resume() + } + target.once('drain', resume) + target.once('close', resume) + } + }) + stream.on('end', () => { + if (buffer.length > 0 && !target.destroyed) { + target.write(`[${label}] ${buffer}\n`) + } + }) } function runNodeScript(scriptPath) { diff --git a/config/scripts/build-native-for-platform.test.mjs b/config/scripts/build-native-for-platform.test.mjs new file mode 100644 index 00000000000..ce15aa54dc6 --- /dev/null +++ b/config/scripts/build-native-for-platform.test.mjs @@ -0,0 +1,361 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { spawnProcess } from '../../src/shared/child-process/run-process' + +const buildScript = fileURLToPath(new URL('./build-native-for-platform.mjs', import.meta.url)) +const directories = [] +const children = [] +const buildPids = new Set() + +afterEach(() => { + for (const child of children.splice(0)) { + child.kill('SIGKILL') + } + for (const pid of buildPids) { + try { + process.kill(-pid, 'SIGKILL') + } catch {} + } + buildPids.clear() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function startBuild(mode, options = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-native-build-test-')) + directories.push(directory) + const cli = join(directory, 'fake-pnpm.mjs') + const journal = join(directory, 'events.jsonl') + writeFileSync(journal, '') + mkdirSync(join(directory, 'config', 'scripts'), { recursive: true }) + writeFileSync( + join(directory, 'config', 'scripts', 'build-windows-cli-launcher.mjs'), + "console.log('windows launcher only')" + ) + writeFileSync( + cli, + ` + import { appendFileSync, existsSync } from 'node:fs' + import { spawn } from 'node:child_process' + const name = process.argv.at(-1) + const delay = name.includes('computer') ? 0 : name.includes('keyboard') ? 200 : 400 + const record = (event, extra = {}) => appendFileSync(process.env.NATIVE_BUILD_JOURNAL, JSON.stringify({ name, event, pid: process.pid, ...extra }) + '\\n') + const finish = (signal) => { record(signal); process.exit(process.env.NATIVE_BUILD_MODE === 'failure-status' ? 9 : 0) } + if (process.env.NATIVE_BUILD_MODE !== 'signal-default') process.on('SIGTERM', () => { if (process.env.NATIVE_BUILD_MODE !== 'ignore') setTimeout(() => finish('SIGTERM'), delay) }) + if (process.env.NATIVE_BUILD_MODE !== 'signal-default' || !name.includes('computer')) process.on('SIGINT', () => setTimeout(() => finish('SIGINT'), delay)) + process.on('SIGHUP', () => setTimeout(() => finish('SIGHUP'), delay)) + record('started') + process.stdout.write('ready ' + process.pid + '\\n') + if (process.env.NATIVE_BUILD_MODE.startsWith('descendant')) { + spawn(process.execPath, ['-e', ${JSON.stringify("process.on('SIGTERM', () => {}); console.log('descendant ' + process.pid); setInterval(() => {}, 1000)")}], { stdio: 'inherit' }) + } + let flooding = false + setInterval(() => { + if (!existsSync(process.env.NATIVE_BUILD_GATE)) return + if (process.env.NATIVE_BUILD_MODE.startsWith('output-closed-')) { + const target = process.env.NATIVE_BUILD_MODE.endsWith('stderr') ? process.stderr : process.stdout + if (name.includes('computer')) target.write('compiler progress\\n') + return + } + if (process.env.NATIVE_BUILD_MODE === 'flood') { + if (flooding) return + flooding = true + const chunk = 'f'.repeat(65535) + '\\n' + const pump = () => { while (process.stdout.write(chunk)) {} ; process.stdout.once('drain', pump) } + pump() + return + } + if (['success', 'descendant-success'].includes(process.env.NATIVE_BUILD_MODE)) { record('completed'); process.exit(0) } + if (process.env.NATIVE_BUILD_MODE === 'stalled-consumer') { + if (!name.includes('computer') || existsSync(process.env.NATIVE_BUILD_GATE + '-exit')) { record('completed'); process.exit(0) } + if (flooding) return + flooding = true + // Each callback means the kernel pipe accepted the line, so it survives our exit. + const pump = (line) => process.stdout.write('line ' + line + ' ' + 'x'.repeat(190) + '\\n', () => { record('accepted', { line }); pump(line + 1) }) + pump(1) + return + } + if (!name.includes('computer')) return + record('failed') + if (process.env.NATIVE_BUILD_MODE === 'failure-signal') process.kill(process.pid, 'SIGALRM') + else if (process.env.NATIVE_BUILD_MODE === 'failure-sigpipe') { process.on('SIGPIPE', () => {}); process.removeAllListeners('SIGPIPE'); process.kill(process.pid, 'SIGPIPE') } + else process.exit(7) + }, 10) + ` + ) + const child = spawnProcess({ + program: process.execPath, + args: [ + ...(options.platform + ? [ + '--import', + `data:text/javascript,${encodeURIComponent(`Object.defineProperty(process, 'platform', { value: '${options.platform}' })`)}` + ] + : []), + ...(options.reportBuffered + ? [ + '--import', + `data:text/javascript,${encodeURIComponent(`import { appendFileSync } from 'node:fs'; setInterval(() => appendFileSync(process.env.NATIVE_BUILD_JOURNAL, JSON.stringify({ name: 'launcher', event: 'buffered', bytes: process.stdout.writableLength }) + '\\n'), 50).unref()`)}` + ] + : []), + ...(options.lateOutputError + ? [ + '--import', + `data:text/javascript,${encodeURIComponent(`process.once('beforeExit', () => process.${options.lateOutputError}.emit('error', new Error('late output failure')))`)}` + ] + : []), + buildScript + ], + cwd: directory, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + npm_execpath: options.missingCli ? join(directory, 'missing-pnpm') : cli, + NATIVE_BUILD_JOURNAL: journal, + NATIVE_BUILD_GATE: join(directory, 'release'), + NATIVE_BUILD_MODE: mode + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + children.push(child) + let output = '' + let stderr = '' + let descendantPids = [] + let readyResolve + const ready = new Promise((resolve) => { + readyResolve = resolve + }) + child.stdout.on('data', (chunk) => { + output += chunk.toString() + const pids = [...output.matchAll(/ready (\d+)/g)].map((match) => Number(match[1])) + descendantPids = [...output.matchAll(/descendant (\d+)/g)].map((match) => Number(match[1])) + for (const pid of pids) { + buildPids.add(pid) + } + if (pids.length === 3 && (!mode.startsWith('descendant') || descendantPids.length === 3)) { + readyResolve() + } + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + const closed = new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (code, signal) => resolve({ code, signal, output, stderr })) + }) + return { + child, + ready, + closed, + descendants: () => descendantPids, + release: () => writeFileSync(join(directory, 'release'), ''), + releaseExit: () => writeFileSync(join(directory, 'release-exit'), ''), + events: () => + readFileSync(journal, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + } +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function waitFor(condition, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs + while (!condition()) { + if (Date.now() > deadline) { + throw new Error('timed out waiting for condition') + } + await sleep(50) + } +} + +describe.skipIf(process.platform !== 'darwin')('parallel native builds', () => { + it('starts every independent build before any completes', async () => { + const build = startBuild('success') + await build.ready + expect(build.events().map(({ event }) => event)).toEqual(['started', 'started', 'started']) + build.release() + expect(await build.closed).toMatchObject({ code: 0, signal: null }) + expect(build.events().filter(({ event }) => event === 'completed')).toHaveLength(3) + }) + + it.each(['SIGINT', 'SIGTERM', 'SIGHUP'])( + 'waits for every sibling before re-raising %s', + async (signal) => { + const build = startBuild('signal') + await build.ready + build.child.kill(signal) + expect(await build.closed).toMatchObject({ code: null, signal }) + expect(build.events().filter(({ event }) => event === signal)).toHaveLength(3) + } + ) + + it('waits for sibling cancellation when a build fails', async () => { + const build = startBuild('failure') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 7, signal: null }) + expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2) + }) + + it('reports the first failure, not the status of siblings it cancelled', async () => { + const build = startBuild('failure-status') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 7, signal: null }) + expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2) + }) + + it('re-raises the signal that killed a build', async () => { + const build = startBuild('failure-signal') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: null, signal: 'SIGALRM' }) + expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2) + }) + + it('fails when the signal that killed a build is one the launcher ignores', async () => { + const build = startBuild('failure-sigpipe') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 1, signal: null }) + expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2) + }) + + it('lets siblings finish SIGINT cleanup when one child uses the default handler', async () => { + const build = startBuild('signal-default') + await build.ready + build.child.kill('SIGINT') + expect(await build.closed).toMatchObject({ code: null, signal: 'SIGINT' }) + expect(build.events().filter(({ event }) => event === 'SIGINT')).toHaveLength(2) + }) + + it('forces a sibling that ignores graceful cancellation to exit', async () => { + const build = startBuild('ignore') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 7, signal: null }) + for (const { pid } of build.events().filter(({ event }) => event === 'started')) { + expect(() => process.kill(pid, 0)).toThrow() + } + }) + + it.each(['stdout', 'stderr'])( + 'stops quiet siblings when the %s consumer closes', + async (target) => { + const build = startBuild(`output-closed-${target}`) + await build.ready + build.child[target].destroy() + build.release() + + const result = await build.closed + expect(result).toMatchObject({ code: 1, signal: null }) + expect(result.stderr).not.toContain('Unhandled') + for (const { pid } of build.events().filter(({ event }) => event === 'started')) { + expect(() => process.kill(pid, 0)).toThrow() + } + } + ) + + it.each(['stdout', 'stderr'])( + 'fails on a late %s error after successful child exits', + async (target) => { + const build = startBuild('success', { lateOutputError: target }) + await build.ready + build.release() + + const result = await build.closed + expect(result).toMatchObject({ code: 1, signal: null }) + expect(result.stderr).not.toContain('Unhandled') + expect(build.events().filter(({ event }) => event === 'completed')).toHaveLength(3) + } + ) + + it('reports a missing build command without waiting forever', async () => { + const build = startBuild('success', { missingCli: true }) + expect(await build.closed).toMatchObject({ code: 1, signal: null }) + }) + + it('reaps compiler descendants that retain pipes after their launcher exits', async () => { + const build = startBuild('descendant') + await build.ready + build.child.kill('SIGTERM') + expect(await build.closed).toMatchObject({ code: null, signal: 'SIGTERM' }) + for (const pid of build.descendants()) { + expect(() => process.kill(pid, 0)).toThrow() + } + }) + + it("reaps descendants that keep a finished build's pipes open instead of hanging", async () => { + const build = startBuild('descendant-success') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 0, signal: null }) + expect(build.events().filter(({ event }) => event === 'completed')).toHaveLength(3) + for (const pid of build.descendants()) { + expect(() => process.kill(pid, 0)).toThrow() + } + }) + + it('stops reading compiler output while its own stdout is blocked', async () => { + const build = startBuild('flood', { reportBuffered: true }) + await build.ready + build.child.stdout.pause() + build.release() + await new Promise((resolve) => setTimeout(resolve, 1_500)) + const buffered = build + .events() + .filter(({ event }) => event === 'buffered') + .map(({ bytes }) => bytes) + expect(buffered.length).toBeGreaterThan(0) + expect(Math.max(...buffered)).toBeLessThan(1_000_000) + }) + + it('delivers every compiler line when its own stdout consumer stalls past the reap timeout', async () => { + const build = startBuild('stalled-consumer', { reportBuffered: true }) + await build.ready + build.child.stdout.pause() + build.release() + // Launcher stops reading once its stdout hits the high-water mark; then let the compiler fill its pipe. + await waitFor(() => + build.events().some(({ event, bytes }) => event === 'buffered' && bytes >= 16_384) + ) + await sleep(300) + build.releaseExit() + await waitFor(() => build.events().some(({ event }) => event === 'completed')) + const accepted = Math.max( + ...build + .events() + .filter(({ event }) => event === 'accepted') + .map(({ line }) => line) + ) + expect(accepted).toBeGreaterThan(0) + await sleep(3_000) + build.child.stdout.resume() + + const result = await build.closed + expect(result).toMatchObject({ code: 0, signal: null }) + const delivered = [...result.output.matchAll(/^\[computer\] line (\d+) /gm)].map((match) => + Number(match[1]) + ) + expect(delivered).toEqual(Array.from({ length: accepted }, (_, index) => index + 1)) + }) + + it.each(['linux', 'win32'])('keeps the %s entry point out of macOS builds', async (platform) => { + const build = startBuild('success', { platform }) + const result = await build.closed + expect(result).toMatchObject({ code: 0, signal: null }) + expect(build.events()).toEqual([]) + expect(result.output).toContain( + platform === 'win32' + ? 'windows launcher only' + : 'no macOS native computer build required on linux' + ) + }) +}) diff --git a/config/scripts/capture-agent-pty-transcript.mjs b/config/scripts/capture-agent-pty-transcript.mjs new file mode 100644 index 00000000000..a60d0adbdd5 --- /dev/null +++ b/config/scripts/capture-agent-pty-transcript.mjs @@ -0,0 +1,283 @@ +/** + * Records a live agent CLI session through a real PTY into a test fixture, bytes intact. + * + * Why a PTY and not `agy | tee`: a pipe is not a terminal, so the CLI renders its + * non-interactive path — no alternate screen, no caret, no dialogs. The detector under + * test only ever sees the PTY shape, so that is the only shape worth capturing. + * + * Nothing here strips escapes, folds CRs, or rewraps lines: the transcript is written + * exactly as the terminal received it. See docs/reference/agent-pty-transcript-capture.md. + */ +import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + formatFindings, + redactTranscript, + scanTranscriptForSecrets +} from './pty-transcript-secret-scan.mjs' + +const REPO_ROOT = resolve(import.meta.dirname, '..', '..') +const FIXTURE_DIR = join(REPO_ROOT, 'src', 'main', 'runtime', '__fixtures__') +const STOP_KEY = 0x1d // Ctrl-], consumed by the recorder and never forwarded to the agent. +const NAME_RE = /^[a-z0-9][a-z0-9-]*$/ + +const USAGE = `Capture a raw agent PTY transcript into src/main/runtime/__fixtures__/. + + node config/scripts/capture-agent-pty-transcript.mjs --name [options] -- [args...] + node config/scripts/capture-agent-pty-transcript.mjs --scan [--redact] + +Options + --name Output fixture name, e.g. antigravity-ready-personal-non-gemini + --out Write somewhere other than the fixture directory + --cols --rows Pin the PTY size (default: this terminal's size, else 120x40) + --duration Stop unattended after N seconds + --send ":" Type into the PTY at (repeatable; \\r \\n \\t \\e escapes) + --note "" Recorded in the .meta.json sidecar + --scan Scan existing transcripts for identifiers/credentials and exit + --redact With --scan: rewrite each finding as a same-length placeholder + +Press Ctrl-] to end a capture. That key is consumed here, so the agent keeps whatever +dialog it is showing — which is the only way to capture a dialog that owns the screen.` + +function parseArgs(argv) { + const options = { cols: null, rows: null, duration: null, scan: [], sends: [], redact: false } + const command = [] + let cursor = 0 + let afterSeparator = false + while (cursor < argv.length) { + const arg = argv[cursor] + if (afterSeparator) { + command.push(arg) + cursor += 1 + continue + } + if (arg === '--') { + afterSeparator = true + } else if (arg === '--redact') { + options.redact = true + } else if (arg === '--help' || arg === '-h') { + options.help = true + } else if (arg === '--scan') { + while (cursor + 1 < argv.length && !argv[cursor + 1].startsWith('--')) { + cursor += 1 + options.scan.push(argv[cursor]) + } + } else if (arg === '--send') { + cursor += 1 + options.sends.push(parseSend(argv[cursor])) + } else if (arg.startsWith('--')) { + const key = arg.slice(2) + cursor += 1 + options[key] = argv[cursor] + } + cursor += 1 + } + for (const key of ['cols', 'rows', 'duration']) { + options[key] = options[key] == null ? null : Number(options[key]) + } + return { options, command } +} + +// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw +// control byte in source, which is unreadable and survives badly in diffs. +const ESC = String.fromCharCode(27) +const SEND_ESCAPES = { r: '\r', n: '\n', t: '\t', e: ESC, '\\': '\\' } + +/** `":"` — a keystroke to deliver at a fixed offset, for an unattended dialog capture. */ +function parseSend(value) { + const separator = String(value ?? '').indexOf(':') + if (separator === -1) { + throw new Error(`--send expects ":", got ${String(value)}`) + } + const atMs = Number(value.slice(0, separator)) + if (!Number.isFinite(atMs)) { + throw new Error( + `--send delay must be a number of milliseconds, got ${value.slice(0, separator)}` + ) + } + const text = value + .slice(separator + 1) + .replace(/\\(.)/g, (whole, code) => SEND_ESCAPES[code] ?? whole) + return { atMs, text } +} + +function runScan(files, redact) { + let failed = false + for (const file of files) { + const path = resolve(file) + const text = readFileSync(path, 'utf8') + if (redact) { + const { text: redacted, redacted: count } = redactTranscript(text) + writeFileSync(path, redacted) + console.log(`${file}: redacted ${count} span(s) in place, same length each.`) + continue + } + const findings = scanTranscriptForSecrets(text) + console.log(formatFindings(file, findings)) + failed ||= findings.length > 0 + } + return failed ? 1 : 0 +} + +function resolveSpawn(command) { + // node-pty cannot run a .cmd/.bat shim directly on Windows; those need cmd.exe. + if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command[0])) { + return { file: 'cmd.exe', args: ['/c', `"${command[0]}"`, ...command.slice(1)] } + } + return { file: command[0], args: command.slice(1) } +} + +async function runCapture(options, command) { + const name = options.name + if (typeof name === 'string' && !NAME_RE.test(name)) { + console.error(`--name must be lowercase kebab-case; got ${name}`) + return 2 + } + const outPath = options.out ? resolve(options.out) : join(FIXTURE_DIR, `${name}.txt`) + mkdirSync(dirname(outPath), { recursive: true }) + + const pty = await import('node-pty').catch((error) => { + console.error( + `node-pty failed to load. Build it for plain node first: + node config/scripts/ensure-native-runtime.mjs --runtime=node +${String(error)}` + ) + return null + }) + if (pty === null) { + return 2 + } + + const cols = options.cols ?? process.stdout.columns ?? 120 + const rows = options.rows ?? process.stdout.rows ?? 40 + const { file, args } = resolveSpawn(command) + const term = pty.spawn(file, args, { + name: 'xterm-256color', + cols, + rows, + cwd: process.cwd(), + env: { ...process.env, TERM: 'xterm-256color' }, + encoding: null + }) + + const sink = createWriteStream(outPath) + let recording = true + term.onData((chunk) => { + const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk + // Why recording stops before the kill: an agent repaints an idle frame on its way out, so + // a transcript that keeps writing through shutdown ends on that frame instead of on the + // state you stopped to capture. A mid-turn or dialog capture cannot survive that. + if (recording) { + sink.write(bytes) + } + process.stdout.write(bytes) + }) + + const wasRaw = process.stdin.isTTY === true && process.stdin.isRaw === true + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + let stopping = false + const stop = () => { + if (stopping) { + return + } + stopping = true + recording = false + try { + term.kill() + } catch { + // The agent may have exited on its own; the transcript is already on disk. + } + } + process.stdin.on('data', (chunk) => { + if (chunk.includes(STOP_KEY)) { + stop() + return + } + term.write(chunk.toString('binary')) + }) + // Why scripted input: a dialog capture has to be driven, and CI (or an agent) has no TTY to + // type into. The keystrokes ride the same PTY a human's would, so the capture is unchanged. + const sendTimers = options.sends.map((send) => setTimeout(() => term.write(send.text), send.atMs)) + const durationTimer = options.duration === null ? null : setTimeout(stop, options.duration * 1000) + + const exitCode = await new Promise((resolveExit) => { + term.onExit(({ exitCode: code }) => resolveExit(code ?? 0)) + }) + for (const timer of sendTimers) { + clearTimeout(timer) + } + if (durationTimer !== null) { + clearTimeout(durationTimer) + } + if (process.stdin.isTTY) { + process.stdin.setRawMode(wasRaw) + } + process.stdin.pause() + await new Promise((done) => sink.end(done)) + + writeMeta(outPath, { command, cols, rows, note: options.note ?? null, exitCode }) + const findings = scanTranscriptForSecrets(readFileSync(outPath, 'utf8')) + console.log(`\nTranscript: ${outPath}`) + console.log(formatFindings('scrub check', findings)) + if (findings.length > 0) { + console.log( + `Scrub with: + node config/scripts/capture-agent-pty-transcript.mjs --scan ${outPath} --redact` + ) + } + return 0 +} + +function writeMeta(outPath, details) { + const metaPath = outPath.replace(/\.txt$/, '.meta.json') + writeFileSync( + metaPath, + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + platform: process.platform, + command: details.command, + cols: details.cols, + rows: details.rows, + note: details.note, + exitCode: details.exitCode + }, + null, + 2 + )}\n` + ) +} + +async function main() { + const { options, command } = parseArgs(process.argv.slice(2)) + if (options.help === true) { + console.log(USAGE) + return 0 + } + if (options.scan.length > 0) { + return runScan(options.scan, options.redact) + } + if (command.length === 0 || (options.name === undefined && options.out === undefined)) { + console.error(USAGE) + return 2 + } + return runCapture(options, command) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().then( + (code) => { + process.exitCode = code + }, + (error) => { + console.error(error) + process.exitCode = 1 + } + ) +} + +export { parseArgs, resolveSpawn } diff --git a/config/scripts/casting-code-quality.test.mjs b/config/scripts/casting-code-quality.test.mjs new file mode 100644 index 00000000000..a9be7bf7c80 --- /dev/null +++ b/config/scripts/casting-code-quality.test.mjs @@ -0,0 +1,153 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, it } from 'vitest' +import { + OXLINT_SCANS, + diagnosticTouchesAddedLines, + findCastingDirectivesMissingSafety, + isCastingDirectiveUnusedWarning +} from './check-changed-code-quality.mjs' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' + +const root = path.resolve(import.meta.dirname, '..', '..') +const oxlint = resolveOxlintInvocation(root) +const rule = 'typescript(consistent-type-assertions)' +const ruleName = 'typescript/consistent-type-assertions' +// Built rather than written out so no line here is itself a casting directive the gate would scan. +const directive = (reason) => `// oxlint-disable-next-line ${ruleName} -- ${reason}` +const trailingDirective = (reason) => `// oxlint-disable-line ${ruleName} -- ${reason}` + +function lint(file, args = []) { + const result = spawnSync( + oxlint.command, + [...oxlint.prefixArgs, ...args, '--format', 'json', file], + { cwd: root, encoding: 'utf8', windowsHide: true } + ) + expect(result.error).toBeUndefined() + return { status: result.status, diagnostics: JSON.parse(result.stdout).diagnostics } +} + +it.each(['config', 'mobile'])('enforces new casts without changing full lint in %s', (parent) => { + const directory = mkdtempSync(path.join(root, parent, 'casting-lint-test-')) + const file = path.join(directory, 'fixture.test.ts') + try { + writeFileSync( + file, + [ + "export const oldCast = { current: '⌘N' as string | null }", + 'export const doubleCast = undefined as unknown as string', + "export const annotated: { current: string | null } = { current: '⌘N' }", + "export const constant = { current: '⌘N' } as const", + "export const checked = { current: '⌘N' } satisfies { current: string | null }", + directive('SAFETY: Exercise the explicit exception.'), + 'export const justified = undefined as unknown' + ].join('\n') + ) + + const full = lint(file) + expect(full.status).toBe(0) + expect(full.diagnostics.filter((diagnostic) => diagnostic.code === rule)).toEqual([]) + + const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'casting code quality') + expect(scan).toBeDefined() + const casting = lint(file, scan.args) + expect(casting.status).toBe(1) + expect(casting.diagnostics).toHaveLength(3) + expect(casting.diagnostics.every((diagnostic) => diagnostic.code === rule)).toBe(true) + + const relative = path.relative(root, file).split(path.sep).join('/') + const changed = new Map([[relative, [{ start: 2, end: 2 }]]]) + const findings = casting.diagnostics.filter((diagnostic) => + diagnosticTouchesAddedLines(diagnostic, changed, root) + ) + expect(findings).toHaveLength(2) + expect(findings.every((diagnostic) => diagnostic.severity === 'error')).toBe(true) + + writeFileSync(file, 'export const angle = undefined\n') + expect(lint(file).status).toBe(1) + expect(lint(file, scan.args).diagnostics.map((diagnostic) => diagnostic.code)).toEqual([rule]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +it("exempts the SAFETY: directive from the untyped scan's unused-directive warning", () => { + const directory = mkdtempSync(path.join(root, 'config', 'casting-lint-test-')) + const file = path.join(directory, 'fixture.test.ts') + try { + writeFileSync( + file, + [ + directive('SAFETY: Verified invariant.'), + 'export const justified = undefined as unknown', + '' + ].join('\n') + ) + + const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'code quality') + const untyped = lint(file, scan.args) + const unused = untyped.diagnostics.filter((diagnostic) => + diagnostic.message.startsWith('Unused oxlint-disable directive') + ) + + expect(unused).toHaveLength(1) + expect(unused.every((diagnostic) => isCastingDirectiveUnusedWarning(diagnostic, root))).toBe( + true + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +it('rejects a casting suppression on an added line that omits the SAFETY: rationale', () => { + const directory = mkdtempSync(path.join(root, 'config', 'casting-lint-test-')) + const file = path.join(directory, 'fixture.test.ts') + try { + writeFileSync( + file, + [ + directive('no required prefix'), + 'export const unchecked = undefined as unknown', + directive('SAFETY: Verified invariant.'), + 'export const justified = undefined as unknown', + '' + ].join('\n') + ) + + const relative = path.relative(root, file).split(path.sep).join('/') + const findings = findCastingDirectivesMissingSafety( + root, + new Map([[relative, [{ start: 1, end: 4 }]]]) + ) + + expect(findings.map((finding) => finding.labels[0].span.line)).toEqual([1]) + + // Unchanged lines stay out of the gate. + expect( + findCastingDirectivesMissingSafety(root, new Map([[relative, [{ start: 3, end: 4 }]]])) + ).toEqual([]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +// Why: an earlier pattern skipped a directive whose `//` sat right after a quote, which let an +// unjustified cast through the gate -- the wrong failure direction for a gate. +it('catches a trailing casting suppression that abuts a string literal', () => { + const directory = mkdtempSync(path.join(root, 'config', 'casting-lint-test-')) + const file = path.join(directory, 'fixture.test.ts') + try { + writeFileSync(file, `export const abutted = 'a'${trailingDirective('no required prefix')}\n`) + + const relative = path.relative(root, file).split(path.sep).join('/') + const findings = findCastingDirectivesMissingSafety( + root, + new Map([[relative, [{ start: 1, end: 1 }]]]) + ) + + expect(findings.map((finding) => finding.labels[0].span.line)).toEqual([1]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index af4e9e82776..a1b5b2fc88a 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -8,6 +8,9 @@ import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/ const ROOT_CODE_QUALITY_IGNORED_PREFIXES = ['cloud/'] +const CASTING_RULE = 'typescript/consistent-type-assertions' +const CASTING_DISABLE_PATTERN = + /\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*typescript\/consistent-type-assertions/ export const OXLINT_SCANS = [ { // Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root @@ -15,6 +18,10 @@ export const OXLINT_SCANS = [ label: 'code quality', args: ['--report-unused-disable-directives-severity', 'warn'] }, + { + label: 'casting code quality', + args: ['--config', 'config/oxlint-code-quality-casting.json'] + }, { label: 'type-aware code quality', args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json'] @@ -303,6 +310,50 @@ function printDiagnostic(diagnostic, root) { console.error(`${file}:${line} ${code}: ${diagnostic.message}`) } +// Why: only the casting scan enforces `assertionStyle: never`, so under the root config an +// `as` cast is legal and the SAFETY: directive AGENTS.md mandates reads as unused. The untyped +// scan reports that as a warning, which the gate counts, so exempt exactly those directives. +export function isCastingDirectiveUnusedWarning(diagnostic, root) { + if (!/^Unused (?:oxlint|eslint)-disable/.test(diagnostic.message ?? '')) { + return false + } + return (diagnostic.labels ?? []).some((label) => + diagnosticHighlightedLines(root, diagnostic.filename, label.span).some((line) => + CASTING_DISABLE_PATTERN.test(line) + ) + ) +} + +// Why: oxlint cannot see the AGENTS.md requirement that every casting suppression carry a +// line-specific SAFETY: rationale, so the directive text itself is checked over added lines. +export function findCastingDirectivesMissingSafety(root, rangesByFile) { + const findings = [] + for (const [file, ranges] of rangesByFile) { + const absolutePath = path.join(root, file) + if (!existsSync(absolutePath)) { + continue + } + readFileSync(absolutePath, 'utf8') + .split(/\r?\n/) + .forEach((text, index) => { + const line = index + 1 + if ( + CASTING_DISABLE_PATTERN.test(text) && + !text.includes('SAFETY:') && + overlapsAddedLines(line, line, ranges) + ) { + findings.push({ + filename: file, + code: `${CASTING_RULE} (missing SAFETY:)`, + message: `Suppressing ${CASTING_RULE} requires a line-specific "SAFETY:" explanation.`, + labels: [{ span: { line } }] + }) + } + }) + } + return findings +} + function isSuppressedDiagnostic(diagnostic, root) { const files = SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS.get(diagnostic.code) return files?.has(normalizedDiagnosticPath(root, diagnostic.filename)) ?? false @@ -344,6 +395,7 @@ export function main( const diagnostics = runOxlintScan(root, scan, files).filter( (diagnostic) => !isSuppressedDiagnostic(diagnostic, root) && + !isCastingDirectiveUnusedWarning(diagnostic, root) && diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks) ) for (const diagnostic of diagnostics) { @@ -355,6 +407,15 @@ export function main( ) } + const missingSafety = findCastingDirectivesMissingSafety(root, rangesByFile) + for (const diagnostic of missingSafety) { + printDiagnostic(diagnostic, root) + } + failures += missingSafety.length + console.log( + `casting SAFETY: rationale: ${missingSafety.length} new finding(s) across ${files.length} changed file(s).` + ) + if (failures > 0) { console.error( `Changed-code quality gate failed with ${failures} finding(s) since ${comparisonBase.slice(0, 12)}.` diff --git a/config/scripts/check-job-log-byte-cap-benchmark.mjs b/config/scripts/check-job-log-byte-cap-benchmark.mjs new file mode 100644 index 00000000000..3a8fd530919 --- /dev/null +++ b/config/scripts/check-job-log-byte-cap-benchmark.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pipe the baseline check-job-log-tail-slice.ts on stdin; both arms use the actual UTF-8 implementation. +const entry = path.resolve('src/shared/check-job-log-tail-slice.ts') +const sources = [readFileSync(0, 'utf8'), readFileSync(entry, 'utf8')] +assert(sources.every((source) => source.includes('export function sliceCheckLogTail'))) + +async function load(source) { + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'log-excerpt-source', + setup(builder) { + builder.onLoad({ filter: /check-job-log-tail-slice\.ts$/ }, () => ({ + contents: source, + loader: 'ts', + resolveDir: path.dirname(entry) + })) + } + } + ] + }) + const bundled = `${result.outputFiles[0].text}\n//# sourceURL=check-log-byte-cap-benchmark-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(bundled).toString('base64')}`) +} + +const modules = await Promise.all(sources.map(load)) +const arms = modules.map((module) => module.sliceCheckLogTail) +const limit = modules[0].PR_CHECK_LOG_TAIL_BYTES +assert.equal(modules[1].PR_CHECK_LOG_TAIL_BYTES, limit) +let seed = 0xc0ffee16 +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return (seed >>> 8) % max +} + +let comparisons = 0 +function compare(text) { + const expected = arms[0](text) + assert.equal(arms[1](text), expected) + assert(Buffer.byteLength(expected, 'utf8') <= limit) + comparisons++ + return expected +} + +const units = ['x', 'é', '界', '😀', '\ud83d', '\udc00', 'x\ud83d界\udc00'] +for (const unit of units) { + for (let delta = -4; delta <= 4; delta++) { + const text = unit.repeat(Math.floor(limit / Buffer.byteLength(unit)) + delta) + compare(text) + compare(`error: ${text}\n${'recent\n'.repeat(103)}`) + } +} +const endings = ['\n', '\r\n', '\r', ''] +const tokens = [ + 'plain text', + '##[error]', + '::error::', + 'error:', + 'FAILED', + 'exit code', + 'ENOENT', + 'EACCES', + 'panic:', + 'AssertionError', + 'emoji 😀', + '\ud83d', + '\udc00', + '\0', + '界', + 'é', + '\r' +] +for (let iteration = 0; iteration < 5000; iteration++) { + const rows = Array.from({ length: random(250) }, (_, index) => { + const token = tokens[random(tokens.length)] + if (index === 0 && iteration % 20 === 0) { + return `${token}${units[random(units.length)].repeat(limit + random(4))}` + } + return `${token} ${index} ${units[random(units.length)].repeat(random(30))}` + }) + compare(rows.join(endings[random(endings.length)]) + endings[random(endings.length)]) +} +console.log(`${comparisons} full-output differential cases passed`) + +const workloads = [ + ['short ASCII', 'log '.repeat(16)], + ['short Unicode', '🦀界'.repeat(20)], + ['8KiB ASCII', 'x'.repeat(8192)], + ['16KiB ASCII exact cap', 'x'.repeat(limit)], + ['8Ki code units / 24KiB Unicode', '界'.repeat(8192)], + ['2MiB ASCII line', 'x'.repeat(2 * 1024 * 1024)], + ['8MiB ASCII line', 'x'.repeat(8 * 1024 * 1024)], + ['2MiB Unicode line', '界'.repeat(Math.floor((2 * 1024 * 1024) / 3))], + [ + '2MiB earlier error context', + `error: ${'x'.repeat(2 * 1024 * 1024)}\n${'recent\n'.repeat(103)}` + ], + [ + '220 ordinary lines', + Array.from({ length: 220 }, (_, i) => `line ${i} ${'text'.repeat(8)}`).join('\n') + ], + [ + '220 lines / small earlier error', + Array.from( + { length: 220 }, + (_, i) => `${i === 30 ? 'error:' : 'line'} ${i} ${'text'.repeat(8)}` + ).join('\n') + ] +] + +function sample(arm, input, expected, repeats) { + const started = performance.now() + let output + for (let i = 0; i < repeats; i++) { + output = arm(input) + } + const elapsed = (performance.now() - started) / repeats + assert.equal(output, expected) + return elapsed +} + +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + pairs: 8, + unit: 'ms' + }) +) +for (const [name, input] of workloads) { + const expected = compare(input) + for (const arm of arms) { + const until = performance.now() + 80 + while (performance.now() < until) { + sample(arm, input, expected, 1) + } + } + const repeats = Math.max(1, Math.min(100000, Math.ceil(40 / sample(arms[0], input, expected, 1)))) + /** @type {number[][]} */ + const samples = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + samples[index].push(sample(arms[index], input, expected, repeats)) + } + } + const median = samples.map((values) => { + values.sort((a, b) => a - b) + return (values[3] + values[4]) / 2 + }) + console.log(JSON.stringify({ name, repeats, median, samples })) +} diff --git a/config/scripts/check-readme-local-links.mjs b/config/scripts/check-readme-local-links.mjs new file mode 100644 index 00000000000..723d7e3ccce --- /dev/null +++ b/config/scripts/check-readme-local-links.mjs @@ -0,0 +1,102 @@ +import { execFileSync } from 'node:child_process' +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +// Why: the READMEs embed media from trees other jobs own (docs-site public media, +// generated feature-wall tiles), and the docs-only classifier skips the whole CI +// matrix for one of them. GitHub renders only committed files, so this checks the +// git index rather than the working tree. +const TRANSLATED_README_DIR = path.join('docs', 'readme') +const EXTERNAL_TARGET = /^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i +const HTML_ATTRIBUTE = /\b(?:src|srcset|href)\s*=\s*(?:"([^"]*)"|'([^']*)')/g +const MARKDOWN_LINK = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g + +function readmeFiles(root) { + const translated = readdirSync(path.join(root, TRANSLATED_README_DIR)) + .filter((name) => name.endsWith('.md')) + .sort() + .map((name) => path.posix.join('docs', 'readme', name)) + return ['README.md', ...translated] +} + +// Why only the referenced paths: a full `git ls-files` of this repo overflows the +// default child buffer; asking about a few dozen pathspecs stays bounded. +function trackedFiles(root, candidates) { + if (candidates.length === 0) { + return new Set() + } + const stdout = execFileSync( + 'git', + ['--literal-pathspecs', 'ls-files', '-z', '--', ...candidates], + { cwd: root, encoding: 'utf8' } + ) + return new Set(stdout.split('\0').filter(Boolean)) +} + +function* localTargets(markdown) { + for (const match of markdown.matchAll(HTML_ATTRIBUTE)) { + // Why: srcset is a candidate list ("a.gif 1x, b.gif 2x"); each entry starts with a URL. + for (const candidate of (match[1] ?? match[2]).split(',')) { + const target = candidate.trim().split(/\s+/)[0] + if (target) { + yield target + } + } + } + for (const match of markdown.matchAll(MARKDOWN_LINK)) { + yield match[1].replace(/^<|>$/g, '') + } +} + +function resolveTarget(readme, target) { + const bare = target.split(/[?#]/)[0] + if (!bare) { + return null + } + const resolved = path.posix.normalize( + path.posix.join(path.posix.dirname(readme), decodeURIComponent(bare)) + ) + return resolved.startsWith('../') ? null : resolved +} + +function collectLinks(root) { + const links = [] + for (const readme of readmeFiles(root)) { + const markdown = readFileSync(path.join(root, readme), 'utf8') + for (const target of new Set(localTargets(markdown))) { + if (EXTERNAL_TARGET.test(target)) { + continue + } + links.push({ readme, target, resolved: resolveTarget(readme, target) }) + } + } + return links +} + +export function findBrokenReadmeLinks(root) { + const links = collectLinks(root) + const candidates = [...new Set(links.map((link) => link.resolved).filter(Boolean))] + const tracked = trackedFiles(root, candidates) + return links.filter(({ resolved }) => resolved === null || !tracked.has(resolved)) +} + +export function main(root = process.cwd()) { + const broken = findBrokenReadmeLinks(root) + if (broken.length > 0) { + console.error(`README local link check failed with ${broken.length} broken link(s):`) + for (const { readme, target, resolved } of broken) { + console.error( + `- ${readme}: ${target} -> ${resolved ?? 'outside the repository'} is not tracked` + ) + } + return 1 + } + console.log('README local link check passed.') + return 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main()) +} diff --git a/config/scripts/check-readme-local-links.test.mjs b/config/scripts/check-readme-local-links.test.mjs new file mode 100644 index 00000000000..2f7cfc76c92 --- /dev/null +++ b/config/scripts/check-readme-local-links.test.mjs @@ -0,0 +1,156 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { findBrokenReadmeLinks, main } from './check-readme-local-links.mjs' + +const projectDir = path.resolve(import.meta.dirname, '../..') +const tempDirs = [] + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() +} + +function writeFiles(root, files) { + for (const [relativePath, contents] of Object.entries(files)) { + const target = path.join(root, relativePath) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, contents) + } +} + +function makeFixture(files, { untracked = {} } = {}) { + const root = mkdtempSync(path.join(tmpdir(), 'orca-readme-links-')) + tempDirs.push(root) + git(root, ['init', '--quiet']) + git(root, ['config', 'user.email', 'readme-links-test@example.com']) + git(root, ['config', 'user.name', 'README Links Test']) + writeFiles(root, files) + git(root, ['add', '-A']) + git(root, ['commit', '--quiet', '-m', 'fixture']) + writeFiles(root, untracked) + return root +} + +const validReadmes = { + 'README.md': [ + '', + '', + '日本語', + "", + '', + '[Contributing](.github/CONTRIBUTING.md) [Docs](https://example.com/docs) [Top](#top)', + '![hero](docs/assets/hero%20image.jpg "Hero")' + ].join('\n'), + 'docs/readme/README.ja.md': [ + '', + '', + 'English self', + '[LICENSE](../../LICENSE)' + ].join('\n'), + 'resources/build/icon.png': 'png', + 'resources/onboarding/feature-wall/tile-01.poster.jpg': 'jpg', + 'docs/site/public/docs/tab-split.gif': 'gif', + 'docs/assets/hero image.jpg': 'jpg', + '.github/CONTRIBUTING.md': 'contributing', + LICENSE: 'mit' +} + +afterEach(() => { + vi.restoreAllMocks() + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('README local link check', () => { + it('accepts the checked-in READMEs', () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + expect(main(projectDir)).toBe(0) + }) + + it('accepts local links in every supported shape', () => { + expect(findBrokenReadmeLinks(makeFixture(validReadmes))).toEqual([]) + }) + + it('reports a deleted media file for the root and translated READMEs', () => { + const { 'docs/site/public/docs/tab-split.gif': _gif, ...files } = validReadmes + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeFixture(files) + + expect(findBrokenReadmeLinks(root)).toEqual([ + { + readme: 'README.md', + target: 'docs/site/public/docs/tab-split.gif', + resolved: 'docs/site/public/docs/tab-split.gif' + }, + { + readme: 'docs/readme/README.ja.md', + target: '../site/public/docs/tab-split.gif', + resolved: 'docs/site/public/docs/tab-split.gif' + } + ]) + expect(main(root)).toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('README.md: docs/site/public/docs/tab-split.gif') + ) + }) + + // Why: GitHub renders the commit, so a file that only exists on disk is broken. + it('reports a referenced file that exists on disk but is not tracked', () => { + const { 'resources/build/icon.png': icon, ...files } = validReadmes + const root = makeFixture(files, { untracked: { 'resources/build/icon.png': icon } }) + + expect(findBrokenReadmeLinks(root).map((link) => link.resolved)).toEqual([ + 'resources/build/icon.png', + 'resources/build/icon.png' + ]) + }) + + it('reports a link that escapes the repository', () => { + const root = makeFixture({ + ...validReadmes, + 'docs/readme/README.ja.md': '' + }) + + expect(findBrokenReadmeLinks(root)).toEqual([ + { readme: 'docs/readme/README.ja.md', target: '../../../outside.png', resolved: null } + ]) + }) + + // Why: a single-quoted attribute is valid HTML and GitHub renders it, so a parser + // that only reads double quotes would pass a README with a broken image. + it('reports a missing target in a single-quoted attribute', () => { + const files = { + ...validReadmes, + 'README.md': `${validReadmes['README.md']}\n` + } + + expect(findBrokenReadmeLinks(makeFixture(files))).toEqual([ + { + readme: 'README.md', + target: 'docs/assets/missing.gif', + resolved: 'docs/assets/missing.gif' + } + ]) + }) + + // Why the ungated job: static_analysis is skipped for docs-only diffs, which is + // exactly the kind of PR that deletes a docs-site GIF the README embeds. + it('runs on every PR through the ungated guard job and in the lint script', () => { + const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) + const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + const guardJob = workflow.jobs.root_directory_guard + const step = guardJob.steps.find((candidate) => candidate.name === 'Check README local links') + + expect(guardJob.if).toBeUndefined() + expect(guardJob.needs).toBeUndefined() + expect(step.run).toBe('node config/scripts/check-readme-local-links.mjs') + expect(scripts['check:readme-local-links']).toBe( + 'node config/scripts/check-readme-local-links.mjs' + ) + expect(scripts.lint).toContain('pnpm run check:readme-local-links') + }) +}) diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs new file mode 100644 index 00000000000..d2111a230bb --- /dev/null +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -0,0 +1,76 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const read = (path) => parse(readFileSync(path, 'utf8')) +const workflow = (name) => read(`.github/workflows/${name}.yml`) +const action = read('.github/actions/install-node-dependencies/action.yml') + +describe('CI dependency download caches', () => { + it('scopes desktop stores to the root lockfile and lets mixed installs opt in', () => { + expect(action.inputs['cache-dependency-path'].default).toBe('pnpm-lock.yaml') + for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) { + expect(step.with.cache).toBe('pnpm') + expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}') + } + const install = action.runs.steps.find((step) => step.name === 'Install dependencies') + expect(install.if).toBeUndefined() + expect(install.run).toContain('pnpm install --frozen-lockfile --ignore-scripts') + expect(install.run).toContain( + 'diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml' + ) + const mobile = workflow('mobile').jobs.verify.steps.find((step) => + step.uses?.includes('install-node-dependencies') + ) + expect(mobile.with['cache-dependency-path'].trim().split('\n')).toEqual([ + 'pnpm-lock.yaml', + 'mobile/pnpm-lock.yaml' + ]) + }) +}) + +describe('release install targets', () => { + const macCpuFlag = '--cpu=current,x64,arm64' + // Both shapes: `run:` steps and steps wrapped in nick-fields/retry (`with.command`). + const installSteps = (name) => + Object.values(workflow(name).jobs) + .flatMap((job) => job.steps ?? []) + .map((step) => step.with?.command ?? step.run) + .filter((command) => typeof command === 'string' && command.includes('pnpm install ')) + + it.each(['adhoc-mac-build', 'daily-mac-build', 'hourly-mac-build', 'release-mac-build'])( + '%s installs both mac CPU variants for the x64+arm64 package config', + (name) => { + const installs = installSteps(name) + expect(installs.length).toBeGreaterThan(0) + expect(installs.some((command) => command.includes(macCpuFlag))).toBe(true) + } + ) + + it.each(['release-cut', 'dev-channel-win-build', 'windows-signing-rehearsal'])( + '%s keeps installs scoped to the runner host', + (name) => { + const installs = installSteps(name) + expect(installs.length).toBeGreaterThan(0) + for (const command of installs) { + expect(command).not.toContain('--os=') + expect(command).not.toContain('--cpu=') + } + } + ) + + it('offers the mac CPU targets for local packaging without touching the lockfile', () => { + const script = JSON.parse(readFileSync('package.json', 'utf8')).scripts['install:release'] + expect(script).toContain('--frozen-lockfile') + expect(script).toContain(macCpuFlag) + }) + + it('keeps installed Windows addon checks in the Windows CI lane', () => { + const steps = Object.values(workflow('pr').jobs).flatMap((job) => job.steps ?? []) + const test = steps.find((step) => step.name === 'Test Windows-specific boundaries') + expect(test.run).toContain('config/scripts/windows-process-tree-gyp-path.test.mjs') + expect(test.run).toContain('config/scripts/windows-process-tree-gyp-rebuild.test.mjs') + expect(test.run).toContain('config/scripts/package-electron-runtime-contract.test.mjs') + expect(test.run).toContain('config/scripts/electron-builder-runtime-resources.test.mjs') + }) +}) diff --git a/config/scripts/ci-e2e-shard-plan.mjs b/config/scripts/ci-e2e-shard-plan.mjs new file mode 100644 index 00000000000..ea6002451a3 --- /dev/null +++ b/config/scripts/ci-e2e-shard-plan.mjs @@ -0,0 +1,97 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + balanceFiles, + compareIds, + readTimingBaseline, + writeAssignment +} from './ci-shard-assignment.mjs' + +export function discoverE2eFiles(report) { + if (report.errors?.length) { + throw new Error('Playwright discovery reported errors') + } + const files = new Map() + function visit(suite) { + for (const spec of suite.specs ?? []) { + const file = spec.file.replaceAll('\\', '/') + if (file.startsWith('/') || file.split('/').includes('..') || /[\n\r>›]/.test(file)) { + throw new Error(`Unsafe test-list path: ${file}`) + } + for (const test of spec.tests) { + const id = `${test.projectName}:${spec.id}` + const ids = files.get(file) ?? [] + ids.push(id) + files.set(file, ids) + } + } + for (const child of suite.suites ?? []) { + visit(child) + } + } + for (const suite of report.suites) { + visit(suite) + } + if (!files.size) { + throw new Error('Playwright discovered no tests') + } + const ids = [...files.values()].flat() + if (new Set(ids).size !== ids.length) { + throw new Error('Duplicate discovered test identity') + } + return Object.fromEntries([...files.entries()].sort(([a], [b]) => compareIds(a, b))) +} + +export function planE2e(report, count, baseline) { + const testsByFile = discoverE2eFiles(report) + const timings = Object.fromEntries( + Object.entries(baseline.timings).map(([file, duration]) => [ + file.replace(/^tests\/e2e\//, ''), + duration + ]) + ) + const assignment = balanceFiles(Object.keys(testsByFile), count, timings) + return { ...assignment, testsByFile, baselineSha256: baseline.baselineSha256 } +} + +export function verifyE2eSelection(assignment, report) { + const actual = Object.values(discoverE2eFiles(report)).flat().sort(compareIds) + const expected = assignment.shards[assignment.selectedShard - 1].files + .flatMap((file) => assignment.testsByFile[file]) + .sort(compareIds) + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('Native Playwright selection differs from shard assignment') + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + if (process.argv[2] === '--verify') { + verifyE2eSelection( + JSON.parse(readFileSync(process.argv[3], 'utf8')), + JSON.parse(readFileSync(process.argv[4], 'utf8')) + ) + } else { + const [input, shard, directory] = process.argv.slice(2) + const match = shard?.match(/^(\d+)\/(\d+)$/) + if (!input || !directory || !match) { + throw new Error('Usage: ci-e2e-shard-plan.mjs DISCOVERY INDEX/COUNT OUTPUT_DIRECTORY') + } + const index = Number(match[1]) + const count = Number(match[2]) + if (index < 1 || index > count) { + throw new Error('Invalid shard index') + } + const assignment = planE2e( + JSON.parse(readFileSync(input, 'utf8')), + count, + readTimingBaseline('e2e') + ) + const selected = assignment.shards[index - 1].files + if (!selected.length) { + throw new Error('Empty E2E shard') + } + writeAssignment(join(directory, 'assignment.json'), { ...assignment, selectedShard: index }) + writeFileSync(join(directory, 'selected.txt'), `${selected.join('\n')}\n`) + } +} diff --git a/config/scripts/ci-e2e-shard-selection.test.mjs b/config/scripts/ci-e2e-shard-selection.test.mjs new file mode 100644 index 00000000000..15a031179e7 --- /dev/null +++ b/config/scripts/ci-e2e-shard-selection.test.mjs @@ -0,0 +1,73 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, it } from 'vitest' +import { runProcess } from '../../src/shared/child-process/run-process' +import { planE2e, verifyE2eSelection } from './ci-e2e-shard-plan.mjs' + +const require = createRequire(import.meta.url) + +it('native Playwright test-list preserves full discovery, serial suites, skips and headful filtering', async () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-playwright-shards-'))) + const testPackage = JSON.stringify(require.resolve('@stablyai/playwright-test')) + const config = join(directory, 'playwright.config.cjs') + writeFileSync( + config, + `module.exports = { testDir: '.', fullyParallel: true, projects: [{ name: 'electron-headless', grepInvert: /@headful/ }] }` + ) + for (let index = 0; index < 17; index++) { + writeFileSync( + join(directory, `file-${index}.spec.cjs`), + ` + const { test } = require(${testPackage}); + test('normal', () => {}); + test.skip('skipped', () => {}); + test('visible @headful', () => {}); + test.describe.serial('serial', () => { + test('first', () => {}); + test('second', () => {}); + }); + ` + ) + } + async function discover(extra = []) { + const result = await runProcess({ + program: process.execPath, + cwd: directory, + args: [ + join(dirname(require.resolve('playwright/package.json')), 'cli.js'), + 'test', + '--config', + config, + '--list', + '--reporter=json', + ...extra + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 20000 + }) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout) + } + try { + const full = await discover() + const assignment = planE2e(full, 14, { timings: {} }) + const ids = [] + for (let index = 0; index < 14; index++) { + const path = join(directory, 'selected.txt') + writeFileSync(path, `${assignment.shards[index].files.join('\n')}\n`) + const selected = await discover(['--test-list', path]) + verifyE2eSelection({ ...assignment, selectedShard: index + 1 }, selected) + for (const suite of selected.suites) { + expect(suite.specs.some((spec) => spec.title.includes('@headful'))).toBe(false) + } + ids.push(...assignment.shards[index].files.flatMap((file) => assignment.testsByFile[file])) + } + expect(ids).toHaveLength(17 * 4) + expect(new Set(ids).size).toBe(ids.length) + expect(() => verifyE2eSelection({ ...assignment, selectedShard: 1 }, full)).toThrow('differs') + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 60000) diff --git a/config/scripts/ci-shard-assignment.mjs b/config/scripts/ci-shard-assignment.mjs new file mode 100644 index 00000000000..1cb44969a63 --- /dev/null +++ b/config/scripts/ci-shard-assignment.mjs @@ -0,0 +1,66 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +export const compareIds = (a, b) => (a < b ? -1 : a > b ? 1 : 0) + +export function balanceFiles(files, count, timings, overheadMs = 0) { + if (!Number.isInteger(count) || count < 1) { + throw new Error('Invalid shard count') + } + if (new Set(files).size !== files.length) { + throw new Error('Duplicate discovered file') + } + const known = Object.values(timings).filter((value) => Number.isFinite(value) && value > 0) + known.sort((a, b) => a - b) + const fallbackMs = known[Math.floor(known.length / 2)] ?? 1000 + const weighted = files.map((file) => ({ + file, + durationMs: + (Number.isFinite(timings[file]) && timings[file] > 0 ? timings[file] : fallbackMs) + + overheadMs + })) + weighted.sort((a, b) => b.durationMs - a.durationMs || compareIds(a.file, b.file)) + const shards = Array.from({ length: count }, () => ({ files: [], durationMs: 0 })) + for (const entry of weighted) { + const target = shards.reduce((best, shard) => + shard.durationMs < best.durationMs || + (shard.durationMs === best.durationMs && shard.files.length < best.files.length) + ? shard + : best + ) + target.files.push(entry.file) + target.durationMs += entry.durationMs + } + for (const shard of shards) { + shard.files.sort(compareIds) + } + const assigned = shards.flatMap((shard) => shard.files).sort(compareIds) + if (JSON.stringify(assigned) !== JSON.stringify([...files].sort(compareIds))) { + throw new Error('Shard coverage differs from discovery') + } + return { algorithm: 'file-lpt-v1', fallbackMs, overheadMs, shards } +} + +export function readTimingBaseline(suite) { + const bytes = readFileSync(new URL('./ci-shard-timings.json', import.meta.url), 'utf8') + const baseline = JSON.parse(bytes) + return { ...baseline[suite], baselineSha256: createHash('sha256').update(bytes).digest('hex') } +} + +export function writeAssignment(path, assignment) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync( + path, + `${JSON.stringify( + { + sourceSha: process.env.ORCA_SHARD_SOURCE_SHA ?? process.env.GITHUB_SHA ?? null, + runId: process.env.GITHUB_RUN_ID ?? null, + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null, + ...assignment + }, + null, + 2 + )}\n` + ) +} diff --git a/config/scripts/ci-shard-assignment.test.mjs b/config/scripts/ci-shard-assignment.test.mjs new file mode 100644 index 00000000000..607a73d392c --- /dev/null +++ b/config/scripts/ci-shard-assignment.test.mjs @@ -0,0 +1,124 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BaseSequencer } from 'vitest/node' +import { balanceFiles } from './ci-shard-assignment.mjs' +import { discoverE2eFiles, planE2e } from './ci-e2e-shard-plan.mjs' +import { parseTimingLog } from './ci-shard-timing-import.mjs' +import TimingSequencer from './ci-unit-sequencer.mjs' + +const directories = [] +afterEach(() => { + vi.unstubAllEnvs() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('timing-weighted shard selection', () => { + it('distributes long files, includes unknowns exactly once, and ignores discovery order', () => { + const files = ['long', 'medium', 'short', 'unknown', 'new', 'zero', 'invalid'] + const timings = { long: 100, medium: 80, short: 20, zero: 0, invalid: -1, deleted: 20 } + const plan = balanceFiles(files, 3, timings, 10) + expect(plan).toEqual(balanceFiles(files.toReversed(), 3, timings, 10)) + expect(plan.fallbackMs).toBe(80) + expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([...files].sort()) + expect(Math.max(...plan.shards.map((shard) => shard.durationMs))).toBeLessThan(250) + }) + + it('has a deterministic cold fallback and permits fewer files than shards', () => { + expect(balanceFiles(['b', 'a'], 3, {}).shards).toEqual([ + { files: ['a'], durationMs: 1000 }, + { files: ['b'], durationMs: 1000 }, + { files: [], durationMs: 0 } + ]) + expect(() => balanceFiles(['a', 'a'], 8, {})).toThrow('Duplicate') + expect(() => balanceFiles(['a'], 0, {})).toThrow('count') + }) + + it('uses the post-filter Vitest discovery unchanged across eight shards and retains default sort', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-unit-shards-')) + directories.push(directory) + vi.stubEnv('ORCA_SHARD_MANIFEST', join(directory, 'assignment.json')) + const specs = Array.from({ length: 37 }, (_, i) => ({ + moduleId: resolve(`src/fixture-${i}.test.ts`) + })) + const selected = [] + for (let index = 1; index <= 8; index++) { + const sequencer = new TimingSequencer({ + config: { root: process.cwd(), shard: { index, count: 8 } } + }) + expect(sequencer.sort).toBe(BaseSequencer.prototype.sort) + selected.push(...(await sequencer.shard(specs))) + const manifest = JSON.parse(readFileSync(join(directory, 'assignment.json'), 'utf8')) + expect(manifest.selectedShard).toBe(index) + expect(manifest.baselineSha256).toMatch(/^[a-f0-9]{64}$/) + } + expect(new Set(selected).size).toBe(specs.length) + expect(selected).toHaveLength(specs.length) + expect(new Set(selected)).toEqual(new Set(specs)) + }) + + it('wires a constructor into the opt-in Vitest config', async () => { + vi.stubEnv('ORCA_BALANCE_UNIT_SHARDS', '1') + const { default: config } = await import('../vitest.config') + expect(config.test.sequence.sequencer).toBe(TimingSequencer) + }) + + it('keeps nested/serial E2E files atomic and fails closed on discovery errors', () => { + const spec = (id, file) => ({ id, file, tests: [{ projectName: 'electron-headless' }] }) + const report = { + suites: [ + { + specs: [spec('a', 'one.spec.ts')], + suites: [{ specs: [spec('b', 'one.spec.ts'), spec('c', 'two.spec.ts')] }] + } + ] + } + const plan = planE2e(report, 14, { timings: { 'tests/e2e/one.spec.ts': 4000 } }) + expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([ + 'one.spec.ts', + 'two.spec.ts' + ]) + expect(plan.testsByFile['one.spec.ts']).toHaveLength(2) + expect(() => discoverE2eFiles({ ...report, errors: [{}] })).toThrow('errors') + expect(() => discoverE2eFiles({ suites: [] })).toThrow('no tests') + expect(() => + discoverE2eFiles({ suites: [{ specs: [spec('a', '../escape.spec.ts')] }] }) + ).toThrow('Unsafe') + expect(() => + discoverE2eFiles({ + suites: [{ specs: [spec('a', 'one.spec.ts'), spec('a', 'one.spec.ts')] }] + }) + ).toThrow('Duplicate') + }) + + it('imports ANSI unit timings and E2E failures without counting headful reruns', () => { + const parsed = parseTimingLog( + [ + '\u001b[32m✓\u001b[39m src/a.test.ts (2 tests) 35ms', + 'Duration 1s (transform 0.1s, setup 0.2s, import 0.3s, tests 0.04s, environment 0.4s)', + '✓ 1 [electron-headless] › tests/e2e/a.spec.ts:1:1 › works (2s)', + '✘ 2 [electron-headless] › tests/e2e/a.spec.ts:2:1 › fails (1.2m)', + '✓ 3 [electron-headful] › tests/e2e/a.spec.ts:3:1 › benchmark (9s)' + ].join('\n') + ) + expect(parsed).toEqual({ + unit: { 'src/a.test.ts': 35 }, + e2e: { 'tests/e2e/a.spec.ts': 74000 }, + overheadMs: 1000 + }) + }) + + it('reads mixed units from captured Vitest output', () => { + const parsed = parseTimingLog( + 'Duration 5.14s (transform 952ms, setup 449ms, import 1.18s, tests 9.41s, environment 1ms)' + ) + expect(parsed.overheadMs).toBe(2582) + }) + + it('rejects incomplete unit evidence instead of silently dropping overhead', () => { + expect(() => parseTimingLog('✓ src/a.test.ts (2 tests) 35ms')).toThrow('Duration summary') + }) +}) diff --git a/config/scripts/ci-shard-timing-import.mjs b/config/scripts/ci-shard-timing-import.mjs new file mode 100644 index 00000000000..5b62c0f9f7f --- /dev/null +++ b/config/scripts/ci-shard-timing-import.mjs @@ -0,0 +1,86 @@ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { stripVTControlCharacters } from 'node:util' +import { pathToFileURL } from 'node:url' + +export function parseTimingLog(text) { + const clean = stripVTControlCharacters(text) + const unit = {} + const e2e = {} + for (const match of clean.matchAll( + /[✓×❯] ([\w./-]+\.test\.(?:ts|tsx|mjs)) \([^\n]*?\)\s+([\d.]+)ms/g + )) { + unit[match[1]] = Number(match[2]) + } + for (const match of clean.matchAll( + /[✓✘]\s+\d+ \[electron-headless\] › (tests\/e2e\/[^:]+):\d+:\d+ › .*? \(([\d.]+)(ms|s|m)\)/g + )) { + e2e[match[1]] = (e2e[match[1]] ?? 0) + Number(match[2]) * { ms: 1, s: 1000, m: 60000 }[match[3]] + } + const summary = clean.match( + /Duration\s+[\d.]+(?:ms|s) \(transform ([\d.]+(?:ms|s)), setup ([\d.]+(?:ms|s)), import ([\d.]+(?:ms|s)), tests [\d.]+(?:ms|s), environment ([\d.]+(?:ms|s))\)/ + ) + if (Object.keys(unit).length && !summary) { + throw new Error('Unit timing log has no supported Duration summary') + } + return { + unit, + e2e, + overheadMs: summary + ? summary + .slice(1) + .reduce( + (sum, value) => sum + Number.parseFloat(value) * (value.endsWith('ms') ? 1 : 1000), + 0 + ) + : 0 + } +} + +export function importTimingLogs(directory, unitRun, e2eRun) { + const baseline = { + unit: { runId: unitRun, jobIds: [], overheadMs: 0, timings: {} }, + e2e: { runId: e2eRun, jobIds: [], overheadMs: 0, timings: {} } + } + for (const file of readdirSync(directory) + .filter((file) => /^log-\d+\.txt$/.test(file)) + .sort()) { + const parsed = parseTimingLog(readFileSync(join(directory, file), 'utf8')) + for (const suite of ['unit', 'e2e']) { + if (!Object.keys(parsed[suite]).length) { + continue + } + baseline[suite].jobIds.push(file.match(/\d+/)[0]) + for (const [name, duration] of Object.entries(parsed[suite])) { + if (suite === 'unit' && name in baseline.unit.timings) { + throw new Error(`Duplicate unit timing: ${name}`) + } + baseline[suite].timings[name] = (baseline[suite].timings[name] ?? 0) + duration + } + } + baseline.unit.overheadMs += parsed.overheadMs + } + for (const suite of ['unit', 'e2e']) { + if (!baseline[suite].jobIds.length) { + throw new Error(`No ${suite} timing evidence`) + } + baseline[suite].timings = Object.fromEntries( + Object.entries(baseline[suite].timings).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + ) + } + baseline.unit.overheadMs = Math.ceil( + baseline.unit.overheadMs / Object.keys(baseline.unit.timings).length + ) + return baseline +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [directory, unitRun, e2eRun, output] = process.argv.slice(2) + if (!directory || !unitRun || !e2eRun || !output) { + throw new Error('Usage: ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN E2E_RUN OUTPUT') + } + writeFileSync( + output, + `${JSON.stringify(importTimingLogs(directory, unitRun, e2eRun), null, 2)}\n` + ) +} diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json new file mode 100644 index 00000000000..46ea33ce679 --- /dev/null +++ b/config/scripts/ci-shard-timings.json @@ -0,0 +1,8815 @@ +{ + "unit": { + "runId": "34675583768", + "jobIds": [ + "103505449197", + "103505449205", + "103505449211", + "103505449212", + "103505449228", + "103505449235", + "103505449248", + "103505449256" + ], + "overheadMs": 526, + "timings": { + "config/scripts/adhoc-build-version.test.mjs": 31, + "config/scripts/agent-status-hot-path-benchmark.test.ts": 2277, + "config/scripts/app-store-performance-plugin.test.mjs": 912, + "config/scripts/audit-localization-coverage.test.mjs": 23, + "config/scripts/benchmark-artifact-comparison.test.mjs": 132, + "config/scripts/benchmark-sample-summary.test.mjs": 8, + "config/scripts/build-linux-local.test.mjs": 15, + "config/scripts/build-mac-local.test.mjs": 5, + "config/scripts/build-orcad-prebuilds.test.mjs": 27, + "config/scripts/build-windows-cli-launcher.test.mjs": 39, + "config/scripts/check-changed-code-quality.test.mjs": 7, + "config/scripts/check-max-lines-ratchet.test.mjs": 10, + "config/scripts/check-react-doctor-changed.test.mjs": 189, + "config/scripts/check-reliability-gates.test.mjs": 171, + "config/scripts/check-root-directory-entries.test.mjs": 880, + "config/scripts/check-runtime-electron-ratchet.test.mjs": 1931, + "config/scripts/check-terminal-perf-report-budgets.test.mjs": 398, + "config/scripts/check-ts-nocheck-ratchet.test.mjs": 5, + "config/scripts/ci-native-toolchain.test.mjs": 38, + "config/scripts/client-hosted-browser-package-coverage.test.mjs": 81, + "config/scripts/codex-index-heal-contract-workflow.test.mjs": 10, + "config/scripts/codex-primary-home-tripwire.test.ts": 421, + "config/scripts/computer-e2e-workflow.test.mjs": 132, + "config/scripts/computer-use-modifier-safety.test.mjs": 5, + "config/scripts/computer-use-mouse-button-routing.test.mjs": 9, + "config/scripts/computer-use-skill-guidance.test.mjs": 15, + "config/scripts/computer-use-smoke.test.mjs": 471, + "config/scripts/computer-use-windows-horizontal-scroll.test.mjs": 3, + "config/scripts/counterbalanced-benchmark-schedule.test.mjs": 10, + "config/scripts/create-draft-release.test.mjs": 18, + "config/scripts/daily-build-version.test.mjs": 36, + "config/scripts/daily-e2e-dispatch-contract.test.mjs": 6, + "config/scripts/dev-channel-base-version.test.mjs": 8, + "config/scripts/dev-channel-windows-workflow-contract.test.mjs": 218, + "config/scripts/dev-cli-terminal-wrapper.test.mjs": 9, + "config/scripts/dev-electron-bundle-cache.test.ts": 10, + "config/scripts/dev-electron-bundle-identity.test.ts": 7, + "config/scripts/electron-builder-config.test.mjs": 155, + "config/scripts/electron-builder-mac-channel-config.test.mjs": 577, + "config/scripts/electron-builder-markdown-associations.test.mjs": 14, + "config/scripts/electron-builder-native-rebuild.test.mjs": 11, + "config/scripts/electron-builder-runtime-resources.test.mjs": 393, + "config/scripts/electron-builder-speech-config.test.mjs": 16, + "config/scripts/electron-runtime-floor.test.ts": 5, + "config/scripts/electron-vite-output-contract.test.ts": 13, + "config/scripts/ensure-native-runtime-job-ownership.test.mjs": 10, + "config/scripts/ensure-native-runtime.test.mjs": 422, + "config/scripts/generate-bundled-skill-guides.test.mjs": 293, + "config/scripts/generate-runtime-required-english-catalog.test.mjs": 8, + "config/scripts/generate-skill-bundle-manifest.test.mjs": 448, + "config/scripts/generate-terminal-perf-html-report.test.mjs": 19, + "config/scripts/git-binary-compatibility-workflow.test.mjs": 98, + "config/scripts/git-pull-request-diff-base.test.mjs": 4, + "config/scripts/hang-watchdog-process-metrics.test.mjs": 6, + "config/scripts/happy-dom-mutation-observer-retention.test.ts": 100, + "config/scripts/happy-dom-offscreen-canvas.test.ts": 42, + "config/scripts/headless-serve-shutdown-workflow.test.mjs": 15, + "config/scripts/hourly-build-version.test.mjs": 27, + "config/scripts/hourly-preflight-workflow.test.mjs": 33, + "config/scripts/idle-cpu-process-sampling.test.mjs": 5, + "config/scripts/install-electron-package-binary.test.mjs": 2145, + "config/scripts/install-node-dependencies-action.test.mjs": 140, + "config/scripts/latest-stable-release.test.mjs": 10, + "config/scripts/lint-staged-worktree-backup.test.mjs": 276, + "config/scripts/linux-package-maintainer-scripts.test.mjs": 6, + "config/scripts/live-freeze-bounded-history.test.mjs": 6, + "config/scripts/live-remote-bulk-open-freeze-metrics.test.mjs": 17, + "config/scripts/live-remote-freeze-rpc.test.mjs": 5, + "config/scripts/live-remote-status-watchdog.test.mjs": 248, + "config/scripts/locale-count-fragment-separator.test.mjs": 17, + "config/scripts/locale-generic-ui-terms.test.mjs": 20, + "config/scripts/locale-ko-frozen-terminal-search-keywords.test.mjs": 35, + "config/scripts/locale-ko-key-overrides.test.mjs": 38, + "config/scripts/locale-repair-catalog-missing-leaves.test.mjs": 14, + "config/scripts/locale-translation-policy-ko-round5.test.mjs": 12, + "config/scripts/locale-translation-policy.es-pr.test.mjs": 13, + "config/scripts/locale-translation-policy.es-round5.test.mjs": 9, + "config/scripts/locale-translation-policy.ja-relocalization.test.mjs": 17, + "config/scripts/locale-translation-policy.ja-round5.test.mjs": 13, + "config/scripts/locale-translation-policy.test.mjs": 38, + "config/scripts/locale-translation-policy.zh-round5.test.mjs": 14, + "config/scripts/locale-translation-policy.zh-status-bar-usage.test.mjs": 24, + "config/scripts/localization-package-contract.test.mjs": 8, + "config/scripts/mac-build-compatibility.test.mjs": 8, + "config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs": 10, + "config/scripts/macos-tcc-prompt-localization.test.mjs": 75, + "config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs": 450, + "config/scripts/node-old-space-limit.test.mjs": 6, + "config/scripts/node-pty-console-list-agent-patch.test.mjs": 7, + "config/scripts/node-pty-master-cloexec-patch.test.mjs": 22, + "config/scripts/node-pty-windows-pty-teardown-patch.test.mjs": 59, + "config/scripts/orca-cli-skill-guidance.test.mjs": 14, + "config/scripts/orca-dev-bin.test.mjs": 68, + "config/scripts/orca-linear-skill-guidance.test.mjs": 15, + "config/scripts/orcad-operations-restart-safety.test.mjs": 5, + "config/scripts/orchestration-guide-command-contract.test.mjs": 11, + "config/scripts/orchestration-skill-guidance.test.mjs": 23, + "config/scripts/oxc-cli-invocation.test.mjs": 128, + "config/scripts/oxlint-cli-invocation.test.mjs": 46, + "config/scripts/package-electron-runtime-contract.test.mjs": 360, + "config/scripts/packaged-browser-lane-contract.test.mjs": 7, + "config/scripts/packaged-hang-watchdog-worker-contract.test.mjs": 55, + "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 25, + "config/scripts/packaged-source-map-prune.test.mjs": 53, + "config/scripts/patched-dependencies-frozen-install.test.mjs": 451, + "config/scripts/plain-node-entry-guard.test.ts": 610, + "config/scripts/pnpm-cli-invocation.test.mjs": 6, + "config/scripts/pr-code-change-scope.test.mjs": 346, + "config/scripts/pr-e2e-gate-contract.test.mjs": 108, + "config/scripts/pr-e2e-native-only-routing.test.mjs": 19, + "config/scripts/pr-test-loc-summary.test.mjs": 89, + "config/scripts/pr-workflow-lint-parity.test.mjs": 53, + "config/scripts/pr-workflow-parallelism.test.mjs": 898, + "config/scripts/project-renderer-web-client.test.mjs": 213, + "config/scripts/pty-transcript-secret-scan.test.mjs": 14, + "config/scripts/publish-complete-draft-releases.test.mjs": 168, + "config/scripts/quadratic-buffer-concat-plugin.test.mjs": 2010, + "config/scripts/rebuild-native-deps-node-pty.test.mjs": 2338, + "config/scripts/rebuild-native-deps.test.mjs": 831, + "config/scripts/reclaim-dev-electron-bundles.test.ts": 35, + "config/scripts/regenerate-xterm-patches.test.mjs": 300, + "config/scripts/relay-artifact-manifest.test.mjs": 3554, + "config/scripts/relay-asset-line-ending-pin.test.mjs": 74, + "config/scripts/release-blocker-fixes.test.mjs": 39, + "config/scripts/release-cut-signpath-slack.test.mjs": 72, + "config/scripts/release-cut-sourcemap-publish.test.mjs": 11, + "config/scripts/release-cut-token-permissions.test.mjs": 19, + "config/scripts/release-e2e-dispatch-contract.test.mjs": 8, + "config/scripts/release-mac-build-workflow-dispatch.test.mjs": 10, + "config/scripts/release-rc-history.test.mjs": 166, + "config/scripts/renderer-boot-graph.test.mjs": 54, + "config/scripts/renderer-scrollbar-style-plugin.test.mjs": 2888, + "config/scripts/replace-cached-nsis-elevate.test.mjs": 1880, + "config/scripts/resolve-7za-path.test.mjs": 1412, + "config/scripts/run-codex-real-account-validation.test.ts": 3652, + "config/scripts/run-internal-dev-setup.test.mjs": 10, + "config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs": 10, + "config/scripts/run-terminal-scale-perf-report-gate.test.mjs": 17, + "config/scripts/shared-electron-dist-cache.test.ts": 44, + "config/scripts/shebang-script-line-ending-pin.test.mjs": 77, + "config/scripts/skill-critical-guidance.test.mjs": 6, + "config/scripts/skill-description-length.test.mjs": 26, + "config/scripts/skill-recipe-shell.test.mjs": 43, + "config/scripts/skill-sharing-release-workflow.test.mjs": 12, + "config/scripts/skill-update-roundtrip-workflow.test.mjs": 5, + "config/scripts/skills-cli-package-workflow.test.mjs": 8, + "config/scripts/sort-comparator-performance-plugin.test.mjs": 269, + "config/scripts/space-sharing-copy.test.ts": 40, + "config/scripts/ssh-browser-e2e-routing.test.mjs": 23, + "config/scripts/ssh-localhost-e2e-routing.test.mjs": 28, + "config/scripts/static-appimage-package-contract.test.mjs": 42, + "config/scripts/summarize-terminal-perf-report.test.mjs": 52, + "config/scripts/telemetry-bundle-constant-patterns.test.mjs": 4, + "config/scripts/terminal-ime-e2e-workflow.test.mjs": 9, + "config/scripts/terminal-ime-engagement-receipt.test.mjs": 11, + "config/scripts/terminal-perf-report-annotations.test.mjs": 4, + "config/scripts/trim-windows-icon-source.test.mjs": 121, + "config/scripts/verify-cli-bin.test.mjs": 64, + "config/scripts/verify-dev-channel-packaging.test.mjs": 666, + "config/scripts/verify-linux-glibc-floor.test.mjs": 118, + "config/scripts/verify-localization-catalog.test.mjs": 46, + "config/scripts/verify-localization-extraction.test.mjs": 5, + "config/scripts/verify-packaged-browser-participation.test.mjs": 10, + "config/scripts/verify-packaged-daemon-entry.test.mjs": 111, + "config/scripts/verify-packaged-node-pty-job-ownership.test.mjs": 10, + "config/scripts/verify-packaged-plugin-resources.test.mjs": 67, + "config/scripts/verify-release-required-assets.test.mjs": 8, + "config/scripts/verify-skills-cli-runtime.test.mjs": 141, + "config/scripts/verify-windows-inner-signature.test.mjs": 20, + "config/scripts/verify-wsl-e2e-participation.test.mjs": 9, + "config/scripts/websocket-server-loopback-bind.test.ts": 5, + "config/scripts/win-crash-survival-e2e.test.mjs": 12, + "config/scripts/win32-test-lane-registration.test.mjs": 22, + "config/scripts/windows-cmd-shim-spawn-boundary.test.mjs": 6, + "config/scripts/windows-process-tree-gyp-path.test.mjs": 42, + "config/scripts/windows-process-tree-gyp-rebuild.test.mjs": 49, + "config/scripts/windows-process-tree-patch-contract.test.mjs": 530, + "config/scripts/windows-pty-native-capability-workflow.test.mjs": 7, + "config/scripts/windows-signing-gate-toolset.test.mjs": 53, + "config/scripts/windows-signing-workflow-contract.test.mjs": 531, + "config/scripts/windows-uninstaller-signing.test.mjs": 17, + "config/scripts/workflow-ref-mirror-case-safety.test.mjs": 74, + "config/scripts/workflow-ref-reachability.test.mjs": 592, + "config/scripts/wsl-e2e-lane-contract.test.mjs": 76, + "config/scripts/xterm-webgl-runtime-contract.test.mjs": 19, + "src/cli/agent-context.test.ts": 16, + "src/cli/args.test.ts": 36, + "src/cli/automation-format.test.ts": 16, + "src/cli/automation-owner-conflict-recovery.test.ts": 12, + "src/cli/base64-payload-byte-count.test.ts": 3, + "src/cli/browser-cookie-credentials-empty-value.test.ts": 58, + "src/cli/browser-storage-empty-value.test.ts": 48, + "src/cli/browser.test.ts": 109, + "src/cli/cli-command-name-parity.test.ts": 7, + "src/cli/cli-version.test.ts": 13, + "src/cli/codex-command-classification.test.ts": 11, + "src/cli/command-suggestion-budget.test.ts": 25, + "src/cli/command-suggestion.test.ts": 20, + "src/cli/computer-format.test.ts": 3, + "src/cli/emulator-logcat-format.test.ts": 4, + "src/cli/execution-host-flag.test.ts": 21, + "src/cli/flags.test.ts": 4, + "src/cli/format-recovery.test.ts": 10, + "src/cli/format.test.ts": 29, + "src/cli/handler-group-manifest.test.ts": 638, + "src/cli/handlers/account.test.ts": 558, + "src/cli/handlers/agent-hooks.test.ts": 83, + "src/cli/handlers/artifacts.test.ts": 36, + "src/cli/handlers/automation-destination-fencing.test.ts": 17, + "src/cli/handlers/automation-owner-fencing.test.ts": 16, + "src/cli/handlers/computer-action-routing.test.ts": 69, + "src/cli/handlers/computer-action-validation.test.ts": 86, + "src/cli/handlers/computer-state-formatting.test.ts": 65, + "src/cli/handlers/computer.test.ts": 103, + "src/cli/handlers/core.test.ts": 7, + "src/cli/handlers/emulator.test.ts": 37, + "src/cli/handlers/file-absolute-paths.test.ts": 60, + "src/cli/handlers/file.test.ts": 46, + "src/cli/handlers/interactive-login-interruption.test.ts": 12, + "src/cli/handlers/linear.test.ts": 131, + "src/cli/handlers/orchestration-caller-identity-cli.test.ts": 18, + "src/cli/handlers/orchestration-check-identity.test.ts": 12, + "src/cli/handlers/orchestration-federated-legacy-settlement.test.ts": 8, + "src/cli/handlers/orchestration-gate-cli.test.ts": 99, + "src/cli/handlers/orchestration-legacy-read-only.test.ts": 9, + "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts": 15, + "src/cli/handlers/orchestration-lifecycle-rejection.test.ts": 12, + "src/cli/handlers/orchestration-migration.test.ts": 12, + "src/cli/handlers/orchestration-module-boundaries.test.ts": 11, + "src/cli/handlers/orchestration-request-show-cli.test.ts": 7, + "src/cli/handlers/orchestration-run-cli.test.ts": 22, + "src/cli/handlers/orchestration-send-receipt-warnings.test.ts": 7, + "src/cli/handlers/orchestration-task-create-cli.test.ts": 8, + "src/cli/handlers/orchestration-task-list-brief.test.ts": 12, + "src/cli/handlers/orchestration-timeout-cli.test.ts": 29, + "src/cli/handlers/orchestration-timeout.test.ts": 26, + "src/cli/handlers/orchestration-windows-ask-cli.test.ts": 11, + "src/cli/handlers/orchestration-worker-cli.test.ts": 32, + "src/cli/handlers/orchestration-worker-show-wait-cli.test.ts": 9, + "src/cli/handlers/orchestration.test.ts": 31, + "src/cli/handlers/orchestration/worker-list-run-scope.test.ts": 13, + "src/cli/handlers/orchestration/worker-output.test.ts": 13, + "src/cli/handlers/skill-sharing.test.ts": 16, + "src/cli/handlers/terminal.test.ts": 29, + "src/cli/host-selector-alternatives.test.ts": 14, + "src/cli/index-automation-identifiers.test.ts": 42, + "src/cli/index-automation-schedule.test.ts": 64, + "src/cli/index-automation-session-reuse.test.ts": 54, + "src/cli/index-automation-source-context.test.ts": 75, + "src/cli/index-automation-target.test.ts": 89, + "src/cli/index-device-commands.test.ts": 57, + "src/cli/index-environment-commands.test.ts": 103, + "src/cli/index-local-command-routing-flags.test.ts": 141, + "src/cli/index-memory-diagnostics.test.ts": 54, + "src/cli/index-omitted-host-scope-selectors.test.ts": 54, + "src/cli/index-orchestration.test.ts": 72, + "src/cli/index-project-setup.test.ts": 95, + "src/cli/index-serve-command.test.ts": 121, + "src/cli/index-terminal-commands.test.ts": 78, + "src/cli/index-terminal-list-host-scope.test.ts": 92, + "src/cli/index-vm-recipe-doctor.test.ts": 125, + "src/cli/index-worktree-create-agent.test.ts": 64, + "src/cli/index-worktree-create-linear.test.ts": 44, + "src/cli/index-worktree-create-parent.test.ts": 118, + "src/cli/index-worktree-create-target.test.ts": 65, + "src/cli/index-worktree-selector-resolution.test.ts": 185, + "src/cli/index-worktree-set.test.ts": 83, + "src/cli/index.test.ts": 263, + "src/cli/linear-format.test.ts": 9, + "src/cli/main-module-bundle-parity.test.ts": 14, + "src/cli/orchestration-dispatch-refusal-format.test.ts": 8, + "src/cli/orchestration-mutation-recovery.test.ts": 52, + "src/cli/orchestration-structured-sender-identity.test.ts": 93, + "src/cli/orchestration-structured-session-no-identity.test.ts": 11, + "src/cli/quote-stripped-json-flag.test.ts": 8, + "src/cli/registry-parity.test.ts": 7, + "src/cli/retry-request-flag.test.ts": 9, + "src/cli/runtime-client-deferral.test.ts": 486, + "src/cli/runtime-client.test.ts": 110, + "src/cli/runtime/client-recovery.test.ts": 250, + "src/cli/runtime/client-timeout-policy.test.ts": 13, + "src/cli/runtime/envelope-schema.test.ts": 33, + "src/cli/runtime/environments.test.ts": 27, + "src/cli/runtime/launch.test.ts": 53, + "src/cli/runtime/orchestration-recovery-command.test.ts": 5, + "src/cli/runtime/serve-signal-exit-diagnostic.test.ts": 28, + "src/cli/runtime/status.test.ts": 19, + "src/cli/runtime/transport-framing.test.ts": 78, + "src/cli/runtime/transport.test.ts": 533, + "src/cli/runtime/types.test.ts": 5, + "src/cli/runtime/websocket-transport-error.test.ts": 8, + "src/cli/runtime/websocket-transport.test.ts": 176, + "src/cli/serve-electron-flag-parity.test.ts": 8, + "src/cli/shell-command-quote.test.ts": 8, + "src/cli/skill-guide-cli-parity.test.ts": 14, + "src/cli/skills-reference-selector.test.ts": 28, + "src/cli/skills.test.ts": 963, + "src/cli/specs/account.test.ts": 6, + "src/cli/specs/bundled-guide-flags.test.ts": 40, + "src/cli/specs/computer.test.ts": 9, + "src/cli/specs/orchestration.test.ts": 16, + "src/cli/specs/skills.test.ts": 6, + "src/cli/terminal-format-draft.test.ts": 6, + "src/cli/terminal-format.test.ts": 12, + "src/cli/terminal-list-host-scope-format.test.ts": 8, + "src/cli/terminal-read-screen.test.ts": 68, + "src/cli/vocabulary-policy.test.ts": 8, + "src/cli/worktree-selector-wsl-posix-path.test.ts": 11, + "src/main/active-view-persistence-boundary.test.ts": 725, + "src/main/active-view-preference-sync-flush-veto.test.ts": 777, + "src/main/active-view-preference.test.ts": 20, + "src/main/agent-auth-restart-preservation.test.ts": 16, + "src/main/agent-awake-service-platform-assertions.test.ts": 10, + "src/main/agent-awake-service.test.ts": 29, + "src/main/agent-hooks/branch-rename-failure-output.test.ts": 7, + "src/main/agent-hooks/ended-process-reconciliation.test.ts": 34, + "src/main/agent-hooks/first-work-branch-rename.test.ts": 140, + "src/main/agent-hooks/first-work-folder-rename.test.ts": 13, + "src/main/agent-hooks/hook-script-outside-orca.test.ts": 32, + "src/main/agent-hooks/hook-status-session-tabs-republish.test.ts": 22, + "src/main/agent-hooks/install-telemetry.test.ts": 10, + "src/main/agent-hooks/installer-utils-remote.test.ts": 14, + "src/main/agent-hooks/installer-utils.test.ts": 66, + "src/main/agent-hooks/local-agent-cli-presence.test.ts": 11, + "src/main/agent-hooks/managed-agent-hook-controls.test.ts": 76, + "src/main/agent-hooks/managed-hook-detection-commands.test.ts": 5, + "src/main/agent-hooks/managed-hook-install-lock.test.ts": 263, + "src/main/agent-hooks/managed-hook-local-filesystem.test.ts": 147, + "src/main/agent-hooks/managed-hook-owner-identity.test.ts": 33, + "src/main/agent-hooks/managed-hook-runtime.test.ts": 41, + "src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts": 27, + "src/main/agent-hooks/managed-hook-script-refresh.test.ts": 108, + "src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts": 1979, + "src/main/agent-hooks/managed-hook-timeout.test.ts": 1585, + "src/main/agent-hooks/managed-toml-ownership.test.ts": 12, + "src/main/agent-hooks/manual-compact-hook-stream.test.ts": 119, + "src/main/agent-hooks/manual-compact-status-cleanup.test.ts": 6, + "src/main/agent-hooks/migration-unsupported-pty-state.test.ts": 8, + "src/main/agent-hooks/opencode-message-part-flood-bench.test.ts": 1234, + "src/main/agent-hooks/remote-hook-service-installers.test.ts": 89, + "src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts": 75, + "src/main/agent-hooks/server-ai-vault-liveness.test.ts": 34, + "src/main/agent-hooks/server-amp-normalization.test.ts": 13, + "src/main/agent-hooks/server-authority-evidence.test.ts": 21, + "src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts": 213, + "src/main/agent-hooks/server-claude-normalization.test.ts": 36, + "src/main/agent-hooks/server-claude-permission-visibility.test.ts": 114, + "src/main/agent-hooks/server-claude-statusline.test.ts": 41, + "src/main/agent-hooks/server-closed-tab-suppression.test.ts": 167, + "src/main/agent-hooks/server-codex-normalization.test.ts": 14, + "src/main/agent-hooks/server-codex-subagent-transcript.test.ts": 4092, + "src/main/agent-hooks/server-copilot-normalization.test.ts": 416, + "src/main/agent-hooks/server-cursor-normalization.test.ts": 13, + "src/main/agent-hooks/server-droid-normalization.test.ts": 10, + "src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts": 35, + "src/main/agent-hooks/server-gemini-normalization.test.ts": 13, + "src/main/agent-hooks/server-grok-discovery.test.ts": 513, + "src/main/agent-hooks/server-hook-http-ingest.test.ts": 94, + "src/main/agent-hooks/server-ingest-remote.test.ts": 45, + "src/main/agent-hooks/server-ingest-structured-status.test.ts": 34, + "src/main/agent-hooks/server-ingest-terminal-status.test.ts": 18, + "src/main/agent-hooks/server-interrupt-inference-guards.test.ts": 76, + "src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts": 35, + "src/main/agent-hooks/server-interrupt-inference-validation.test.ts": 24, + "src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts": 75, + "src/main/agent-hooks/server-last-status-hydrate-validation.test.ts": 83, + "src/main/agent-hooks/server-last-status-lead-boundary.test.ts": 172, + "src/main/agent-hooks/server-last-status-restored-children.test.ts": 596, + "src/main/agent-hooks/server-last-status-write.test.ts": 125, + "src/main/agent-hooks/server-observation-provenance.test.ts": 27, + "src/main/agent-hooks/server-opencode-lifecycle.test.ts": 228, + "src/main/agent-hooks/server-opencode-normalization.test.ts": 15, + "src/main/agent-hooks/server-pane-authority.test.ts": 254, + "src/main/agent-hooks/server-pi-normalization.test.ts": 14, + "src/main/agent-hooks/server-prompt-sent-telemetry.test.ts": 59, + "src/main/agent-hooks/server-relay-listener-replay.test.ts": 25, + "src/main/agent-hooks/server-reminted-pane-key.test.ts": 112, + "src/main/agent-hooks/server-replay-evidence-clock.test.ts": 28, + "src/main/agent-hooks/server-retired-pane-new-turn.test.ts": 21, + "src/main/agent-hooks/server-start-failure-lifecycle.test.ts": 32, + "src/main/agent-hooks/server-status-listener-fanout.test.ts": 84, + "src/main/agent-hooks/server-transport-interference.test.ts": 5826, + "src/main/agent-hooks/server.claude-interactive-question.test.ts": 21, + "src/main/agent-hooks/spool.test.ts": 134, + "src/main/agent-hooks/terminal-handle-row-identity.test.ts": 40, + "src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts": 8, + "src/main/agent-hooks/windows-hook-payload-delivery.test.ts": 8, + "src/main/agent-hooks/windows-hook-post-interpreter.test.ts": 83, + "src/main/agent-hooks/windows-powershell-hook-launcher.test.ts": 5, + "src/main/agent-hooks/wsl-guest-plugin-install.test.ts": 9, + "src/main/agent-hooks/wsl-hook-relay-launch.test.ts": 7, + "src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts": 2716, + "src/main/agent-hooks/wsl-hook-relay-manager.test.ts": 1110, + "src/main/agent-hooks/wsl-hook-relay-reattach.test.ts": 6, + "src/main/agent-hooks/wsl-hook-relay-recovery.test.ts": 2323, + "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts": 17, + "src/main/agent-state-file-reader.test.ts": 39, + "src/main/agent-trust-presets.test.ts": 33, + "src/main/ai-vault-search/session-search-content-hash.test.ts": 5, + "src/main/ai-vault-search/session-search-cwd-key.test.ts": 9, + "src/main/ai-vault-search/session-search-deleted-sources.test.ts": 176, + "src/main/ai-vault-search/session-search-directory-listings.test.ts": 10, + "src/main/ai-vault-search/session-search-engine.test.ts": 1166, + "src/main/ai-vault-search/session-search-file-write.test.ts": 423, + "src/main/ai-vault-search/session-search-fts5-contract.test.ts": 62, + "src/main/ai-vault-search/session-search-hit-ranking.test.ts": 13, + "src/main/ai-vault-search/session-search-identifier-split.test.ts": 8, + "src/main/ai-vault-search/session-search-index-consumer.test.ts": 172, + "src/main/ai-vault-search/session-search-index-generation.test.ts": 111, + "src/main/ai-vault-search/session-search-index-pass.test.ts": 134, + "src/main/ai-vault-search/session-search-index-writer.test.ts": 114, + "src/main/ai-vault-search/session-search-indexer.test.ts": 2283, + "src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts": 4538, + "src/main/ai-vault-search/session-search-live-transcript.test.ts": 83, + "src/main/ai-vault-search/session-search-merged-roots.test.ts": 111, + "src/main/ai-vault-search/session-search-message-rows.test.ts": 787, + "src/main/ai-vault-search/session-search-native-chat-indexing.test.ts": 211, + "src/main/ai-vault-search/session-search-opencode-decline.test.ts": 218, + "src/main/ai-vault-search/session-search-orphan-rows.test.ts": 179, + "src/main/ai-vault-search/session-search-paging.test.ts": 497, + "src/main/ai-vault-search/session-search-query-planner.test.ts": 14, + "src/main/ai-vault-search/session-search-read-decision.test.ts": 4, + "src/main/ai-vault-search/session-search-retention-delete.test.ts": 108, + "src/main/ai-vault-search/session-search-retention-policy.test.ts": 4, + "src/main/ai-vault-search/session-search-row-filter.test.ts": 194, + "src/main/ai-vault-search/session-search-row-identity.test.ts": 52, + "src/main/ai-vault-search/session-search-scan-roots.test.ts": 5, + "src/main/ai-vault-search/session-search-schema.test.ts": 322, + "src/main/ai-vault-search/session-search-sidebar-parity.test.ts": 213, + "src/main/ai-vault-search/session-search-snippet-marks.test.ts": 118, + "src/main/ai-vault-search/session-search-store-is-memory.test.ts": 305, + "src/main/ai-vault-search/session-search-synthetic-corpus.test.ts": 188, + "src/main/ai-vault-search/session-search-typo-policy.test.ts": 556, + "src/main/ai-vault-search/session-search-typo-scope.test.ts": 47, + "src/main/ai-vault/ai-vault-scan-cancellation.test.ts": 5, + "src/main/ai-vault/ai-vault-scan-coordinator.test.ts": 16, + "src/main/ai-vault/cached-session-list.test.ts": 10, + "src/main/ai-vault/claude-project-dir-encoding.test.ts": 10, + "src/main/ai-vault/codex-session-collection.test.ts": 32, + "src/main/ai-vault/codex-session-root-dedup.test.ts": 13, + "src/main/ai-vault/local-log-tail-reader.test.ts": 25, + "src/main/ai-vault/remote-session-parse-cache.test.ts": 26, + "src/main/ai-vault/remote-session-scan-batching.test.ts": 6, + "src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts": 29, + "src/main/ai-vault/remote-session-scanner.test.ts": 37, + "src/main/ai-vault/remote-session-sidecar-observation.test.ts": 31, + "src/main/ai-vault/runtime-session-scanner.test.ts": 16, + "src/main/ai-vault/session-delete-target.test.ts": 20, + "src/main/ai-vault/session-delete.test.ts": 22, + "src/main/ai-vault/session-first-user-prompt-read.test.ts": 28, + "src/main/ai-vault/session-list-result-validation.test.ts": 27, + "src/main/ai-vault/session-list-results.test.ts": 12, + "src/main/ai-vault/session-newest-files.test.ts": 121, + "src/main/ai-vault/session-parse-cache-persistence.test.ts": 73, + "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 9, + "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 57, + "src/main/ai-vault/session-scanner-background.test.ts": 8, + "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 23, + "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 21, + "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 71, + "src/main/ai-vault/session-scanner-claude-title.test.ts": 32, + "src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts": 43, + "src/main/ai-vault/session-scanner-cline-parser.test.ts": 32, + "src/main/ai-vault/session-scanner-codex-dual-root.test.ts": 51, + "src/main/ai-vault/session-scanner-codex-fast-path.test.ts": 180, + "src/main/ai-vault/session-scanner-codex-parser.test.ts": 47, + "src/main/ai-vault/session-scanner-codex-title-index.test.ts": 175, + "src/main/ai-vault/session-scanner-codex-tool-records.test.ts": 16, + "src/main/ai-vault/session-scanner-codex-workers.test.ts": 36, + "src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts": 45, + "src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts": 154, + "src/main/ai-vault/session-scanner-dedup-batches.test.ts": 228, + "src/main/ai-vault/session-scanner-devin-parser.test.ts": 13, + "src/main/ai-vault/session-scanner-directory-reader.test.ts": 15, + "src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts": 16, + "src/main/ai-vault/session-scanner-first-user-prompt.test.ts": 9, + "src/main/ai-vault/session-scanner-fs-import-guard.test.ts": 28, + "src/main/ai-vault/session-scanner-graph-parsers.test.ts": 6, + "src/main/ai-vault/session-scanner-grok-parser.test.ts": 26, + "src/main/ai-vault/session-scanner-grok-user-text.test.ts": 6, + "src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts": 25, + "src/main/ai-vault/session-scanner-injected-title.test.ts": 29, + "src/main/ai-vault/session-scanner-jsonl-reader.test.ts": 84, + "src/main/ai-vault/session-scanner-kimi-index-cache.test.ts": 31, + "src/main/ai-vault/session-scanner-kimi-parser.test.ts": 153, + "src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts": 24, + "src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts": 38, + "src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts": 15, + "src/main/ai-vault/session-scanner-opencode-parser.test.ts": 25, + "src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts": 23, + "src/main/ai-vault/session-scanner-opencode-sources.test.ts": 9, + "src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts": 539, + "src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts": 58, + "src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts": 4131, + "src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts": 9, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts": 23, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.test.ts": 6, + "src/main/ai-vault/session-scanner-opencode-sqlite.test.ts": 225, + "src/main/ai-vault/session-scanner-parse-cache-agents.test.ts": 198, + "src/main/ai-vault/session-scanner-parse-cache.test.ts": 57, + "src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts": 31, + "src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts": 21, + "src/main/ai-vault/session-scanner-preview-window-truncation.test.ts": 6, + "src/main/ai-vault/session-scanner-recoverable-empty.test.ts": 57, + "src/main/ai-vault/session-scanner-roots.test.ts": 7, + "src/main/ai-vault/session-scanner-scope.test.ts": 82, + "src/main/ai-vault/session-scanner-service-client.test.ts": 178, + "src/main/ai-vault/session-scanner-service-entry-path.test.ts": 5, + "src/main/ai-vault/session-scanner-service-entry.test.ts": 486, + "src/main/ai-vault/session-scanner-service-env.test.ts": 8, + "src/main/ai-vault/session-scanner-service-restart-policy.test.ts": 6, + "src/main/ai-vault/session-scanner-service-spawn.test.ts": 6, + "src/main/ai-vault/session-scanner-values.test.ts": 21, + "src/main/ai-vault/session-scanner-worker-client.test.ts": 17, + "src/main/ai-vault/session-scanner.test.ts": 109, + "src/main/ai-vault/session-sidecar-stat.test.ts": 9, + "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 22, + "src/main/ai-vault/session-title-file-reader.test.ts": 15, + "src/main/ai-vault/session-transcript-consumers.test.ts": 107, + "src/main/ai-vault/session-transcript-message-content.test.ts": 10, + "src/main/ai-vault/ssh-session-list.test.ts": 87, + "src/main/ai-vault/structured-session-ownership.test.ts": 18, + "src/main/amp/hook-service.test.ts": 13, + "src/main/antigravity/hook-service.test.ts": 61, + "src/main/antigravity/windows-hook-payload-delivery.test.ts": 10, + "src/main/app-icon.test.ts": 24, + "src/main/app-relaunch.test.ts": 7, + "src/main/appimage-runtime-identity.test.ts": 36, + "src/main/appkit-scene-mutation.test.ts": 9, + "src/main/artifacts/artifact-cloud-config.test.ts": 6, + "src/main/artifacts/artifact-cloud-recovery.test.ts": 132, + "src/main/artifacts/artifact-cloud-service-races.test.ts": 219, + "src/main/artifacts/artifact-cloud-service.test.ts": 725, + "src/main/artifacts/artifact-create-intent-store.test.ts": 339, + "src/main/artifacts/artifact-recovery-directory-fsync.test.ts": 14, + "src/main/artifacts/artifact-share-record-store.test.ts": 310, + "src/main/asar-transparent-fs.test.ts": 9, + "src/main/automations/automation-dispatch-host-fence.test.ts": 1300, + "src/main/automations/automation-owner-fencing.test.ts": 1874, + "src/main/automations/automation-owner-migration.test.ts": 17, + "src/main/automations/automation-run-terminal-surface.test.ts": 34, + "src/main/automations/automation-run-writer.test.ts": 7, + "src/main/automations/automation-skip-coalescing.test.ts": 1783, + "src/main/automations/automation-ssh-readoption-migration.test.ts": 9, + "src/main/automations/automation-update-host-retarget.test.ts": 1187, + "src/main/automations/automation-workspace-host-attribution.test.ts": 757, + "src/main/automations/external-automation-manager-cache.test.ts": 12, + "src/main/automations/external-automation-owner-guard.test.ts": 8, + "src/main/automations/external-automation-probe-scheduler.test.ts": 123, + "src/main/automations/external-job-run-sorting.test.ts": 170, + "src/main/automations/external-manager-scoped.test.ts": 23, + "src/main/automations/external-manager.test.ts": 15, + "src/main/automations/headless-workspace-create.test.ts": 9, + "src/main/automations/hermes-cron-output.test.ts": 73, + "src/main/automations/precheck-runner.test.ts": 1066, + "src/main/automations/refused-manual-run.test.ts": 2290, + "src/main/automations/retained-run-reconciliation.test.ts": 491, + "src/main/automations/run-completion-watcher.test.ts": 1046, + "src/main/automations/run-target-resolution.test.ts": 6, + "src/main/automations/runtime-terminal-run-observer.test.ts": 54, + "src/main/automations/service-precheck.test.ts": 931, + "src/main/automations/service.test.ts": 1218, + "src/main/azure-devops/azure-devops-api-request.test.ts": 61, + "src/main/azure-devops/client.test.ts": 84, + "src/main/azure-devops/pull-request-creation.test.ts": 74, + "src/main/azure-devops/pull-request-mappers.test.ts": 5, + "src/main/azure-devops/repository-ref.test.ts": 33, + "src/main/bitbucket/client.test.ts": 102, + "src/main/bitbucket/credential-connection.test.ts": 151, + "src/main/bitbucket/credential-store.test.ts": 146, + "src/main/bitbucket/pull-request-creation.test.ts": 70, + "src/main/bitbucket/pull-request-mappers.test.ts": 7, + "src/main/bitbucket/repository-ref.test.ts": 24, + "src/main/bitbucket/status-no-decrypt.test.ts": 171, + "src/main/browser/agent-browser-bridge-automation-visibility.test.ts": 91, + "src/main/browser/agent-browser-bridge-command-transport.test.ts": 102, + "src/main/browser/agent-browser-bridge-mouse-input.test.ts": 24, + "src/main/browser/agent-browser-bridge-navigation.test.ts": 156, + "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts": 244, + "src/main/browser/agent-browser-bridge-tab-routing.test.ts": 89, + "src/main/browser/agent-browser-bridge-text-input.test.ts": 104, + "src/main/browser/agent-browser-orphan-sweep.test.ts": 14, + "src/main/browser/agent-browser-process-environment.test.ts": 9, + "src/main/browser/agent-browser-session-reset.test.ts": 9, + "src/main/browser/browser-certificate-trust-controller.test.ts": 186, + "src/main/browser/browser-clicked-link-routing.test.ts": 111, + "src/main/browser/browser-client-download-relay.test.ts": 39, + "src/main/browser/browser-client-download-routing.test.ts": 12, + "src/main/browser/browser-client-file-channel-negotiation.test.ts": 13, + "src/main/browser/browser-client-file-channel-reconnect.test.ts": 317, + "src/main/browser/browser-client-host-attach-request.test.ts": 13, + "src/main/browser/browser-client-host-authority-replacement.test.ts": 20, + "src/main/browser/browser-client-host-command-dispatcher.test.ts": 26, + "src/main/browser/browser-client-host-id.test.ts": 37, + "src/main/browser/browser-client-host-placement-preparation.test.ts": 17, + "src/main/browser/browser-client-host-published-url-wiring.test.ts": 451, + "src/main/browser/browser-client-host-reconciliation-command-order.test.ts": 19, + "src/main/browser/browser-client-network-route-registry.test.ts": 45, + "src/main/browser/browser-client-page-automation-runtime.test.ts": 15, + "src/main/browser/browser-client-page-command-executor-fencing.test.ts": 25, + "src/main/browser/browser-client-page-command-executor.test.ts": 33, + "src/main/browser/browser-client-page-command-integration.test.ts": 21, + "src/main/browser/browser-client-page-execution-host-supersession.test.ts": 12, + "src/main/browser/browser-client-page-inventory.test.ts": 68, + "src/main/browser/browser-client-page-metadata-transport.test.ts": 21, + "src/main/browser/browser-client-page-published-url.test.ts": 12, + "src/main/browser/browser-client-page-reconciliation-adapters.test.ts": 28, + "src/main/browser/browser-client-page-renderer-bridge.test.ts": 35, + "src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts": 1571, + "src/main/browser/browser-client-page-renderer-runtime.test.ts": 12, + "src/main/browser/browser-client-page-unavailability.test.ts": 13, + "src/main/browser/browser-client-page-upload-routing.test.ts": 60, + "src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts": 20, + "src/main/browser/browser-client-route-cookie-import.test.ts": 14, + "src/main/browser/browser-client-upload-command.test.ts": 34, + "src/main/browser/browser-client-upload-staging.test.ts": 57, + "src/main/browser/browser-client-upload-transfer.test.ts": 3444, + "src/main/browser/browser-cookie-clear-preserve.test.ts": 71, + "src/main/browser/browser-cookie-clear-store-lifecycle.test.ts": 11, + "src/main/browser/browser-cookie-clear-store.test.ts": 25, + "src/main/browser/browser-cookie-import-app-bound-prefix.test.ts": 7, + "src/main/browser/browser-cookie-import-clear-atomicity.test.ts": 15, + "src/main/browser/browser-cookie-import-concurrency.test.ts": 113, + "src/main/browser/browser-cookie-import-google-exclusion.test.ts": 135, + "src/main/browser/browser-cookie-import-partition-fidelity.test.ts": 502, + "src/main/browser/browser-cookie-import-partition-reland.electron.test.ts": 2913, + "src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts": 4236, + "src/main/browser/browser-cookie-import-partition-success.electron.test.ts": 513, + "src/main/browser/browser-cookie-import-partition.electron.test.ts": 606, + "src/main/browser/browser-cookie-import-plan-writes.test.ts": 19, + "src/main/browser/browser-cookie-import-policy.test.ts": 127, + "src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts": 1412, + "src/main/browser/browser-cookie-import-replacement.test.ts": 72, + "src/main/browser/browser-cookie-import-route-partition-staging.test.ts": 51, + "src/main/browser/browser-cookie-import-scope.test.ts": 72, + "src/main/browser/browser-cookie-import-undecryptable.test.ts": 285, + "src/main/browser/browser-cookie-import-validated-partition.electron.test.ts": 594, + "src/main/browser/browser-cookie-import-write.test.ts": 11, + "src/main/browser/browser-cookie-import.comet.test.ts": 427, + "src/main/browser/browser-cookie-import.helium.test.ts": 358, + "src/main/browser/browser-cookie-import.test.ts": 1644, + "src/main/browser/browser-cookie-registrable-family.test.ts": 19, + "src/main/browser/browser-cookie-samesite.electron.test.ts": 714, + "src/main/browser/browser-cookie-source-partition.test.ts": 8, + "src/main/browser/browser-cookie-validation.test.ts": 7, + "src/main/browser/browser-download-destination.test.ts": 15, + "src/main/browser/browser-execution-host-storage-identity.test.ts": 10, + "src/main/browser/browser-google-auth-ua.test.ts": 7, + "src/main/browser/browser-grab-payload.test.ts": 13, + "src/main/browser/browser-guest-shortcut-forwarding.test.ts": 31, + "src/main/browser/browser-guest-wheel-zoom-scroll.test.ts": 6, + "src/main/browser/browser-host-client-identity.test.ts": 30, + "src/main/browser/browser-host-lease-reconnect-delay.test.ts": 6, + "src/main/browser/browser-manager-annotation-bridge.test.ts": 16, + "src/main/browser/browser-manager-auth-user-agent.test.ts": 19, + "src/main/browser/browser-manager-client-hosted-downloads.test.ts": 135, + "src/main/browser/browser-manager-downloads.test.ts": 40, + "src/main/browser/browser-manager-grab-capture.test.ts": 13, + "src/main/browser/browser-manager-grab-mode.test.ts": 16, + "src/main/browser/browser-manager-grab-selection.test.ts": 37, + "src/main/browser/browser-manager-grab-shortcuts.test.ts": 14, + "src/main/browser/browser-manager-guest-lifecycle.test.ts": 33, + "src/main/browser/browser-manager-guest-policy-profile.test.ts": 11, + "src/main/browser/browser-manager-guest-shortcuts.test.ts": 26, + "src/main/browser/browser-manager-guest-visibility.test.ts": 16, + "src/main/browser/browser-manager-load-failure-replay.test.ts": 29, + "src/main/browser/browser-manager-popup-child.test.ts": 19, + "src/main/browser/browser-manager-popup-routing.test.ts": 77, + "src/main/browser/browser-manager-viewport-override.test.ts": 45, + "src/main/browser/browser-manager-viewport-partial-failure.test.ts": 13, + "src/main/browser/browser-network-deferred-socket.test.ts": 6, + "src/main/browser/browser-network-execution-route.test.ts": 14, + "src/main/browser/browser-network-tunnel-client-memory-budget.test.ts": 13, + "src/main/browser/browser-network-tunnel-client.test.ts": 36, + "src/main/browser/browser-network-tunnel-conformance.test.ts": 56, + "src/main/browser/browser-network-tunnel-outbound-memory-budget.test.ts": 7, + "src/main/browser/browser-network-tunnel-session-aggregate-memory.test.ts": 6, + "src/main/browser/browser-network-tunnel-session-stream-scoped-failure.test.ts": 26, + "src/main/browser/browser-network-tunnel-session.test.ts": 57, + "src/main/browser/browser-page-initiated-tab-budget.test.ts": 5, + "src/main/browser/browser-route-dns-prefetch.electron.test.ts": 4337, + "src/main/browser/browser-route-guest-popups.test.ts": 14, + "src/main/browser/browser-route-h3-egress.electron.test.ts": 13849, + "src/main/browser/browser-route-identity.test.ts": 8, + "src/main/browser/browser-route-partition-binding-capacity.test.ts": 27, + "src/main/browser/browser-route-partition-binding-store.test.ts": 42, + "src/main/browser/browser-route-partition-migration.test.ts": 261, + "src/main/browser/browser-route-partition-stability.test.ts": 30, + "src/main/browser/browser-route-partition-storage-lifecycle.test.ts": 33, + "src/main/browser/browser-route-partition-storage-retirement.test.ts": 14, + "src/main/browser/browser-route-partition-storage-runtime.test.ts": 10, + "src/main/browser/browser-route-persisted-worker-egress.electron.test.ts": 1471, + "src/main/browser/browser-route-prepared-page-rekey.test.ts": 9, + "src/main/browser/browser-route-session-registry.test.ts": 30, + "src/main/browser/browser-route-tcp-egress.electron.test.ts": 1082, + "src/main/browser/browser-route-webcontents-registry.test.ts": 43, + "src/main/browser/browser-route-webrtc-egress.electron.test.ts": 8203, + "src/main/browser/browser-screencast-lifecycle.test.ts": 16, + "src/main/browser/browser-screencast-snapshot-scaling.test.ts": 116, + "src/main/browser/browser-screencast-stream.test.ts": 549, + "src/main/browser/browser-session-cookie-staging.scoped.test.ts": 105, + "src/main/browser/browser-session-cookie-staging.test.ts": 8, + "src/main/browser/browser-session-partition-policies.test.ts": 36, + "src/main/browser/browser-session-partition-proxy-install.test.ts": 222, + "src/main/browser/browser-session-proxy.test.ts": 171, + "src/main/browser/browser-session-registry.persistence.test.ts": 550, + "src/main/browser/browser-session-registry.test.ts": 497, + "src/main/browser/browser-session-startup.test.ts": 33, + "src/main/browser/browser-session-ua-wire-identity.electron.test.ts": 2126, + "src/main/browser/browser-text-insertion.test.ts": 19, + "src/main/browser/browser-viewport-user-agent.test.ts": 9, + "src/main/browser/browser-webauthn-access.test.ts": 7, + "src/main/browser/browser-webauthn-account-picker.test.ts": 15, + "src/main/browser/browser-webauthn-profile-delete.test.ts": 64, + "src/main/browser/cdp-bridge-integration.test.ts": 3216, + "src/main/browser/cdp-bridge-state.test.ts": 26, + "src/main/browser/cdp-print-to-pdf.test.ts": 22, + "src/main/browser/cdp-screenshot.test.ts": 16, + "src/main/browser/cdp-ws-proxy-focus-replay.test.ts": 199, + "src/main/browser/cdp-ws-proxy.test.ts": 2405, + "src/main/browser/chromium-cookie-snapshot.test.ts": 279, + "src/main/browser/client-route-cookie-import-source-store.test.ts": 51, + "src/main/browser/doc-preview-download-block-notice.test.ts": 15, + "src/main/browser/doc-preview-failure-notice.test.ts": 7, + "src/main/browser/doc-preview-file-reader.test.ts": 31, + "src/main/browser/doc-preview-grant-registry.test.ts": 12, + "src/main/browser/doc-preview-guest-policy.test.ts": 46, + "src/main/browser/doc-preview-protocol.test.ts": 48, + "src/main/browser/electron-debugger-lease.test.ts": 4, + "src/main/browser/electron-probe-display-launch.test.ts": 8, + "src/main/browser/execution-route-socket-duplex.test.ts": 42, + "src/main/browser/grab-guest-script.test.ts": 29, + "src/main/browser/local-ssh-browser-partition-identity.test.ts": 11, + "src/main/browser/local-ssh-browser-partitions.probe-cache.test.ts": 7, + "src/main/browser/local-ssh-browser-route.test.ts": 40, + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts": 67, + "src/main/browser/offscreen-browser-backend.web-preferences.test.ts": 7, + "src/main/browser/paired-runtime-browser-client-host-composition.test.ts": 51, + "src/main/browser/paired-runtime-browser-client-host-identity-wiring.test.ts": 732, + "src/main/browser/paired-runtime-browser-client-host-reconnect.test.ts": 274, + "src/main/browser/paired-runtime-browser-client-host-registry.test.ts": 13, + "src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts": 60, + "src/main/browser/paired-runtime-browser-client-host-runtime.test.ts": 7, + "src/main/browser/paired-runtime-browser-client-host.test.ts": 95, + "src/main/browser/paired-runtime-browser-host-command-admission.test.ts": 24, + "src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts": 708, + "src/main/browser/paired-runtime-browser-host-lease.test.ts": 692, + "src/main/browser/paired-runtime-browser-host-reconciliation-negotiation.test.ts": 66, + "src/main/browser/paired-runtime-browser-network-route.test.ts": 1007, + "src/main/browser/popup-origin-bar-window.test.ts": 19, + "src/main/browser/remote-browser-socks-buffering.test.ts": 1148, + "src/main/browser/remote-browser-socks-server.test.ts": 296, + "src/main/browser/snapshot-engine.test.ts": 13, + "src/main/browser/ssh-browser-network-execution-route.test.ts": 175, + "src/main/browser/system-ssh-socks-client-socket.test.ts": 16, + "src/main/browser/wsl-browser-network-execution-route.test.ts": 128, + "src/main/browser/wsl-browser-network-relay-launch.test.ts": 23, + "src/main/claude-accounts/claude-account-service-account-selection.test.ts": 134, + "src/main/claude-accounts/claude-account-service-add-account.test.ts": 91, + "src/main/claude-accounts/claude-account-service-api-parity.test.ts": 65, + "src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts": 157, + "src/main/claude-accounts/claude-account-service-credential-capture.test.ts": 94, + "src/main/claude-accounts/claude-account-service-login-process.test.ts": 480, + "src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts": 193, + "src/main/claude-accounts/claude-duplicate-account.test.ts": 4, + "src/main/claude-accounts/claude-login-completion.oracle.test.ts": 105, + "src/main/claude-accounts/claude-structured-auth-policy.test.ts": 8, + "src/main/claude-accounts/claude-windows-interactive-login.test.ts": 75, + "src/main/claude-accounts/keychain.test.ts": 13, + "src/main/claude-accounts/live-pty-gate.test.ts": 13, + "src/main/claude-accounts/oauth-refresh.test.ts": 20, + "src/main/claude-accounts/runtime-auth-service-account-switching.test.ts": 147, + "src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts": 284, + "src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts": 431, + "src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts": 159, + "src/main/claude-accounts/runtime-auth-service-materialization.test.ts": 226, + "src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts": 298, + "src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts": 319, + "src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts": 197, + "src/main/claude-accounts/runtime-selection.test.ts": 4, + "src/main/claude-accounts/windows-command-invocation.test.ts": 8, + "src/main/claude-usage/claude-model-pricing.test.ts": 12, + "src/main/claude-usage/claude-usage-report-aggregation.test.ts": 6, + "src/main/claude-usage/scanner-large-directory.test.ts": 530, + "src/main/claude-usage/scanner-scan.test.ts": 114, + "src/main/claude-usage/scanner.test.ts": 31, + "src/main/claude-usage/store.test.ts": 35, + "src/main/claude-usage/transcript-record-parser-prefilter.test.ts": 5, + "src/main/claude-usage/worktree-attribution-scaling.test.ts": 13, + "src/main/claude/claude-agent-sdk-contract-pins.test.ts": 2683, + "src/main/claude/claude-agent-sdk-control-requests.test.ts": 10, + "src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts": 7, + "src/main/claude/claude-agent-sdk-exit-proof.test.ts": 12842, + "src/main/claude/claude-agent-sdk-import-boundary.test.ts": 951, + "src/main/claude/claude-agent-sdk-process-spawn.test.ts": 13, + "src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts": 15, + "src/main/claude/claude-agent-sdk-user-message-queue.test.ts": 109, + "src/main/claude/claude-background-task-resume.test.ts": 14, + "src/main/claude/claude-background-task-tracker.test.ts": 93, + "src/main/claude/claude-child-process-environment.test.ts": 4, + "src/main/claude/claude-command-lifecycle-frames.test.ts": 7, + "src/main/claude/claude-config-dir-pin.test.ts": 6, + "src/main/claude/claude-descendant-escalation-boundary.test.ts": 462, + "src/main/claude/claude-slash-command-catalog.test.ts": 10, + "src/main/claude/claude-stream-json-connection-close.test.ts": 140, + "src/main/claude/claude-stream-json-connection.test.ts": 22607, + "src/main/claude/claude-streamed-text-checkpoints.test.ts": 9, + "src/main/claude/claude-structured-auth-parity.test.ts": 100, + "src/main/claude/claude-structured-compaction.test.ts": 10, + "src/main/claude/claude-structured-content-parts.test.ts": 18, + "src/main/claude/claude-structured-control-actions.test.ts": 12, + "src/main/claude/claude-structured-dispatch-admission.test.ts": 15, + "src/main/claude/claude-structured-dispatch-content.test.ts": 10, + "src/main/claude/claude-structured-dispatch.test.ts": 1411, + "src/main/claude/claude-structured-effort-reporting.test.ts": 27, + "src/main/claude/claude-structured-inbound-control.test.ts": 15, + "src/main/claude/claude-structured-journal-translation-subagents.test.ts": 23, + "src/main/claude/claude-structured-journal-translation-turn-timing.test.ts": 22, + "src/main/claude/claude-structured-journal-translation.test.ts": 63, + "src/main/claude/claude-structured-launch-resolution.test.ts": 28, + "src/main/claude/claude-structured-location-support.test.ts": 6, + "src/main/claude/claude-structured-model-confirmation.test.ts": 18, + "src/main/claude/claude-structured-option-confirmation.test.ts": 16, + "src/main/claude/claude-structured-options.test.ts": 8, + "src/main/claude/claude-structured-owner-identity.test.ts": 10, + "src/main/claude/claude-structured-prompt-items.test.ts": 11, + "src/main/claude/claude-structured-provider-fallback.test.ts": 25, + "src/main/claude/claude-structured-rewind.test.ts": 25, + "src/main/claude/claude-structured-session-acquisition-processless.test.ts": 8, + "src/main/claude/claude-structured-session-adapter-turns.test.ts": 25, + "src/main/claude/claude-structured-session-adapter.test.ts": 63, + "src/main/claude/claude-structured-session-close.test.ts": 26, + "src/main/claude/claude-structured-session-commands.test.ts": 10, + "src/main/claude/claude-structured-session-recovery.test.ts": 47, + "src/main/claude/claude-subagent-group-row.test.ts": 6, + "src/main/claude/claude-subagent-id-aliases.test.ts": 7, + "src/main/claude/claude-subagent-roster.test.ts": 22, + "src/main/claude/claude-subagent-task-frames.test.ts": 12, + "src/main/claude/claude-transcript-rewind-proof.test.ts": 10, + "src/main/claude/claude-tui-exit.test.ts": 18, + "src/main/claude/claude-tui-resume-launch.test.ts": 10, + "src/main/claude/claude-tui-resume-proof.test.ts": 9, + "src/main/claude/compact-status-registration.test.ts": 27, + "src/main/claude/hook-service.test.ts": 58, + "src/main/claude/statusline-script.test.ts": 186, + "src/main/cli/appimage-extracted-root.test.ts": 148, + "src/main/cli/appimage-extraction-pruning.test.ts": 37, + "src/main/cli/appimage-payload-removal.reentrancy.test.ts": 12, + "src/main/cli/appimage-payload-removal.test.ts": 13, + "src/main/cli/appimage-registration-lock.test.ts": 31, + "src/main/cli/appimage-stable-launcher.test.ts": 158, + "src/main/cli/cli-command-installation-races.test.ts": 78, + "src/main/cli/cli-installer-appimage-ownership.test.ts": 104, + "src/main/cli/cli-installer-appimage-removal.test.ts": 93, + "src/main/cli/cli-installer-command-conflicts.test.ts": 65, + "src/main/cli/cli-installer-macos-command-path.test.ts": 84, + "src/main/cli/cli-installer-windows-path.test.ts": 59, + "src/main/cli/cli-installer.test.ts": 153, + "src/main/cli/cli-privileged-processes.test.ts": 10, + "src/main/cli/keyed-promise-queue.test.ts": 8, + "src/main/cli/legacy-appimage-cli-wrapper.test.ts": 5, + "src/main/cli/linux-bare-orca-dispatcher.test.ts": 51, + "src/main/cli/linux-terminal-orca-cli-shim.test.ts": 75, + "src/main/cli/orca-cli-child-path.test.ts": 14, + "src/main/cli/packaged-cli-assets.test.ts": 492, + "src/main/cli/windows-launcher-asset.test.ts": 7, + "src/main/cli/windows-user-path-registry.test.ts": 10, + "src/main/cli/wsl-cli-installer.test.ts": 52, + "src/main/cli/wsl-cli-powershell-boundary.test.ts": 6, + "src/main/cli/wsl-cli-registration-operation.test.ts": 63, + "src/main/cli/wsl-cli-registration-reconciliation.test.ts": 13, + "src/main/cli/wsl-cli-registration-registry.test.ts": 84, + "src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts": 7, + "src/main/codex-accounts/codex-auth-workspace-identity.test.ts": 15, + "src/main/codex-accounts/codex-credential-absence-grace.test.ts": 9, + "src/main/codex-accounts/codex-windows-interactive-login.test.ts": 234, + "src/main/codex-accounts/fs-utils.test.ts": 69, + "src/main/codex-accounts/host-codex-managed-home-ownership.test.ts": 12, + "src/main/codex-accounts/legacy-shared-auth-migration.test.ts": 62, + "src/main/codex-accounts/legacy-shared-config-compatibility.test.ts": 20, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts": 11759, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts": 400, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts": 23, + "src/main/codex-accounts/managed-codex-auth-readiness.test.ts": 36, + "src/main/codex-accounts/runtime-home-legacy-migration.test.ts": 381, + "src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts": 217, + "src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts": 163, + "src/main/codex-accounts/runtime-home-per-account-homes.test.ts": 440, + "src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts": 278, + "src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts": 119, + "src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts": 657, + "src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts": 278, + "src/main/codex-accounts/runtime-home-session-migration-pass.test.ts": 282, + "src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts": 709, + "src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts": 618, + "src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts": 216, + "src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts": 3, + "src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts": 742, + "src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts": 393, + "src/main/codex-accounts/runtime-home-wsl-system-default.test.ts": 408, + "src/main/codex-accounts/runtime-selection.test.ts": 6, + "src/main/codex-accounts/service-account-add-login.test.ts": 289, + "src/main/codex-accounts/service-account-selection-and-removal.test.ts": 614, + "src/main/codex-accounts/service-add-account-from-home.test.ts": 177, + "src/main/codex-accounts/service-login-process-teardown.test.ts": 166, + "src/main/codex-accounts/service-managed-home-removal-retries.test.ts": 165, + "src/main/codex-accounts/service-quota-refresh-decoupling.test.ts": 174, + "src/main/codex-accounts/service-reauthenticate-activation.test.ts": 129, + "src/main/codex-accounts/service-reset-credit-durability.test.ts": 449, + "src/main/codex-accounts/service-reset-credit-home-ownership.test.ts": 175, + "src/main/codex-accounts/service-reset-credit-target-routing.test.ts": 339, + "src/main/codex-accounts/service-system-default-identity.test.ts": 207, + "src/main/codex-accounts/service-wsl-accounts.test.ts": 298, + "src/main/codex-accounts/service.test.ts": 181, + "src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts": 209, + "src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts": 376, + "src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts": 145, + "src/main/codex-accounts/wsl-codex-command.test.ts": 8, + "src/main/codex-cli/codex-home-process-lock.test.ts": 15, + "src/main/codex-cli/command.test.ts": 39, + "src/main/codex-usage/scanner-large-directory.test.ts": 1093, + "src/main/codex-usage/scanner-paths.test.ts": 64, + "src/main/codex-usage/scanner.test.ts": 10, + "src/main/codex-usage/store-automation-usage.test.ts": 12, + "src/main/codex-usage/store-model-pricing.test.ts": 17, + "src/main/codex-usage/store-orca-scope.test.ts": 17, + "src/main/codex-usage/store-persistence.test.ts": 20, + "src/main/codex-usage/store-snapshot.benchmark.test.ts": 152, + "src/main/codex/codex-account-session-bridge.test.ts": 47, + "src/main/codex/codex-ai-vault-session-resume.test.ts": 14, + "src/main/codex/codex-app-server-capability-cache.test.ts": 11, + "src/main/codex/codex-app-server-client.test.ts": 1418, + "src/main/codex/codex-app-server-connection.test.ts": 5017, + "src/main/codex/codex-app-server-posix-supervisor.test.ts": 9, + "src/main/codex/codex-app-server-process-teardown.test.ts": 26, + "src/main/codex/codex-app-server-session.test.ts": 116, + "src/main/codex/codex-app-server-teardown.integration.test.ts": 17565, + "src/main/codex/codex-background-command-tracker.test.ts": 19, + "src/main/codex/codex-background-task-tracker.test.ts": 38, + "src/main/codex/codex-config-mirror.test.ts": 96, + "src/main/codex/codex-config-settings-removal.test.ts": 5, + "src/main/codex/codex-goal-journal-rows.test.ts": 14, + "src/main/codex/codex-home-paths.test.ts": 31, + "src/main/codex/codex-hook-legacy-profile-block.test.ts": 8, + "src/main/codex/codex-hook-trust-grant.test.ts": 241, + "src/main/codex/codex-hooks-read-denial-followup.test.ts": 116, + "src/main/codex/codex-legacy-session-resume.test.ts": 69, + "src/main/codex/codex-model-provider-config.test.ts": 9, + "src/main/codex/codex-notice-item-translation.test.ts": 13, + "src/main/codex/codex-pane-account-registry-mutations.test.ts": 14, + "src/main/codex/codex-pane-launch-account.test.ts": 8, + "src/main/codex/codex-persistent-command-retention.test.ts": 226, + "src/main/codex/codex-real-home-hook-install.test.ts": 70, + "src/main/codex/codex-real-home-path.test.ts": 7, + "src/main/codex/codex-requested-close-turn-timing.test.ts": 15, + "src/main/codex/codex-resume-process-proof.test.ts": 20, + "src/main/codex/codex-rollout-session-meta.test.ts": 21, + "src/main/codex/codex-server-request-disposition.test.ts": 21, + "src/main/codex/codex-session-backfill-marker.test.ts": 29, + "src/main/codex/codex-session-backfill-scan-dates.test.ts": 27, + "src/main/codex/codex-session-backfill.test.ts": 408, + "src/main/codex/codex-session-bridge.test.ts": 66, + "src/main/codex/codex-session-index-heal-state.test.ts": 369, + "src/main/codex/codex-session-index-heal.test.ts": 1379, + "src/main/codex/codex-session-migration-recent-exits.test.ts": 13, + "src/main/codex/codex-session-migration-scheduler.test.ts": 369, + "src/main/codex/codex-session-resume-home.test.ts": 22, + "src/main/codex/codex-session-resume-preparation.test.ts": 13, + "src/main/codex/codex-session-resume-wrong-account.test.ts": 24, + "src/main/codex/codex-session-source-home.test.ts": 5, + "src/main/codex/codex-stale-pane-accounts.test.ts": 95, + "src/main/codex/codex-state-db-backfill-recovery.test.ts": 147, + "src/main/codex/codex-state-db.test.ts": 27, + "src/main/codex/codex-structured-acquisition-exit-proof.test.ts": 11, + "src/main/codex/codex-structured-app-server-args.test.ts": 7, + "src/main/codex/codex-structured-child-environment.test.ts": 8, + "src/main/codex/codex-structured-item-translation.test.ts": 50, + "src/main/codex/codex-structured-journal-compactions.test.ts": 16, + "src/main/codex/codex-structured-journal-goal-admission.test.ts": 33, + "src/main/codex/codex-structured-journal-goal-resume.test.ts": 125, + "src/main/codex/codex-structured-journal-goal-rows.test.ts": 16, + "src/main/codex/codex-structured-journal-translation-settlement.test.ts": 843, + "src/main/codex/codex-structured-journal-translation-streams.test.ts": 85, + "src/main/codex/codex-structured-journal-translation-subagents.test.ts": 26, + "src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts": 60, + "src/main/codex/codex-structured-journal-translation-turn-state.test.ts": 12, + "src/main/codex/codex-structured-journal-translation.test.ts": 96, + "src/main/codex/codex-structured-launch-resolution.test.ts": 17, + "src/main/codex/codex-structured-location-support.test.ts": 3, + "src/main/codex/codex-structured-owner-identity.test.ts": 9, + "src/main/codex/codex-structured-prompt-items.test.ts": 39, + "src/main/codex/codex-structured-prompt-replies.test.ts": 40, + "src/main/codex/codex-structured-rewind.test.ts": 34, + "src/main/codex/codex-structured-session-adapter-lifecycle.test.ts": 19, + "src/main/codex/codex-structured-session-adapter.test.ts": 37, + "src/main/codex/codex-structured-session-background-tasks.test.ts": 76, + "src/main/codex/codex-structured-session-cancel.test.ts": 164, + "src/main/codex/codex-structured-session-close.test.ts": 20, + "src/main/codex/codex-structured-session-options.test.ts": 12, + "src/main/codex/codex-structured-session-shutdown.test.ts": 62, + "src/main/codex/codex-structured-thread-open.test.ts": 19, + "src/main/codex/codex-structured-turn-processes.integration.test.ts": 85, + "src/main/codex/codex-subagent-execution-projection.test.ts": 16, + "src/main/codex/codex-subagent-executions.test.ts": 21, + "src/main/codex/codex-subagent-roster.test.ts": 40, + "src/main/codex/codex-tool-identity-translation.test.ts": 14, + "src/main/codex/codex-trust-config-concurrent-launch.test.ts": 70, + "src/main/codex/codex-trust-config-mutation-queue.test.ts": 10, + "src/main/codex/codex-trust-config-rollback.test.ts": 16, + "src/main/codex/codex-trust-grant-host.test.ts": 12, + "src/main/codex/codex-trust-grant-ledger.test.ts": 8, + "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 15, + "src/main/codex/codex-trust-grant-telemetry.test.ts": 8, + "src/main/codex/codex-tui-rollout-proof.test.ts": 33, + "src/main/codex/codex-turn-ordinals.test.ts": 45, + "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 13, + "src/main/codex/codex-user-hook-trust-rebase.test.ts": 17, + "src/main/codex/codex-wsl-hook-install-plan.test.ts": 11, + "src/main/codex/config-plugin-registration-promotion.test.ts": 193, + "src/main/codex/config-settings-baseline-upgrade.test.ts": 61, + "src/main/codex/config-settings-promotion.test.ts": 227, + "src/main/codex/config-sync-stall.test.ts": 47, + "src/main/codex/config-toml-hook-trust-scaling.test.ts": 38, + "src/main/codex/config-toml-trust-api-parity.test.ts": 13, + "src/main/codex/config-toml-trust-hash.test.ts": 7, + "src/main/codex/config-toml-trust-hook-read.test.ts": 16, + "src/main/codex/config-toml-trust-hook-removal.test.ts": 26, + "src/main/codex/config-toml-trust-hook-upsert.test.ts": 44, + "src/main/codex/config-toml-trust-key.test.ts": 10, + "src/main/codex/config-toml-trust-paths.test.ts": 10, + "src/main/codex/config-toml-trust-project.test.ts": 27, + "src/main/codex/hook-service-concurrent-launch-install.test.ts": 618, + "src/main/codex/hook-service-legacy-cleanup.test.ts": 601, + "src/main/codex/hook-service-managed-install.test.ts": 99, + "src/main/codex/hook-service-runtime-trust-repair.test.ts": 117, + "src/main/codex/hook-service-trust-grant.test.ts": 123, + "src/main/codex/hook-service-user-hook-mirroring.test.ts": 186, + "src/main/codex/hook-service-wsl-runtime.test.ts": 113, + "src/main/codex/hook-trust-promotion.test.ts": 225, + "src/main/codex/managed-home-shell-preflight.test.ts": 28, + "src/main/codex/retained-codex-hook-state.test.ts": 8, + "src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts": 12, + "src/main/codex/sta-4737-unreadable-is-not-absent.test.ts": 58, + "src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts": 94, + "src/main/codex/wsl-codex-session-bridge.test.ts": 82, + "src/main/command-code/hook-service.test.ts": 239, + "src/main/computer/computer-action-verification-normalization.test.ts": 4, + "src/main/computer/computer-provider-lifecycle.test.ts": 5, + "src/main/computer/computer-provider-unavailable-message.test.ts": 3, + "src/main/computer/computer-sidecar-diagnostics.test.ts": 8, + "src/main/computer/desktop-script-provider-action-errors.test.ts": 68, + "src/main/computer/desktop-script-provider-actions.test.ts": 131, + "src/main/computer/desktop-script-provider-bridge.test.ts": 6, + "src/main/computer/desktop-script-provider-cache-lifecycle.test.ts": 96, + "src/main/computer/desktop-script-provider-cache.test.ts": 115, + "src/main/computer/desktop-script-provider-client.test.ts": 154, + "src/main/computer/desktop-script-provider-errors.test.ts": 161, + "src/main/computer/desktop-script-provider-paste-validation.test.ts": 105, + "src/main/computer/desktop-script-provider-runtime-host-routing.test.ts": 44, + "src/main/computer/desktop-script-runtime-host.test.ts": 106, + "src/main/computer/desktop-script-serve-channel.test.ts": 9, + "src/main/computer/macos-computer-use-permission-status.test.ts": 38, + "src/main/computer/macos-computer-use-permissions.test.ts": 24, + "src/main/computer/macos-native-provider-client.test.ts": 557, + "src/main/computer/macos-native-provider-paste-validation.test.ts": 99, + "src/main/computer/macos-native-provider-socket.test.ts": 14, + "src/main/computer/sidecar-client.test.ts": 28, + "src/main/computer/windows-powershell-execution-policy.test.ts": 7, + "src/main/copilot/hook-service.test.ts": 83, + "src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts": 29, + "src/main/crash-reporting/crash-breadcrumb-store-leak.test.ts": 60, + "src/main/crash-reporting/crash-breadcrumb-store-orphan-cleanup.test.ts": 45, + "src/main/crash-reporting/crash-breadcrumb-store.test.ts": 34, + "src/main/crash-reporting/crash-report-copy-text.test.ts": 8, + "src/main/crash-reporting/crash-report-store.test.ts": 351, + "src/main/crash-reporting/crashpad-capture.test.ts": 60, + "src/main/crash-reporting/durable-crash-breadcrumb.test.ts": 11, + "src/main/crash-reporting/expected-teardown-state.test.ts": 9, + "src/main/crash-reporting/gpu-crash-diagnostics.test.ts": 14, + "src/main/crash-reporting/gpu-crash-fallback-decision.test.ts": 13, + "src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts": 9, + "src/main/crash-reporting/gpu-fallback-engagement.test.ts": 14, + "src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts": 10, + "src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts": 8, + "src/main/crash-reporting/main-process-lifecycle-identity.test.ts": 6, + "src/main/crash-reporting/minidump-crash-signature.test.ts": 30, + "src/main/crash-reporting/pre-gone-host-memory.test.ts": 18, + "src/main/crash-reporting/process-gone-classification.test.ts": 12, + "src/main/crash-reporting/process-gone-dedupe.test.ts": 6, + "src/main/crash-reporting/process-gone-diagnostics.test.ts": 19, + "src/main/crash-reporting/process-gone-killed-one-ordering.test.ts": 40, + "src/main/crash-reporting/process-gone-recorder.test.ts": 599, + "src/main/crash-reporting/process-gone-sibling-correlation.test.ts": 514, + "src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts": 10, + "src/main/crash-reporting/self-initiated-tree-kill-log.test.ts": 26, + "src/main/crash-reporting/suppressed-process-gone-breadcrumb.test.ts": 5, + "src/main/cursor/hook-service.test.ts": 173, + "src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts": 1916, + "src/main/daemon/bash-prompt-command-composition.test.ts": 3541, + "src/main/daemon/client.test.ts": 392, + "src/main/daemon/cold-restore-payload-cache.test.ts": 9, + "src/main/daemon/daemon-adoption-telemetry-event.test.ts": 18, + "src/main/daemon/daemon-audit-eligibility-event.test.ts": 30, + "src/main/daemon/daemon-authenticated-client-activity.test.ts": 16, + "src/main/daemon/daemon-background-transient-facts.test.ts": 12, + "src/main/daemon/daemon-bundle-staleness.test.ts": 347, + "src/main/daemon/daemon-checkpoint-session-queue.test.ts": 204, + "src/main/daemon/daemon-client-notify-settlement.test.ts": 10, + "src/main/daemon/daemon-client-rpc-request.test.ts": 20, + "src/main/daemon/daemon-durable-history-ownership-seed.test.ts": 25, + "src/main/daemon/daemon-endpoint-ownership.test.ts": 26, + "src/main/daemon/daemon-endpoint-publish.test.ts": 92, + "src/main/daemon/daemon-endpoint-windows.test.ts": 8, + "src/main/daemon/daemon-entry-path-layouts.test.ts": 319, + "src/main/daemon/daemon-entry.test.ts": 15, + "src/main/daemon/daemon-errors.test.ts": 7, + "src/main/daemon/daemon-file-log.test.ts": 25, + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts": 7, + "src/main/daemon/daemon-health-endpoint-entry.test.ts": 9, + "src/main/daemon/daemon-health-socket-cleanup.test.ts": 16, + "src/main/daemon/daemon-health.test.ts": 3195, + "src/main/daemon/daemon-host-relocation.test.ts": 140, + "src/main/daemon/daemon-idle-shutdown.test.ts": 204, + "src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts": 13, + "src/main/daemon/daemon-incarnation-evidence.test.ts": 23, + "src/main/daemon/daemon-init-child-readiness.test.ts": 406, + "src/main/daemon/daemon-init-child-startup-failure.test.ts": 406, + "src/main/daemon/daemon-init-endpoint-adoption.test.ts": 868, + "src/main/daemon/daemon-init-live-session-preservation.test.ts": 541, + "src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts": 353, + "src/main/daemon/daemon-init-provider-installation.test.ts": 398, + "src/main/daemon/daemon-init-replacement-reporting.test.ts": 247, + "src/main/daemon/daemon-init-restart-sequence.test.ts": 576, + "src/main/daemon/daemon-init-wedged-daemon-grace.test.ts": 284, + "src/main/daemon/daemon-lifecycle-event.test.ts": 8, + "src/main/daemon/daemon-main.test.ts": 43, + "src/main/daemon/daemon-native-pty-exception.test.ts": 12, + "src/main/daemon/daemon-pid-file-parse.test.ts": 9, + "src/main/daemon/daemon-preflight-client-replacement.test.ts": 234, + "src/main/daemon/daemon-process-inspection.test.ts": 22, + "src/main/daemon/daemon-protocol-version.test.ts": 9, + "src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts": 489, + "src/main/daemon/daemon-pty-adapter-cold-restore-seed.test.ts": 297, + "src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts": 142, + "src/main/daemon/daemon-pty-adapter-daemon-recovery.test.ts": 505, + "src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts": 775, + "src/main/daemon/daemon-pty-adapter-history-recovery.test.ts": 580, + "src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts": 87, + "src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts": 73, + "src/main/daemon/daemon-pty-adapter-replacement-exit-race.test.ts": 53, + "src/main/daemon/daemon-pty-adapter-session-adoption.test.ts": 557, + "src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts": 7, + "src/main/daemon/daemon-pty-adapter.test.ts": 873, + "src/main/daemon/daemon-pty-router-history-handoff.test.ts": 575, + "src/main/daemon/daemon-pty-router.test.ts": 445, + "src/main/daemon/daemon-pty-startup-delivery.test.ts": 1989, + "src/main/daemon/daemon-pty-upgrade-adoption.test.ts": 120, + "src/main/daemon/daemon-pty-write-settlement-recovery.test.ts": 11, + "src/main/daemon/daemon-ready-identity.test.ts": 8, + "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts": 10265, + "src/main/daemon/daemon-respawn-throttle.test.ts": 9, + "src/main/daemon/daemon-restore-scrollback-depth.test.ts": 2078, + "src/main/daemon/daemon-self-retirement-respawn.test.ts": 148, + "src/main/daemon/daemon-server-async-spawn-cancellation.test.ts": 354, + "src/main/daemon/daemon-server-attach-only.test.ts": 59, + "src/main/daemon/daemon-server-attachment-lifecycle.test.ts": 214, + "src/main/daemon/daemon-server-error-handling.test.ts": 179, + "src/main/daemon/daemon-server-kill-attribution.test.ts": 20, + "src/main/daemon/daemon-server.test.ts": 702, + "src/main/daemon/daemon-session-owner-resolution.test.ts": 22, + "src/main/daemon/daemon-session-scrollback-window.test.ts": 54, + "src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts": 1702, + "src/main/daemon/daemon-spawner.test.ts": 74, + "src/main/daemon/daemon-stream-data-batcher.test.ts": 108, + "src/main/daemon/daemon-stream-droppability-lifecycle.test.ts": 46, + "src/main/daemon/daemon-stream-droppable-membership.test.ts": 40, + "src/main/daemon/daemon-tcc-attribution-main-thread.test.ts": 14, + "src/main/daemon/daemon-tcc-attribution.test.ts": 8, + "src/main/daemon/daemon-transport-attachment-release.test.ts": 114, + "src/main/daemon/degraded-daemon-pty-provider.test.ts": 58, + "src/main/daemon/hangul-cell-width-agreement.test.ts": 37, + "src/main/daemon/headless-emulator-fidelity.fuzz.test.ts": 17026, + "src/main/daemon/headless-emulator-restored-osc-links.test.ts": 50, + "src/main/daemon/headless-emulator-unicode-width.test.ts": 15, + "src/main/daemon/headless-emulator-wide-char-repaint.test.ts": 1773, + "src/main/daemon/headless-emulator-wide-char-snapshot.test.ts": 186, + "src/main/daemon/headless-emulator.test.ts": 114, + "src/main/daemon/headless-osc-link-ranges.test.ts": 119, + "src/main/daemon/hibernation-cold-restore-repro.test.ts": 73, + "src/main/daemon/history-manager-disabled-sessions-leak.test.ts": 58, + "src/main/daemon/history-manager.test.ts": 326, + "src/main/daemon/history-reader-memory.test.ts": 332, + "src/main/daemon/history-reader.test.ts": 85, + "src/main/daemon/issue-6814-daemon-failure-classification.test.ts": 6043, + "src/main/daemon/macos-login-session-death-watch.test.ts": 13, + "src/main/daemon/ndjson.test.ts": 173, + "src/main/daemon/node-pty-error-hints.test.ts": 6, + "src/main/daemon/osc7-file-uri.test.ts": 8, + "src/main/daemon/osc7-uri-extraction.test.ts": 8, + "src/main/daemon/post-ready-flush-gate.test.ts": 16, + "src/main/daemon/pty-session-id.test.ts": 16, + "src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts": 12, + "src/main/daemon/pty-subprocess-env-inheritance.test.ts": 58, + "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts": 64, + "src/main/daemon/pty-subprocess-foreground-identity.test.ts": 303, + "src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts": 42, + "src/main/daemon/pty-subprocess-git-credential-guard.test.ts": 32, + "src/main/daemon/pty-subprocess-handle-lifecycle.test.ts": 138, + "src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts": 3575, + "src/main/daemon/pty-subprocess-io-failure-native.test.ts": 2227, + "src/main/daemon/pty-subprocess-managed-agent-env.test.ts": 81, + "src/main/daemon/pty-subprocess-windows-shell-launch.test.ts": 66, + "src/main/daemon/pty-subprocess-wsl-launch.test.ts": 52, + "src/main/daemon/pty-subprocess.test.ts": 58, + "src/main/daemon/reattach-snapshot.test.ts": 649, + "src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts": 61, + "src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts": 57, + "src/main/daemon/session-pending-output.test.ts": 145, + "src/main/daemon/session-shell-recovery.test.ts": 122, + "src/main/daemon/session-terminal-control.test.ts": 46, + "src/main/daemon/session.test.ts": 279, + "src/main/daemon/shell-ready-bash-wrapper.test.ts": 338, + "src/main/daemon/slow-daemon-session-verification.test.ts": 13535, + "src/main/daemon/startup-device-attributes-responder.test.ts": 14, + "src/main/daemon/terminal-checkpoint-serializer.test.ts": 37, + "src/main/daemon/terminal-checkpoint-writer-bounds.test.ts": 91, + "src/main/daemon/terminal-cursor-line-context.test.ts": 26, + "src/main/daemon/terminal-history-incremental-restore.test.ts": 316, + "src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts": 9348, + "src/main/daemon/terminal-history-log.test.ts": 13, + "src/main/daemon/terminal-history-permissions.test.ts": 87, + "src/main/daemon/terminal-history-restorable-retention.test.ts": 7, + "src/main/daemon/terminal-history-seed-chunks.test.ts": 7, + "src/main/daemon/terminal-history-seed-segments.test.ts": 22, + "src/main/daemon/terminal-history-seed-transfer-registry.test.ts": 8, + "src/main/daemon/terminal-history-session-tombstone-retry.test.ts": 10, + "src/main/daemon/terminal-host-agent-session.test.ts": 36, + "src/main/daemon/terminal-host-attach-only.test.ts": 28, + "src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts": 59, + "src/main/daemon/terminal-host-concurrent-create.test.ts": 53, + "src/main/daemon/terminal-host-cwd-readability.test.ts": 30, + "src/main/daemon/terminal-host-non-agent-foreground.test.ts": 17, + "src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts": 29, + "src/main/daemon/terminal-host-process-inspection.test.ts": 18, + "src/main/daemon/terminal-host-pty-owner-backend.test.ts": 19, + "src/main/daemon/terminal-host-readiness-reporting.test.ts": 23, + "src/main/daemon/terminal-host-session-reaping-leak.test.ts": 30, + "src/main/daemon/terminal-host-startup.test.ts": 45, + "src/main/daemon/terminal-host-teardown-recreate.test.ts": 185, + "src/main/daemon/terminal-host-wsl-context.test.ts": 45, + "src/main/daemon/terminal-host.test.ts": 152, + "src/main/daemon/terminal-session-teardown.test.ts": 16, + "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts": 21, + "src/main/daemon/terminal-shell-recovery-barrier.test.ts": 703, + "src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts": 26, + "src/main/daemon/terminal-snapshot-color-parity.test.ts": 109, + "src/main/daemon/terminal-snapshot-osc8-roundtrip.test.ts": 57, + "src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts": 130, + "src/main/daemon/windows-conpty-warmup.test.ts": 12, + "src/main/daemon/wsl-cold-restore-cwd.test.ts": 7, + "src/main/destination-serialized-local-rename.test.ts": 330, + "src/main/devin/hook-config-json.test.ts": 11, + "src/main/devin/hook-service.test.ts": 43, + "src/main/diagnostics/main-thread-churn-probe.test.ts": 13, + "src/main/dock/unread-badge.test.ts": 9, + "src/main/droid/hook-service.test.ts": 81, + "src/main/durable-file-write-syscall-proof.test.ts": 125, + "src/main/durable-file-write.test.ts": 89, + "src/main/emulator/android/adb-devices.test.ts": 10, + "src/main/emulator/android/android-app-control.test.ts": 7, + "src/main/emulator/android/android-capability-operations.test.ts": 10, + "src/main/emulator/android/android-device-inventory.test.ts": 12, + "src/main/emulator/android/android-input-commands.test.ts": 5, + "src/main/emulator/android/android-input-mapping.test.ts": 9, + "src/main/emulator/android/android-logcat.test.ts": 5, + "src/main/emulator/android/android-permissions.test.ts": 8, + "src/main/emulator/android/android-sdk-discovery.test.ts": 7, + "src/main/emulator/android/avd-manager.test.ts": 11, + "src/main/emulator/android/scrcpy-server-deploy.test.ts": 7, + "src/main/emulator/android/scrcpy-video-frame-parser.test.ts": 11, + "src/main/emulator/android/uiautomator-tree.test.ts": 12, + "src/main/emulator/backends/android-emulator-backend.test.ts": 24, + "src/main/emulator/backends/ios-emulator-backend.test.ts": 88, + "src/main/emulator/emulator-availability.test.ts": 7, + "src/main/emulator/emulator-bridge.test.ts": 122, + "src/main/emulator/emulator-gesture-sender.test.ts": 131, + "src/main/emulator/emulator-session-registry.test.ts": 5, + "src/main/emulator/emulator-start-lease-registry.test.ts": 62, + "src/main/emulator/mjpeg-frame-parser.test.ts": 8, + "src/main/emulator/mjpeg-frame-stream.test.ts": 34, + "src/main/emulator/scrcpy-video-registry.test.ts": 11, + "src/main/emulator/serve-sim-accessibility-tree.test.ts": 90, + "src/main/emulator/serve-sim-ax-normalization.test.ts": 12, + "src/main/emulator/serve-sim-detached-session.test.ts": 11, + "src/main/emulator/serve-sim-execution.test.ts": 25, + "src/main/emulator/serve-sim-helper-processes.test.ts": 8, + "src/main/emulator/serve-sim-runtime-materializer.test.ts": 49, + "src/main/emulator/simctl-simulator-devices.test.ts": 11, + "src/main/ephemeral-vm-recipe-runner.test.ts": 296, + "src/main/ephemeral-vm-resume-integrity.test.ts": 11, + "src/main/ephemeral-vm-runtime-service.test.ts": 522, + "src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts": 36, + "src/main/external-editor-launch.test.ts": 19, + "src/main/fish-history-session.test.ts": 22, + "src/main/gemini/hook-service.test.ts": 15, + "src/main/ghostty/discovery.test.ts": 14, + "src/main/ghostty/index.test.ts": 20, + "src/main/ghostty/mapper-extended.test.ts": 11, + "src/main/ghostty/mapper.test.ts": 11, + "src/main/ghostty/parser.test.ts": 8, + "src/main/ghostty/theme-import.test.ts": 14, + "src/main/ghostty/theme-resolution.test.ts": 10, + "src/main/git-bash.test.ts": 13, + "src/main/git/add-sparse-worktree.test.ts": 22, + "src/main/git/admission-tier-plumbing.test.ts": 10, + "src/main/git/branch-rename.test.ts": 8, + "src/main/git/canonical-repo-key.test.ts": 6, + "src/main/git/check-ignored-paths.test.ts": 15, + "src/main/git/coalesced-probe.test.ts": 9, + "src/main/git/command-runner/gh-exec-file-deadline.test.ts": 20, + "src/main/git/command-runner/gh-spawn-boundary.test.ts": 566, + "src/main/git/command-runner/git-admission-output-parity.test.ts": 120, + "src/main/git/command-runner/git-admission-span.test.ts": 175, + "src/main/git/command-runner/git-admission-storm-measurement.test.ts": 25859, + "src/main/git/command-runner/git-command-timeout-behavior.test.ts": 1728, + "src/main/git/command-runner/git-command-timeout.test.ts": 8, + "src/main/git/command-runner/git-exec-admission-lifetime.test.ts": 375, + "src/main/git/command-runner/git-spawn-admission-lifetime.test.ts": 15, + "src/main/git/command-runner/git-stream-admission-lifetime.test.ts": 120, + "src/main/git/command-runner/git-subprocess-admission.test.ts": 448, + "src/main/git/command-runner/hosted-cli-deadline-log.test.ts": 22, + "src/main/git/command-runner/wsl-host-failure.test.ts": 17, + "src/main/git/commit-object-ref.test.ts": 8, + "src/main/git/commit.test.ts": 6, + "src/main/git/exact-ref-probe.test.ts": 17, + "src/main/git/fetch-error-classification.test.ts": 9, + "src/main/git/fork-remote-refspec.test.ts": 14, + "src/main/git/fork-remote-stale-branch-refspec.test.ts": 10, + "src/main/git/gh-rate-limit-breaker.test.ts": 20, + "src/main/git/git-capability-state.test.ts": 14, + "src/main/git/git-status-read-lease-owner.test.ts": 15, + "src/main/git/git-upstream-status-read-owner.test.ts": 18, + "src/main/git/hosted-remote-url.test.ts": 6, + "src/main/git/huge-folder-ignore.test.ts": 20, + "src/main/git/local-repo-ref-maintenance.test.ts": 98, + "src/main/git/pack-refs-lock-ownership.test.ts": 36, + "src/main/git/porcelain-v1-records.test.ts": 140, + "src/main/git/remote-ref-probe-cache.test.ts": 13, + "src/main/git/remote-url-probe.test.ts": 9, + "src/main/git/remote.test.ts": 51, + "src/main/git/remove-worktree-branch-cleanup.test.ts": 16, + "src/main/git/remove-worktree-clean-preflight.test.ts": 14, + "src/main/git/remove-worktree.test.ts": 35, + "src/main/git/repo-api-parity.test.ts": 6, + "src/main/git/repo-branch-conflict-batched-probe.test.ts": 11, + "src/main/git/repo-branch-conflict-real-git.test.ts": 157, + "src/main/git/repo-branch-conflict.test.ts": 20, + "src/main/git/repo-clone-path.test.ts": 6, + "src/main/git/repo-default-base-timeout.test.ts": 11, + "src/main/git/repo-default-remote.test.ts": 11, + "src/main/git/repo-detection.test.ts": 348, + "src/main/git/repo-ref-maintenance-real-git.test.ts": 1092, + "src/main/git/repo-remote-drift-real.test.ts": 103, + "src/main/git/repo-remote-drift.test.ts": 8, + "src/main/git/repo-search-ref-compat.test.ts": 10, + "src/main/git/repo-username.test.ts": 30, + "src/main/git/repo.test.ts": 2330, + "src/main/git/runner-command-exec.test.ts": 530, + "src/main/git/runner-gh-host-args.test.ts": 8, + "src/main/git/runner-gh-rate-limit-breaker.test.ts": 32, + "src/main/git/runner-windows-host-environment.test.ts": 88, + "src/main/git/runner-wsl-direct-read.test.ts": 1710, + "src/main/git/runner-wsl-gh-fallback.test.ts": 857, + "src/main/git/runner-wsl-linked-gitdir-timeout.test.ts": 20, + "src/main/git/runner-wsl-login-shell-capture.test.ts": 18, + "src/main/git/runner-wsl-read-routing.test.ts": 15, + "src/main/git/runner.test.ts": 32, + "src/main/git/settled-diff-cache-bounds.test.ts": 7, + "src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts": 128, + "src/main/git/source-control/resolve-git-dir.test.ts": 6, + "src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts": 20, + "src/main/git/status-branch-compare-real-ref.test.ts": 137, + "src/main/git/status-branch-compare.test.ts": 19, + "src/main/git/status-branch-line-total-exec-contract.test.ts": 751, + "src/main/git/status-branch-line-total-real-git.test.ts": 728, + "src/main/git/status-branch-line-total-relay-parity.test.ts": 301, + "src/main/git/status-conflict-operations.test.ts": 19, + "src/main/git/status-conflict-overlap.bench.test.ts": 25, + "src/main/git/status-cquoted-paths.test.ts": 79, + "src/main/git/status-diff-settled-cache.test.ts": 152, + "src/main/git/status-diff.test.ts": 37, + "src/main/git/status-discard-and-bulk-staging.test.ts": 73, + "src/main/git/status-discard-symlink.test.ts": 429, + "src/main/git/status-line-stats-host-paths.test.ts": 14, + "src/main/git/status-pathspec-literals.test.ts": 352, + "src/main/git/status-porcelain-parser.test.ts": 7, + "src/main/git/status-read-coalescing.test.ts": 37, + "src/main/git/status-shared-symlinks.test.ts": 825, + "src/main/git/status-submodule-path-cache.test.ts": 894, + "src/main/git/status-submodule.test.ts": 45, + "src/main/git/status-symlink-probe-budget.test.ts": 44, + "src/main/git/status-upstream-negative-cache.test.ts": 990, + "src/main/git/status-upstream-probe-churn.test.ts": 42, + "src/main/git/status-upstream-ref.test.ts": 13, + "src/main/git/status-wsl-pathspecs.test.ts": 14, + "src/main/git/status.test.ts": 72, + "src/main/git/upstream-deferred-fork-remote-real.test.ts": 49, + "src/main/git/upstream.test.ts": 179, + "src/main/git/worktree-add-creation-config.test.ts": 38, + "src/main/git/worktree-add-local-base-refresh.test.ts": 16, + "src/main/git/worktree-add-local-base-suggestion.test.ts": 15, + "src/main/git/worktree-add-timeout-override.test.ts": 12, + "src/main/git/worktree-base-divergence-real-git.test.ts": 1158, + "src/main/git/worktree-base-divergence.test.ts": 39, + "src/main/git/worktree-base-ref-probe.test.ts": 16, + "src/main/git/worktree-common-dir-comparison.test.ts": 6, + "src/main/git/worktree-configured-paths-concurrency.test.ts": 27, + "src/main/git/worktree-create-preparation-real-git.test.ts": 1004, + "src/main/git/worktree-created-description-real-git.test.ts": 1472, + "src/main/git/worktree-deferred-removal-real-git.test.ts": 269, + "src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 6, + "src/main/git/worktree-diff-stamp-host-paths.test.ts": 8, + "src/main/git/worktree-git-capabilities.test.ts": 13, + "src/main/git/worktree-graph-listing.test.ts": 13, + "src/main/git/worktree-include-file.test.ts": 83, + "src/main/git/worktree-list-paths.test.ts": 228, + "src/main/git/worktree-list-porcelain.test.ts": 26, + "src/main/git/worktree-listing-created-sparse-distro.test.ts": 9, + "src/main/git/worktree-listing-sparse-distro.test.ts": 8, + "src/main/git/worktree-move.test.ts": 12, + "src/main/git/worktree-mutation-route-invalidation.test.ts": 25, + "src/main/git/worktree-porcelain-parsing.test.ts": 14, + "src/main/git/worktree-preparation-base-oid.test.ts": 14, + "src/main/git/worktree-remove-branch-deletion.test.ts": 17, + "src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 8, + "src/main/git/worktree-scan-cache-sharing.test.ts": 52, + "src/main/git/worktree-separate-git-dir.test.ts": 350, + "src/main/git/worktree-shared-directories.test.ts": 726, + "src/main/git/worktree-sparse-checkout-cache.test.ts": 31, + "src/main/git/worktree-sparse-checkout.test.ts": 276, + "src/main/git/worktree-sparse-state-host-paths.test.ts": 5, + "src/main/git/worktree-symlink-detection.test.ts": 11, + "src/main/git/wsl-direct-git-read-commands.test.ts": 8, + "src/main/git/wsl-linked-worktree-git-route-invalidation.test.ts": 9, + "src/main/git/wsl-linked-worktree-git-routing.test.ts": 57, + "src/main/git/wsl-process-group-termination.test.ts": 8, + "src/main/gitea/client.test.ts": 145, + "src/main/gitea/pull-request-creation.test.ts": 82, + "src/main/gitea/pull-request-mappers.test.ts": 5, + "src/main/gitea/repository-ref.test.ts": 19, + "src/main/github/auth-diagnose.test.ts": 10, + "src/main/github/client-create-pr.test.ts": 39, + "src/main/github/client-file-viewed.test.ts": 10, + "src/main/github/client-issue-origin-preference.test.ts": 17, + "src/main/github/client-issue-source.test.ts": 28, + "src/main/github/client-merge-queue-auto-merge.test.ts": 70, + "src/main/github/client-merged-pr-visibility.test.ts": 19, + "src/main/github/client-pr-branch-discovery.test.ts": 16, + "src/main/github/client-pr-check-details.test.ts": 36, + "src/main/github/client-pr-checks.test.ts": 42, + "src/main/github/client-pr-comment-reactions.test.ts": 13, + "src/main/github/client-pr-conflict-summary.test.ts": 26, + "src/main/github/client-pr-fallback-number.test.ts": 23, + "src/main/github/client-pr-linked-lookup.test.ts": 18, + "src/main/github/client-pr-local-runtime.test.ts": 32, + "src/main/github/client-pr-push-target.test.ts": 24, + "src/main/github/client-pr-state.test.ts": 10, + "src/main/github/client-rate-limit-block.test.ts": 12, + "src/main/github/client-ssh-provider-execution-boundary.test.ts": 23, + "src/main/github/client-stack-merge-guard.test.ts": 32, + "src/main/github/client-starred.test.ts": 12, + "src/main/github/client-tracked-upstream-fork-owner.test.ts": 21, + "src/main/github/client-tracked-upstream-snapshot.test.ts": 232, + "src/main/github/client-work-item-check-summary.test.ts": 12, + "src/main/github/client-work-items-query-paging.test.ts": 35, + "src/main/github/client-work-items.test.ts": 18, + "src/main/github/comment-reactions.test.ts": 3, + "src/main/github/conflict-summary.test.ts": 33, + "src/main/github/default-branch-stale-pr.test.ts": 11, + "src/main/github/gh-utils-concurrency.test.ts": 6, + "src/main/github/gh-utils.test.ts": 88, + "src/main/github/github-api-repository.test.ts": 113, + "src/main/github/github-enterprise-repository.test.ts": 36, + "src/main/github/github-pr-stack.test.ts": 46, + "src/main/github/github-remote-identity-parsing.test.ts": 9, + "src/main/github/github-repository-identity.fork-owner-repo.test.ts": 13, + "src/main/github/github-repository-identity.signed-cache.test.ts": 9, + "src/main/github/github-repository-identity.ssh-host-alias.test.ts": 33, + "src/main/github/issues.test.ts": 18, + "src/main/github/pr-head-tracking-ref.test.ts": 13, + "src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts": 271, + "src/main/github/pr-refresh-coordinator-active-visible-priority.test.ts": 192, + "src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts": 175, + "src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts": 223, + "src/main/github/pr-refresh-coordinator-refresh-events.test.ts": 185, + "src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts": 163, + "src/main/github/pr-refresh-error-classification.test.ts": 8, + "src/main/github/pr-refresh-queue-growth-bound.test.ts": 439, + "src/main/github/pr-refresh-validation-backoff.test.ts": 14, + "src/main/github/pr-review-comment-lines.test.ts": 6, + "src/main/github/pr-start-point-compare-base.test.ts": 15, + "src/main/github/pr-start-point.test.ts": 20, + "src/main/github/project-view-host-auth.test.ts": 16, + "src/main/github/project-view.test.ts": 22, + "src/main/github/project-view/mutations.test.ts": 14, + "src/main/github/project-view/project-field-mutations.test.ts": 6, + "src/main/github/project-view/project-view-table.test.ts": 11, + "src/main/github/project-view/repository-field-options.test.ts": 5, + "src/main/github/rate-limit.test.ts": 19, + "src/main/github/review-head-remote.test.ts": 11, + "src/main/github/stacked-pr-creation.test.ts": 23, + "src/main/github/work-item-details-api-parity.test.ts": 7, + "src/main/github/work-item-details-concurrency.test.ts": 7, + "src/main/github/work-item-details-enterprise-host.test.ts": 20, + "src/main/github/work-item-details-file-viewed.test.ts": 18, + "src/main/github/work-item-details-pr-files.test.ts": 22, + "src/main/github/work-item-details.test.ts": 29, + "src/main/gitlab/client-mr-auth-rate-limit.test.ts": 14, + "src/main/gitlab/client-mr-branch-lookup.test.ts": 27, + "src/main/gitlab/client-mr-job-ci.test.ts": 12, + "src/main/gitlab/client-mr-listing.test.ts": 15, + "src/main/gitlab/client-mr-review-actions.test.ts": 11, + "src/main/gitlab/client-work-items.test.ts": 16, + "src/main/gitlab/client.test.ts": 19, + "src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts": 15, + "src/main/gitlab/gitlab-known-host-probe.test.ts": 23, + "src/main/gitlab/gl-utils.test.ts": 41, + "src/main/gitlab/issues.test.ts": 23, + "src/main/gitlab/mappers-workitem.test.ts": 5, + "src/main/gitlab/mappers.test.ts": 23, + "src/main/gitlab/merge-request-creation.test.ts": 11, + "src/main/gitlab/mr-head-tracking-ref.test.ts": 10, + "src/main/gitlab/project-ref-parser.test.ts": 12, + "src/main/gitlab/work-item-details.test.ts": 31, + "src/main/global-fetch-call-site-audit.test.ts": 135, + "src/main/grok-accounts/status.test.ts": 6, + "src/main/grok/grok-hook-config-file.test.ts": 99, + "src/main/grok/grok-hook-owners.test.ts": 26, + "src/main/grok/grok-hook-remnant-removal.test.ts": 15, + "src/main/grok/hook-service.test.ts": 38, + "src/main/grok/windows-grok-hook-script.test.ts": 9, + "src/main/grok/windows-hook-launcher-chain.test.ts": 9, + "src/main/hang-watchdog/hang-detection-marker.test.ts": 7, + "src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts": 11, + "src/main/hang-watchdog/hang-watchdog-worker-path.test.ts": 8, + "src/main/hang-watchdog/main-thread-hang-telemetry.test.ts": 6, + "src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts": 19, + "src/main/hang-watchdog/main-thread-hang-watchdog.test.ts": 26, + "src/main/headless-automation-dispatcher-source-boundary.test.ts": 4, + "src/main/hermes/hook-service.test.ts": 196, + "src/main/hooks-effective-hook-resolution.test.ts": 173, + "src/main/hooks-issue-command.test.ts": 237, + "src/main/hooks-orca-yaml-parsing.test.ts": 37, + "src/main/hooks-runner.test.ts": 103, + "src/main/hooks-setup-runner-script.test.ts": 171, + "src/main/hooks.test.ts": 316, + "src/main/host-tree-removal-asar.electron.test.ts": 285, + "src/main/host/deferred-secret-protection-report.test.ts": 16, + "src/main/host/electron-secret-store.test.ts": 11, + "src/main/host/secret-protection-report.test.ts": 13, + "src/main/i18n/main-i18n-lazy-locale.test.ts": 270, + "src/main/ipc/agent-hooks.test.ts": 472, + "src/main/ipc/agent-pane-authority-ownership.test.ts": 4, + "src/main/ipc/ai-vault-scan-coalescing.test.ts": 322, + "src/main/ipc/ai-vault.test.ts": 159, + "src/main/ipc/app.test.ts": 24, + "src/main/ipc/automations-external-scope.test.ts": 110, + "src/main/ipc/bounded-warning-dedupe.test.ts": 8, + "src/main/ipc/browser-client-page-metadata-ipc.test.ts": 19, + "src/main/ipc/browser-preview-tool-authorization.test.ts": 81, + "src/main/ipc/browser-session-profile-ipc.test.ts": 10, + "src/main/ipc/browser-tab-registration-wait.test.ts": 12, + "src/main/ipc/browser.test.ts": 83, + "src/main/ipc/cli-appimage-stale-registration.test.ts": 214, + "src/main/ipc/cli.test.ts": 62, + "src/main/ipc/codex-config-sync.test.ts": 13, + "src/main/ipc/command-path-resolver.test.ts": 13, + "src/main/ipc/computer-use-permissions.test.ts": 9, + "src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts": 39, + "src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts": 12, + "src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts": 24, + "src/main/ipc/crash-reporting.test.ts": 43, + "src/main/ipc/created-worktree-reconciliation.test.ts": 72, + "src/main/ipc/created-worktree-root-prune.test.ts": 19, + "src/main/ipc/dashboard-payload-validation.test.ts": 74, + "src/main/ipc/dashboard-popout.test.ts": 13, + "src/main/ipc/deferred-emoji-shortcode-dataset.test.ts": 65, + "src/main/ipc/developer-permissions.test.ts": 17, + "src/main/ipc/diagnostics.test.ts": 20, + "src/main/ipc/doc-preview-grant-ipc.test.ts": 10, + "src/main/ipc/dropped-path-resolution.test.ts": 19, + "src/main/ipc/emulator-stream-listener-cleanup.test.ts": 12, + "src/main/ipc/ephemeral-vm-provision-cancel.test.ts": 58, + "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts": 447, + "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts": 94, + "src/main/ipc/ephemeral-vm.test.ts": 595, + "src/main/ipc/feedback-image-attachments.test.ts": 67, + "src/main/ipc/feedback.test.ts": 132, + "src/main/ipc/filesystem-allowed-roots.test.ts": 334, + "src/main/ipc/filesystem-auth.test.ts": 678, + "src/main/ipc/filesystem-branch-compare-diff.test.ts": 24, + "src/main/ipc/filesystem-commit-message-generation.test.ts": 32, + "src/main/ipc/filesystem-commit-message-model-discovery.test.ts": 25, + "src/main/ipc/filesystem-conflict-operation-routing.test.ts": 14, + "src/main/ipc/filesystem-download-transfers.test.ts": 106, + "src/main/ipc/filesystem-git-commit-dispatch.test.ts": 18, + "src/main/ipc/filesystem-git-status-staging.test.ts": 82, + "src/main/ipc/filesystem-import-ssh-ops.test.ts": 23, + "src/main/ipc/filesystem-import-ssh-path-safety.test.ts": 18, + "src/main/ipc/filesystem-import-ssh.test.ts": 31, + "src/main/ipc/filesystem-import.test.ts": 64, + "src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts": 325, + "src/main/ipc/filesystem-list-files-git-fallback-real.test.ts": 271, + "src/main/ipc/filesystem-list-files-install-rg.test.ts": 23, + "src/main/ipc/filesystem-list-files.test.ts": 253, + "src/main/ipc/filesystem-markdown-document-listing.test.ts": 17, + "src/main/ipc/filesystem-mutations.test.ts": 49, + "src/main/ipc/filesystem-path-containment-remote-enoent.test.ts": 8, + "src/main/ipc/filesystem-pull-request-field-generation.test.ts": 18, + "src/main/ipc/filesystem-search-file-paths.test.ts": 257, + "src/main/ipc/filesystem-search-git.test.ts": 150, + "src/main/ipc/filesystem-search-rg-timeout.test.ts": 50, + "src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts": 320, + "src/main/ipc/filesystem-watcher-dormant-rearm.test.ts": 170, + "src/main/ipc/filesystem-watcher-event-batch.test.ts": 9, + "src/main/ipc/filesystem-watcher-ignore.test.ts": 7, + "src/main/ipc/filesystem-watcher-large-batch.test.ts": 150, + "src/main/ipc/filesystem-watcher-local-events.test.ts": 240, + "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts": 818, + "src/main/ipc/filesystem-watcher-native-capacity.test.ts": 67, + "src/main/ipc/filesystem-watcher-real.test.ts": 488, + "src/main/ipc/filesystem-watcher-remote-batch.test.ts": 27, + "src/main/ipc/filesystem-watcher-remote-cancellation.test.ts": 324, + "src/main/ipc/filesystem-watcher-remote-capacity.test.ts": 31, + "src/main/ipc/filesystem-watcher-remote-rearm.test.ts": 20, + "src/main/ipc/filesystem-watcher-removal-deadline.test.ts": 129, + "src/main/ipc/filesystem-watcher-terminal-resync.test.ts": 21, + "src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts": 24, + "src/main/ipc/filesystem-watcher-wsl.test.ts": 35, + "src/main/ipc/filesystem-watcher.test.ts": 1789, + "src/main/ipc/filesystem.test.ts": 70, + "src/main/ipc/floating-workspace-directory.test.ts": 23, + "src/main/ipc/folder-repo-git-upgrade.test.ts": 2257, + "src/main/ipc/git-status-upstream-ref-watch-request.test.ts": 13, + "src/main/ipc/github-ipc-channel-parity.test.ts": 13, + "src/main/ipc/github-issue-source-preference.test.ts": 16, + "src/main/ipc/github-pr-refresh-routing.test.ts": 31, + "src/main/ipc/github-repo-access-guards.test.ts": 24, + "src/main/ipc/github-ssh-connection-routing.test.ts": 10, + "src/main/ipc/github-star-telemetry.test.ts": 17, + "src/main/ipc/github-work-item-args.test.ts": 12, + "src/main/ipc/github-wsl-runtime-routing.test.ts": 21, + "src/main/ipc/gitlab-repo-access.test.ts": 6, + "src/main/ipc/gitlab.test.ts": 27, + "src/main/ipc/hosted-review.test.ts": 23, + "src/main/ipc/jira-cancellable-requests.test.ts": 12, + "src/main/ipc/keybindings.test.ts": 8, + "src/main/ipc/linear.test.ts": 9, + "src/main/ipc/local-log-tail.test.ts": 9, + "src/main/ipc/local-network-connection-test.test.ts": 84, + "src/main/ipc/local-worktree-runtime-options.test.ts": 10, + "src/main/ipc/macos-keyboard-layout-change-notifications.test.ts": 13, + "src/main/ipc/macos-keyboard-layout-snapshot.test.ts": 7, + "src/main/ipc/markdown-documents.test.ts": 5, + "src/main/ipc/minimax-credentials.test.ts": 17, + "src/main/ipc/mobile.test.ts": 66, + "src/main/ipc/native-chat-subscribe-lifecycle.test.ts": 18, + "src/main/ipc/native-chat.test.ts": 1703, + "src/main/ipc/notebook.test.ts": 13, + "src/main/ipc/notifications-custom-sound.test.ts": 13, + "src/main/ipc/notifications-delivery-gating.test.ts": 19, + "src/main/ipc/notifications-message-formatting.test.ts": 32, + "src/main/ipc/notifications-mobile-fanout.test.ts": 17, + "src/main/ipc/notifications-permission-onboarding.test.ts": 27, + "src/main/ipc/notifications-retention-lifecycle.test.ts": 27, + "src/main/ipc/orca-profile-auth-handlers.test.ts": 16, + "src/main/ipc/orca-profile-org-members-handlers.test.ts": 10, + "src/main/ipc/orca-profiles.test.ts": 28, + "src/main/ipc/parcel-watcher-child-launch.test.ts": 14, + "src/main/ipc/parcel-watcher-child-registry.test.ts": 60, + "src/main/ipc/parcel-watcher-crash-fuse.test.ts": 4, + "src/main/ipc/parcel-watcher-disconnect-termination.test.ts": 46, + "src/main/ipc/parcel-watcher-entry-path.test.ts": 8, + "src/main/ipc/parcel-watcher-event-cancellation.test.ts": 12, + "src/main/ipc/parcel-watcher-event-delivery.test.ts": 8, + "src/main/ipc/parcel-watcher-process-entry.test.ts": 83, + "src/main/ipc/parcel-watcher-process.test.ts": 395, + "src/main/ipc/parcel-watcher-root-path-rewrite.test.ts": 19, + "src/main/ipc/parcel-watcher-shallow-subscription.test.ts": 11, + "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts": 340, + "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts": 134, + "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts": 38, + "src/main/ipc/pet-bundle.test.ts": 6, + "src/main/ipc/pet.test.ts": 35, + "src/main/ipc/plugin-marketplaces.test.ts": 13, + "src/main/ipc/plugins.test.ts": 18, + "src/main/ipc/preflight-agent-detection-no-subprocess.test.ts": 22, + "src/main/ipc/preflight-agent-detection.test.ts": 42, + "src/main/ipc/preflight-agent-refresh.test.ts": 21, + "src/main/ipc/preflight-command-exec.test.ts": 8, + "src/main/ipc/preflight-host-cli-status.test.ts": 27, + "src/main/ipc/preflight-remote-ssh.test.ts": 16, + "src/main/ipc/preflight-wsl-agent-detection.test.ts": 55, + "src/main/ipc/preflight-wsl-command.test.ts": 17, + "src/main/ipc/pty-activation-inventory-scope.test.ts": 23, + "src/main/ipc/pty-agent-session-write-gate.test.ts": 141, + "src/main/ipc/pty-applied-size-reporting.test.ts": 48, + "src/main/ipc/pty-buffer-snapshot-dispatch.test.ts": 64, + "src/main/ipc/pty-codex-account-attribution.test.ts": 46, + "src/main/ipc/pty-controller-owner-recovery.test.ts": 53, + "src/main/ipc/pty-controller-ownership-routing.test.ts": 37, + "src/main/ipc/pty-controller-process-inventory.test.ts": 17, + "src/main/ipc/pty-controller-spawn-admission.test.ts": 49, + "src/main/ipc/pty-cumulative-ack-accounting.test.ts": 95, + "src/main/ipc/pty-daemon-controller-teardown.test.ts": 44, + "src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts": 72, + "src/main/ipc/pty-daemon-spawn-codex-auth.test.ts": 82, + "src/main/ipc/pty-daemon-spawn-session-identity.test.ts": 60, + "src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts": 67, + "src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts": 48, + "src/main/ipc/pty-dead-owner-respawn.test.ts": 44, + "src/main/ipc/pty-delivery-health-heal.test.ts": 117, + "src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts": 83, + "src/main/ipc/pty-encoding.test.ts": 7, + "src/main/ipc/pty-global-renderer-credit.test.ts": 134, + "src/main/ipc/pty-hidden-at-spawn-mark.test.ts": 120, + "src/main/ipc/pty-hidden-delivery-gate.test.ts": 8, + "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts": 67, + "src/main/ipc/pty-ipc-producer-flow-control.test.ts": 57, + "src/main/ipc/pty-listener-teardown-and-orphans.test.ts": 99, + "src/main/ipc/pty-login-shell-startup-commands.test.ts": 113, + "src/main/ipc/pty-management.test.ts": 46, + "src/main/ipc/pty-output-batching-drain.test.ts": 66, + "src/main/ipc/pty-output-drain-rounds.test.ts": 110, + "src/main/ipc/pty-pane-claim-arbitration.test.ts": 137, + "src/main/ipc/pty-pane-materialization-race.test.ts": 501, + "src/main/ipc/pty-pane-reservation-settlement.test.ts": 71, + "src/main/ipc/pty-pending-data-drain-queue-differential.test.ts": 955, + "src/main/ipc/pty-pending-data-drain-queue.test.ts": 19, + "src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts": 19, + "src/main/ipc/pty-pending-output-cap.test.ts": 97, + "src/main/ipc/pty-pending-projection-admissions.test.ts": 86, + "src/main/ipc/pty-persisted-incarnation-repair.test.ts": 38, + "src/main/ipc/pty-producer-flow-control.test.ts": 16, + "src/main/ipc/pty-renderer-inflight-credit.test.ts": 156, + "src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts": 77, + "src/main/ipc/pty-renderer-liveness-guard.test.ts": 82, + "src/main/ipc/pty-renderer-send-failure-recovery.test.ts": 51, + "src/main/ipc/pty-restore-record-seeding.test.ts": 156, + "src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts": 14, + "src/main/ipc/pty-runtime-kill-and-exit.test.ts": 105, + "src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts": 40, + "src/main/ipc/pty-serializer-settlement-mapping.test.ts": 146, + "src/main/ipc/pty-session-liveness-and-ownership.test.ts": 42, + "src/main/ipc/pty-spawn-codex-home-unavailable.test.ts": 43, + "src/main/ipc/pty-spawn-cwd-fallback.test.ts": 69, + "src/main/ipc/pty-spawn-env-agent-overlays.test.ts": 116, + "src/main/ipc/pty-spawn-env-codex-home-routing.test.ts": 132, + "src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts": 118, + "src/main/ipc/pty-spawn-env-terminal-basics.test.ts": 1128, + "src/main/ipc/pty-spawn-runtime-handle-binding.test.ts": 135, + "src/main/ipc/pty-ssh-stop-verdict.test.ts": 92, + "src/main/ipc/pty-ssh-undelivered-kill.test.ts": 149, + "src/main/ipc/pty-startup-barrier-and-listing.test.ts": 60, + "src/main/ipc/pty-startup-barrier-ordering.test.ts": 3, + "src/main/ipc/pty-startup-swap-window-presence.test.ts": 81, + "src/main/ipc/pty-windows-shell-selection.test.ts": 131, + "src/main/ipc/pty-write-ipc-validation.test.ts": 125, + "src/main/ipc/pty-wsl-cwd-validation.test.ts": 75, + "src/main/ipc/pty/delivery/attached-pty-size.test.ts": 13, + "src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts": 111, + "src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts": 258, + "src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts": 11, + "src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts": 14, + "src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts": 25, + "src/main/ipc/pty/pane/launch-authority.test.ts": 9, + "src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts": 26, + "src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts": 14, + "src/main/ipc/pty/register-headless-runtime.test.ts": 6, + "src/main/ipc/pty/runtime/queried-host-kinds.test.ts": 8, + "src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts": 7, + "src/main/ipc/rate-limits.test.ts": 7, + "src/main/ipc/readdir-error-diagnostics.test.ts": 10, + "src/main/ipc/register-core-handlers/register-core-handlers.test.ts": 20, + "src/main/ipc/remote-watcher-event-batch.test.ts": 29, + "src/main/ipc/remote-workspace-cache.test.ts": 9, + "src/main/ipc/remote-workspace-patch-queue.test.ts": 173, + "src/main/ipc/remote-workspace-snapshot-normalization.test.ts": 10, + "src/main/ipc/remote-workspace-stale-resync.test.ts": 162, + "src/main/ipc/remote-workspace.test.ts": 29, + "src/main/ipc/renderer-shutdown-checkpoint.test.ts": 18, + "src/main/ipc/renderer-terminal-serializer-readiness.test.ts": 11, + "src/main/ipc/repos-add-linked-worktree.test.ts": 46, + "src/main/ipc/repos-create.test.ts": 34, + "src/main/ipc/repos-execution-host-catalog.test.ts": 22, + "src/main/ipc/repos-local-add-and-project-setup.test.ts": 54, + "src/main/ipc/repos-local-clone-lifecycle.test.ts": 245, + "src/main/ipc/repos-nested-import.test.ts": 51, + "src/main/ipc/repos-nested-scan.test.ts": 36, + "src/main/ipc/repos-picker.test.ts": 8, + "src/main/ipc/repos-remote-base-ref-queries.test.ts": 30, + "src/main/ipc/repos-remote-client-events.test.ts": 1415, + "src/main/ipc/repos-remote-git-username.test.ts": 6, + "src/main/ipc/repos-remote.test.ts": 66, + "src/main/ipc/repos-sparse-presets.test.ts": 27, + "src/main/ipc/repos/remote-repo-registration.test.ts": 7, + "src/main/ipc/rg-availability.test.ts": 9, + "src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 14, + "src/main/ipc/runtime-environment-capability-evidence.test.ts": 9, + "src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts": 4, + "src/main/ipc/runtime-environment-federated-read-routing.test.ts": 34, + "src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts": 10, + "src/main/ipc/runtime-environment-removal-storage.test.ts": 565, + "src/main/ipc/runtime-environment-request-connections.test.ts": 1282, + "src/main/ipc/runtime-environment-revision-guard.test.ts": 7, + "src/main/ipc/runtime-environment-status-connection.test.ts": 441, + "src/main/ipc/runtime-environment-status-recovery.test.ts": 23, + "src/main/ipc/runtime-environment-support-routing.test.ts": 62, + "src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts": 853, + "src/main/ipc/runtime-environments-call-routing.test.ts": 187, + "src/main/ipc/runtime-environments-capability-cache.test.ts": 129, + "src/main/ipc/runtime-environments-pairing.test.ts": 66, + "src/main/ipc/runtime-environments-status-diagnostics.test.ts": 37, + "src/main/ipc/runtime-environments-subscription-lifecycle.test.ts": 35, + "src/main/ipc/runtime-environments-subscription-routing.test.ts": 26, + "src/main/ipc/runtime-environments-subscription-teardown.test.ts": 38, + "src/main/ipc/runtime-subscribe-lifecycle.test.ts": 10, + "src/main/ipc/runtime-watcher-pending-assignment.test.ts": 281, + "src/main/ipc/runtime-watcher-process-pool.test.ts": 46, + "src/main/ipc/runtime.test.ts": 23, + "src/main/ipc/settings.test.ts": 30, + "src/main/ipc/shallow-watch-delivery-probe.test.ts": 28, + "src/main/ipc/shell.test.ts": 47, + "src/main/ipc/skill-cloud-install-ipc-schemas.test.ts": 13, + "src/main/ipc/skill-cloud-ipc-handlers.test.ts": 12, + "src/main/ipc/skill-install-management-ipc-handlers.test.ts": 19, + "src/main/ipc/skill-install-progress-ipc.test.ts": 7, + "src/main/ipc/skill-ipc-main-window.test.ts": 9, + "src/main/ipc/skills.test.ts": 17, + "src/main/ipc/source-control-ai-linked-issue.test.ts": 14, + "src/main/ipc/speech.test.ts": 12, + "src/main/ipc/ssh-app-shutdown.test.ts": 45, + "src/main/ipc/ssh-browse.test.ts": 586, + "src/main/ipc/ssh-disconnect-cancellation.test.ts": 1118, + "src/main/ipc/ssh-handler-reregistration.test.ts": 49, + "src/main/ipc/ssh-passphrase.test.ts": 6, + "src/main/ipc/ssh-pty-closed-generation-ranges.test.ts": 46, + "src/main/ipc/ssh-pty-consumer-identity.test.ts": 175, + "src/main/ipc/ssh-pty-legacy-projection.test.ts": 18, + "src/main/ipc/ssh-pty-model-admission-generation-scope.test.ts": 16, + "src/main/ipc/ssh-pty-model-admission.test.ts": 15, + "src/main/ipc/ssh-pty-output-exit-deadline.test.ts": 23, + "src/main/ipc/ssh-pty-output-generation-guard.test.ts": 10, + "src/main/ipc/ssh-pty-output-intake.test.ts": 57, + "src/main/ipc/ssh-pty-output-model-migration.test.ts": 25, + "src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts": 20, + "src/main/ipc/ssh-pty-source-ack-coalescer.test.ts": 14, + "src/main/ipc/ssh-pty-source-ack-session-contract.test.ts": 13, + "src/main/ipc/ssh-pty-source-obligation-coordinator.test.ts": 14, + "src/main/ipc/ssh-pty-source-obligation-ledger.test.ts": 82, + "src/main/ipc/ssh-relay-reset-resume.test.ts": 908, + "src/main/ipc/ssh-state-broadcast-fanout.test.ts": 25, + "src/main/ipc/ssh-target-registry.test.ts": 1633, + "src/main/ipc/ssh-terminate-sessions.test.ts": 144, + "src/main/ipc/ssh.test.ts": 61, + "src/main/ipc/telemetry.test.ts": 13, + "src/main/ipc/terminal-git-credential-guard.test.ts": 13, + "src/main/ipc/terminal-preview-output-stream.test.ts": 9, + "src/main/ipc/terminal-preview.test.ts": 155, + "src/main/ipc/terminal-render-desync-evidence.test.ts": 35, + "src/main/ipc/tui-agent-detection-commands.test.ts": 5, + "src/main/ipc/ui.test.ts": 12, + "src/main/ipc/usage-provider-handlers.test.ts": 6, + "src/main/ipc/watched-worktree-catalog-notification.test.ts": 7, + "src/main/ipc/watcher-event-root-path-rewrite.test.ts": 18, + "src/main/ipc/watcher-removal-gate.test.ts": 12, + "src/main/ipc/workspace-cleanup-activity.test.ts": 33, + "src/main/ipc/workspace-cleanup-broad-scan.test.ts": 55, + "src/main/ipc/workspace-cleanup-execution-host-routing.test.ts": 28, + "src/main/ipc/workspace-cleanup-local-git-routing.test.ts": 9, + "src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts": 33, + "src/main/ipc/workspace-cleanup.test.ts": 4276, + "src/main/ipc/workspace-create-error-classifier.test.ts": 10, + "src/main/ipc/workspace-ports.test.ts": 21, + "src/main/ipc/workspace-space.test.ts": 18, + "src/main/ipc/worktree-base-directory-change-collector.test.ts": 8, + "src/main/ipc/worktree-base-directory-event-filter.test.ts": 18, + "src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts": 142, + "src/main/ipc/worktree-base-directory-poller.test.ts": 2628, + "src/main/ipc/worktree-base-directory-watch-targets.test.ts": 319, + "src/main/ipc/worktree-base-directory-watcher.test.ts": 109, + "src/main/ipc/worktree-create-lineage.test.ts": 16, + "src/main/ipc/worktree-folder-rename-target.test.ts": 8, + "src/main/ipc/worktree-git-common-polling.test.ts": 1088, + "src/main/ipc/worktree-git-common-watch.test.ts": 2677, + "src/main/ipc/worktree-git-status-ref-watch.test.ts": 10, + "src/main/ipc/worktree-head-identity-reader-concurrency.test.ts": 29, + "src/main/ipc/worktree-head-identity-reader-incremental.test.ts": 104, + "src/main/ipc/worktree-head-identity-reader.test.ts": 38, + "src/main/ipc/worktree-head-identity-refresh.test.ts": 28, + "src/main/ipc/worktree-include-copy-budget.test.ts": 53, + "src/main/ipc/worktree-logic-created-agent.test.ts": 5, + "src/main/ipc/worktree-logic-wsl.test.ts": 21, + "src/main/ipc/worktree-logic.test.ts": 53, + "src/main/ipc/worktree-metadata-merge.test.ts": 5, + "src/main/ipc/worktree-path-deduplication.test.ts": 34, + "src/main/ipc/worktree-push-target-cleanup.test.ts": 20, + "src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts": 930, + "src/main/ipc/worktree-push-target-reconciliation.test.ts": 19, + "src/main/ipc/worktree-push-target-refspec-migration.test.ts": 11, + "src/main/ipc/worktree-push-target-refspec-real-git.test.ts": 2112, + "src/main/ipc/worktree-push-target-remote-scan.test.ts": 14, + "src/main/ipc/worktree-push-target-setup.test.ts": 16, + "src/main/ipc/worktree-remote-push-target-materialization.test.ts": 31, + "src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts": 11, + "src/main/ipc/worktree-symlink-reconciliation.real.test.ts": 49, + "src/main/ipc/worktree-symlinks.test.ts": 123, + "src/main/ipc/worktree-watcher-removal-binding.test.ts": 78, + "src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts": 210, + "src/main/ipc/worktrees-create-execution-host-routing.test.ts": 45, + "src/main/ipc/worktrees-create-metadata-persistence.test.ts": 60, + "src/main/ipc/worktrees-delete-pty-teardown.test.ts": 53, + "src/main/ipc/worktrees-detected-scan-cache.test.ts": 97, + "src/main/ipc/worktrees-discovery-metadata-backfill.test.ts": 51, + "src/main/ipc/worktrees-existing-branch-checkout.test.ts": 71, + "src/main/ipc/worktrees-forget-local.test.ts": 39, + "src/main/ipc/worktrees-issue-command-overrides.test.ts": 25, + "src/main/ipc/worktrees-lineage-hydration.test.ts": 33, + "src/main/ipc/worktrees-listing-fallback-rows.test.ts": 39, + "src/main/ipc/worktrees-local-base-ref-resolution.test.ts": 82, + "src/main/ipc/worktrees-local-create-flow.test.ts": 76, + "src/main/ipc/worktrees-orphan-directory-cleanup.test.ts": 35, + "src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts": 28, + "src/main/ipc/worktrees-removal-recovery.test.ts": 75, + "src/main/ipc/worktrees-remove-archive-hooks.test.ts": 35, + "src/main/ipc/worktrees-remove-host-disambiguation.test.ts": 32, + "src/main/ipc/worktrees-remove-preflight.test.ts": 33, + "src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts": 57, + "src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts": 61, + "src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts": 51, + "src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts": 124, + "src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts": 73, + "src/main/ipc/worktrees-ssh-local-base-refresh.test.ts": 86, + "src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts": 43, + "src/main/ipc/worktrees-ssh-provider-authority.test.ts": 35, + "src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts": 42, + "src/main/ipc/worktrees-ssh-setup-launch.test.ts": 48, + "src/main/ipc/worktrees-windows.test.ts": 72, + "src/main/ipc/worktrees-wsl-runtime-routing.test.ts": 84, + "src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts": 19, + "src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts": 29, + "src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts": 21, + "src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts": 13, + "src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 9, + "src/main/jira/adf-markdown.test.ts": 10, + "src/main/jira/attachment-image-cache-generation.test.ts": 11, + "src/main/jira/attachment-image-cache.test.ts": 9, + "src/main/jira/attachment-images.test.ts": 31, + "src/main/jira/client.test.ts": 302, + "src/main/jira/issues.test.ts": 62, + "src/main/jira/jira-issue-mutations.test.ts": 29, + "src/main/jira/jira-issue-summary-timeout.test.ts": 33, + "src/main/jira/jira-search-abort.test.ts": 24, + "src/main/keybindings/keybinding-file.test.ts": 53, + "src/main/keybindings/keybinding-service.test.ts": 39, + "src/main/kimi/hook-service.test.ts": 8, + "src/main/kimi/kimi-hook-config-toml.test.ts": 25, + "src/main/kimi/kimi-runtime-home.test.ts": 15, + "src/main/lib/unread-response-body.test.ts": 51, + "src/main/line-editor-ready-output-scanner.test.ts": 7, + "src/main/linear/client.test.ts": 97, + "src/main/linear/issue-context-client.test.ts": 23, + "src/main/linear/issue-context-current.test.ts": 6, + "src/main/linear/issue-context-errors.test.ts": 6, + "src/main/linear/issue-context-includes.test.ts": 27, + "src/main/linear/issue-context-inline-media.test.ts": 6, + "src/main/linear/issue-context-relations.test.ts": 10, + "src/main/linear/issue-context.test.ts": 16, + "src/main/linear/issue-list-filter.test.ts": 8, + "src/main/linear/issue-relation-write.test.ts": 19, + "src/main/linear/issue-search-summary.test.ts": 6, + "src/main/linear/issues.test.ts": 41, + "src/main/linear/mappers.test.ts": 8, + "src/main/linear/mcp-issue-list-pagination.test.ts": 56, + "src/main/linear/mcp-issue-list.test.ts": 93, + "src/main/linear/projects.test.ts": 306, + "src/main/linear/teams.test.ts": 20, + "src/main/linux-lid-sleep-assertion.test.ts": 14, + "src/main/linux-package-install-command.test.ts": 36, + "src/main/linux-package-install-diagnostic.test.ts": 17, + "src/main/linux-package-update-recovery.test.ts": 178, + "src/main/linux-update-package-type.test.ts": 86, + "src/main/local-builds/local-build-candidate.test.ts": 107, + "src/main/local-builds/local-build-compatibility-contract.test.ts": 6, + "src/main/local-builds/local-build-feed-server.test.ts": 68, + "src/main/local-downloaded-folder-promotion.test.ts": 101, + "src/main/local-worktree-filesystem.test.ts": 269, + "src/main/local-worktree-metadata-prune-gate.test.ts": 9, + "src/main/local-worktree-path-presence.test.ts": 18, + "src/main/local-worktree-removal-recovery.test.ts": 22, + "src/main/localhost-worktree-label-proxy.test.ts": 72, + "src/main/macos-full-disk-access-status.test.ts": 8, + "src/main/macos-press-and-hold-default.test.ts": 13, + "src/main/macos-system-sleep-assertion.test.ts": 20, + "src/main/macos-tcc-prompt-notice.test.ts": 16, + "src/main/macos-tcc-prompt-watch.test.ts": 22, + "src/main/main-process-tree-kill-gate.test.ts": 7, + "src/main/memory/collector-windows-sweep.test.ts": 198, + "src/main/memory/collector.test.ts": 239, + "src/main/memory/host-memory.test.ts": 21, + "src/main/memory/hydrate-local-pty-registry.test.ts": 873, + "src/main/memory/process-memory-metric.test.ts": 6, + "src/main/memory/windows-process-sample-parsing.test.ts": 86, + "src/main/menu/gpu-acceleration-about-panel.test.ts": 9, + "src/main/menu/register-app-menu.test.ts": 21, + "src/main/mimo/hook-service.test.ts": 11, + "src/main/minimax/minimax-api-key-store.test.ts": 30, + "src/main/minimax/minimax-cookie-store.test.ts": 38, + "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts": 293, + "src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts": 97, + "src/main/native-chat/agent-session-journal/journal-cursor.test.ts": 9, + "src/main/native-chat/agent-session-journal/journal-database.test.ts": 95, + "src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts": 38, + "src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts": 94, + "src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts": 7709, + "src/main/native-chat/agent-session-journal/journal-item-identity.test.ts": 10, + "src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts": 181, + "src/main/native-chat/agent-session-journal/journal-reducer.test.ts": 45, + "src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts": 28, + "src/main/native-chat/agent-session-journal/journal-row-schema.test.ts": 32, + "src/main/native-chat/agent-session-journal/journal-row-writer.test.ts": 28, + "src/main/native-chat/agent-session-journal/journal-store-close.test.ts": 85, + "src/main/native-chat/agent-session-journal/journal-store-schema.test.ts": 85, + "src/main/native-chat/agent-session-journal/journal-store.test.ts": 290, + "src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts": 835, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts": 46, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts": 20, + "src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts": 477, + "src/main/native-chat/agent-session-wire/agent-session-history-page-grouping-parity.test.ts": 253, + "src/main/native-chat/agent-session-wire/agent-session-history-page-scaling.test.ts": 17, + "src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts": 1870, + "src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts": 178, + "src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts": 10, + "src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts": 15, + "src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts": 22, + "src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts": 564, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts": 15, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts": 14, + "src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts": 165, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts": 248, + "src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts": 52, + "src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts": 45, + "src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts": 24, + "src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts": 16, + "src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts": 30, + "src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts": 68, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts": 99, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts": 144, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts": 587, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts": 5, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts": 207, + "src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts": 215, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts": 103, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts": 7, + "src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts": 1194, + "src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts": 66, + "src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts": 832, + "src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts": 7, + "src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts": 156, + "src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts": 431, + "src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts": 189, + "src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts": 115, + "src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts": 130, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts": 288, + "src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts": 290, + "src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts": 7, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts": 361, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts": 180, + "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts": 8912, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts": 59, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts": 137, + "src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts": 19, + "src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts": 1652, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts": 34, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts": 20, + "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts": 515, + "src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts": 304, + "src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts": 13, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts": 581, + "src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts": 314, + "src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts": 1169, + "src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts": 64, + "src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts": 251, + "src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts": 47, + "src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts": 15, + "src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts": 985, + "src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts": 569, + "src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts": 831, + "src/main/native-chat/agent-session-wire/structured-conversation-replacements.test.ts": 8, + "src/main/native-chat/agent-session-wire/structured-rewind-claude-proof.test.ts": 9, + "src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts": 16, + "src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts": 21, + "src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts": 420, + "src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts": 21, + "src/main/native-chat/claude-structured-managed-account-support.test.ts": 11, + "src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts": 213, + "src/main/native-chat/host-readable-transcript-path.test.ts": 20, + "src/main/native-chat/native-chat-file-provenance.test.ts": 10, + "src/main/native-chat/session-file-resolver-claude-roots.test.ts": 6, + "src/main/native-chat/session-file-resolver-codex-roots.test.ts": 4, + "src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts": 21, + "src/main/native-chat/session-file-resolver-wsl.test.ts": 21, + "src/main/native-chat/session-file-resolver.test.ts": 87, + "src/main/native-chat/structured-agent-session-create-support.test.ts": 8, + "src/main/native-chat/structured-agent-session-history-adoption.test.ts": 9, + "src/main/native-chat/subagent-entry-id-bounds.test.ts": 7, + "src/main/native-chat/transcript-fallback-id.test.ts": 6, + "src/main/native-chat/transcript-interruption-message.test.ts": 7, + "src/main/native-chat/transcript-line-decoders-claude-control-bytes.test.ts": 7, + "src/main/native-chat/transcript-line-decoders-claude-image-companion.test.ts": 6, + "src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts": 12, + "src/main/native-chat/transcript-line-decoders.grok.test.ts": 13, + "src/main/native-chat/transcript-line-decoders.omp.test.ts": 21, + "src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts": 31, + "src/main/native-chat/transcript-read-cache-wsl-stall.test.ts": 14, + "src/main/native-chat/transcript-read-cache.test.ts": 55, + "src/main/native-chat/transcript-reader-codex-history-mode.test.ts": 21, + "src/main/native-chat/transcript-reader-wsl-stall.test.ts": 20, + "src/main/native-chat/transcript-reader.test.ts": 31, + "src/main/native-chat/transcript-stream-lines.test.ts": 8, + "src/main/native-chat/transcript-tail-reader-cancellation.test.ts": 115, + "src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts": 17, + "src/main/native-chat/transcript-turn-lifecycle.test.ts": 11, + "src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts": 71, + "src/main/native-chat/transcript-watch-error.test.ts": 2068, + "src/main/native-chat/transcript-watch-liveness.test.ts": 639, + "src/main/native-chat/transcript-watch-resolve-poll.test.ts": 82, + "src/main/native-chat/transcript-watch-unflushed-settle.test.ts": 94, + "src/main/native-chat/transcript-watch-unsubscribe-race.test.ts": 26, + "src/main/native-chat/transcript-watch-wsl-exact-path.test.ts": 21, + "src/main/native-chat/transcript-watch-wsl-stall.test.ts": 21, + "src/main/native-chat/transcript-watch.test.ts": 3183, + "src/main/native-chat/transcript-window-tool-attribution.test.ts": 13, + "src/main/native-chat/wsl-codex-session-path-scan.test.ts": 24, + "src/main/native-chat/wsl-transcript-fs-access.test.ts": 29, + "src/main/native-chat/wsl-transcript-fs-gate.test.ts": 463, + "src/main/native-chat/wsl-transcript-fs-process-client.test.ts": 33, + "src/main/native-chat/wsl-transcript-fs-process-operations.test.ts": 5, + "src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts": 22, + "src/main/native-chat/wsl-transcript-fs-route.test.ts": 6, + "src/main/native-chat/wsl-transcript-running-observer.test.ts": 65, + "src/main/network/electron-proxy-request-guard.test.ts": 314, + "src/main/network/macos-system-resolver-health.test.ts": 18, + "src/main/network/macos-tailscale-dns-diagnostic.test.ts": 8, + "src/main/network/proxy-settings-session.test.ts": 779, + "src/main/network/proxy-settings.test.ts": 67, + "src/main/notifications/desktop-away-state.test.ts": 6, + "src/main/observability/architecture.test.ts": 10, + "src/main/observability/bundle.test.ts": 294, + "src/main/observability/diagnostic-upload-http.test.ts": 8, + "src/main/observability/instrumentation.test.ts": 252, + "src/main/observability/local-file-sink-memory.test.ts": 126, + "src/main/observability/local-file-sink.test.ts": 27, + "src/main/observability/redactor-environment-lines.test.ts": 486, + "src/main/observability/redactor.test.ts": 27, + "src/main/observability/tracer.test.ts": 27, + "src/main/opencode-usage/scanner-windows-data-directory.test.ts": 23, + "src/main/opencode-usage/scanner-wsl-gate.test.ts": 18, + "src/main/opencode-usage/scanner.test.ts": 224, + "src/main/opencode-usage/store.test.ts": 28, + "src/main/opencode/hook-plugin-background-child-completion.test.ts": 197, + "src/main/opencode/hook-plugin-child-attention.test.ts": 421, + "src/main/opencode/hook-plugin-fail-open-ownership.test.ts": 460, + "src/main/opencode/hook-plugin-lifecycle-delivery.test.ts": 591, + "src/main/opencode/hook-plugin-message-part-throttle.test.ts": 137, + "src/main/opencode/hook-plugin-module-contract.test.ts": 146, + "src/main/opencode/hook-service.test.ts": 50, + "src/main/opencode/opencode-data-directory.test.ts": 6, + "src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts": 142, + "src/main/orca-profiles/profile-cloud-auth-config.test.ts": 7, + "src/main/orca-profiles/profile-cloud-auth-status.test.ts": 7, + "src/main/orca-profiles/profile-cloud-client.test.ts": 73, + "src/main/orca-profiles/profile-cloud-dev-service.test.ts": 52, + "src/main/orca-profiles/profile-cloud-org-members-client.test.ts": 9, + "src/main/orca-profiles/profile-cloud-org-members-service.test.ts": 33, + "src/main/orca-profiles/profile-cloud-pkce.test.ts": 184, + "src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts": 42, + "src/main/orca-profiles/profile-cloud-service-refresh.test.ts": 148, + "src/main/orca-profiles/profile-cloud-service.test.ts": 45, + "src/main/orca-profiles/profile-cloud-session-mutation.test.ts": 14, + "src/main/orca-profiles/profile-cloud-session-refresh.test.ts": 12, + "src/main/orca-profiles/profile-cloud-session-store.test.ts": 71, + "src/main/orca-profiles/profile-index-store.test.ts": 188, + "src/main/orca-profiles/profile-project-presence.test.ts": 59, + "src/main/orca-profiles/profile-project-session-field-disposition.test.ts": 9, + "src/main/orca-profiles/profile-project-session-state.test.ts": 13, + "src/main/orca-profiles/profile-project-state-file.test.ts": 7, + "src/main/orca-profiles/profile-project-transfer.test.ts": 238, + "src/main/orca-profiles/profile-ui-scope.test.ts": 4, + "src/main/orcad/electron-serve-browser-process.test.ts": 2342, + "src/main/orcad/electron-serve-provider-selection.test.ts": 427, + "src/main/orcad/electron-sidecar-method-routing.test.ts": 7, + "src/main/orcad/electron-sidecar-tab-registry.test.ts": 12, + "src/main/orcad/external-chromium-browser-session.test.ts": 16, + "src/main/orcad/main-preflight-order.test.ts": 8, + "src/main/orcad/native-host-abi.test.ts": 15, + "src/main/orcad/node-pty-prebuilt-slot.test.ts": 12, + "src/main/orcad/node-pty-precondition.test.ts": 290, + "src/main/orcad/orcad-app-paths.test.ts": 10, + "src/main/orcad/orcad-bind-address.test.ts": 11, + "src/main/orcad/orcad-browser-provider.test.ts": 53, + "src/main/orcad/orcad-bundle-native-load-order.test.ts": 3283, + "src/main/orcad/orcad-daemon-supervision.test.ts": 10, + "src/main/orcad/orcad-forked-child-paths.test.ts": 8, + "src/main/orcad/orcad-health.test.ts": 10, + "src/main/orcad/orcad-instance-lock.test.ts": 15, + "src/main/orcad/orcad-launch-contract.test.ts": 13, + "src/main/orcad/orcad-native-preflight.test.ts": 6, + "src/main/orcad/orcad-push-startup.test.ts": 704, + "src/main/own-chromium-tree-kill-guard.test.ts": 20, + "src/main/persistence-async-write-syscalls.test.ts": 5455, + "src/main/persistence-automations.test.ts": 593, + "src/main/persistence-clipboard-selection-migration.test.ts": 65, + "src/main/persistence-cohort-and-identity-migration.test.ts": 67, + "src/main/persistence-cross-host-pane-identity.test.ts": 36, + "src/main/persistence-deregistered-repo-residue.test.ts": 132, + "src/main/persistence-duplicate-repo-id-host-scope.test.ts": 792, + "src/main/persistence-feature-interaction-broadcast.benchmark.test.ts": 126, + "src/main/persistence-floating-terminal-trust.test.ts": 91, + "src/main/persistence-flush-and-save-scheduling.test.ts": 226, + "src/main/persistence-folder-workspace-notes.test.ts": 124, + "src/main/persistence-host-admitted-terminal-membership.test.ts": 32, + "src/main/persistence-host-partitioned-sessions.test.ts": 173, + "src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts": 31, + "src/main/persistence-initial-load.test.ts": 171, + "src/main/persistence-layout-binding-recovery.test.ts": 46, + "src/main/persistence-load-repair-durability.test.ts": 65, + "src/main/persistence-loading-store-extraction.test.ts": 93, + "src/main/persistence-loading-store-write-risks.test.ts": 34, + "src/main/persistence-native-chat-tab-view-mode.test.ts": 17, + "src/main/persistence-pane-identity-migration.test.ts": 27776, + "src/main/persistence-protected-secret-fail-closed.test.ts": 2755, + "src/main/persistence-protected-secret-write-race.test.ts": 698, + "src/main/persistence-proxy-secret-recovery.test.ts": 1089, + "src/main/persistence-pty-binding-leaf-tab-resolution.test.ts": 33, + "src/main/persistence-pty-binding-reconciliation.test.ts": 54, + "src/main/persistence-remote-session-startup.test.ts": 58, + "src/main/persistence-repo-lifecycle.test.ts": 807, + "src/main/persistence-right-sidebar-tab.test.ts": 7, + "src/main/persistence-settings-ui-defaults.test.ts": 137, + "src/main/persistence-settings-update.test.ts": 124, + "src/main/persistence-single-serialize.test.ts": 1727, + "src/main/persistence-source-control-ai-migration.test.ts": 108, + "src/main/persistence-split-pane-incarnation.test.ts": 65, + "src/main/persistence-ssh-lease-reattach-reclaim.test.ts": 46, + "src/main/persistence-ssh-lease-tombstone-retention.test.ts": 44, + "src/main/persistence-ssh-pending-pty-kill.test.ts": 570, + "src/main/persistence-ssh-readoption-automation-migration.test.ts": 1520, + "src/main/persistence-ssh-remote-pty-binding-replay.test.ts": 41, + "src/main/persistence-ssh-remote-pty-leases.test.ts": 129, + "src/main/persistence-ssh-targets-and-pane-keys.test.ts": 72, + "src/main/persistence-terminal-option-key-migration.test.ts": 47, + "src/main/persistence-ui-state.test.ts": 3109, + "src/main/persistence-update-repo.test.ts": 252, + "src/main/persistence-workspace-pinned-automation-fence.test.ts": 1660, + "src/main/persistence-workspace-repin-automation-owner.test.ts": 1100, + "src/main/persistence-workspace-session-scrollback.test.ts": 101, + "src/main/persistence-workspace-status-workflow.test.ts": 67, + "src/main/persistence-worktree-card-properties.test.ts": 43, + "src/main/persistence-worktree-deletion-fencing.test.ts": 88, + "src/main/persistence-worktree-lineage-and-backups.test.ts": 499, + "src/main/persistence-worktree-meta-and-folder-workspaces.test.ts": 83, + "src/main/persistence-worktree-name-retirement.test.ts": 3387, + "src/main/persistence-worktree-visibility.test.ts": 100, + "src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts": 8, + "src/main/persistence/applying-settings/terminal-settings-migrations.test.ts": 4, + "src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.test.ts": 22, + "src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts": 46, + "src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts": 17, + "src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts": 12, + "src/main/persistence/loading-store/persisted-state-redundancy.test.ts": 461, + "src/main/persistence/loading-store/secret-sentinel-substitution.test.ts": 14, + "src/main/persistence/loading-store/state-write-round-trip.test.ts": 72, + "src/main/persistence/loading-store/store-prune-gate-signals.test.ts": 32, + "src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts": 29, + "src/main/persistence/loading-store/workspace-session-partitions.test.ts": 16, + "src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts": 13, + "src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts": 345, + "src/main/persistence/loading-store/worktree-meta-write-normalization.test.ts": 8, + "src/main/persistence/restoring-sessions/pane-alias-normalization-scaling.test.ts": 11, + "src/main/persistence/restoring-sessions/pane-alias-normalization.test.ts": 6, + "src/main/persistence/restoring-sessions/pane-key-remapping.test.ts": 9, + "src/main/persistence/restoring-sessions/session-owner-fields.test.ts": 10, + "src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts": 21, + "src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts": 6, + "src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts": 10, + "src/main/persistence/runtime-authored-workspace-session-fields.test.ts": 5, + "src/main/persistence/scheduling-automations/automation-run-operations.test.ts": 16, + "src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts": 21, + "src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts": 107, + "src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts": 13, + "src/main/persistence/tracking-repos/project-host-compatibility.test.ts": 11, + "src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts": 9, + "src/main/pi/agent-status-extension-omp-lifecycle.test.ts": 839, + "src/main/pi/agent-status-extension-source.test.ts": 1483, + "src/main/pi/agent-status-omp-approval-forwarding.test.ts": 487, + "src/main/pi/agent-status-owner-recovery.test.ts": 432, + "src/main/pi/agent-status-ui-prompt.test.ts": 623, + "src/main/pi/titlebar-extension-overlay-path.test.ts": 9, + "src/main/pi/titlebar-extension-service.test.ts": 147, + "src/main/pi/titlebar-extension-source.test.ts": 618, + "src/main/plugins/plugin-atomic-file-write.test.ts": 1988, + "src/main/plugins/plugin-audit-log-scaling.test.ts": 412, + "src/main/plugins/plugin-audit-log.test.ts": 17, + "src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts": 62, + "src/main/plugins/plugin-bundled-bootstrap.test.ts": 104, + "src/main/plugins/plugin-command-registry.test.ts": 32, + "src/main/plugins/plugin-content-pack-registry.test.ts": 30, + "src/main/plugins/plugin-content-safety.test.ts": 66, + "src/main/plugins/plugin-dev-watcher.test.ts": 106, + "src/main/plugins/plugin-discovery.test.ts": 40, + "src/main/plugins/plugin-enablement.test.ts": 14, + "src/main/plugins/plugin-host-conformance.test.ts": 30, + "src/main/plugins/plugin-host-methods.test.ts": 22, + "src/main/plugins/plugin-host-process.test.ts": 13, + "src/main/plugins/plugin-host-runtime.test.ts": 11, + "src/main/plugins/plugin-install-trust.test.ts": 51, + "src/main/plugins/plugin-install.test.ts": 334, + "src/main/plugins/plugin-kill-list-content-revocation.test.ts": 52, + "src/main/plugins/plugin-kill-list-service.test.ts": 68, + "src/main/plugins/plugin-language-pack-registry.test.ts": 22, + "src/main/plugins/plugin-launch-content.test.ts": 84, + "src/main/plugins/plugin-list-projection.test.ts": 15, + "src/main/plugins/plugin-marketplace-installer.test.ts": 98, + "src/main/plugins/plugin-marketplace-service.test.ts": 136, + "src/main/plugins/plugin-marketplace-store.test.ts": 241, + "src/main/plugins/plugin-panel-controller.test.ts": 25, + "src/main/plugins/plugin-panel-navigation-guard.test.ts": 7, + "src/main/plugins/plugin-panel-owner-lifecycle.test.ts": 4, + "src/main/plugins/plugin-panel-sessions.test.ts": 7, + "src/main/plugins/plugin-private-marketplace.integration.test.ts": 321, + "src/main/plugins/plugin-secrets-store.test.ts": 33, + "src/main/plugins/plugin-service-integrity.test.ts": 70, + "src/main/plugins/plugin-service-reconciliation.test.ts": 314, + "src/main/plugins/plugin-startup-budget.test.ts": 373, + "src/main/plugins/plugin-storage-store.test.ts": 8, + "src/main/plugins/plugin-vm-recipe-registry.test.ts": 40, + "src/main/plugins/plugin-worker-controller.test.ts": 77, + "src/main/plugins/plugin-worker-env.test.ts": 6, + "src/main/plugins/plugin-worker-manager.test.ts": 17, + "src/main/plugins/plugin-worker-output-buffer.test.ts": 12, + "src/main/plugins/plugin-worker-supervision.integration.test.ts": 8191, + "src/main/ports/advertised-url-watcher-pid-validation.test.ts": 10, + "src/main/ports/advertised-url-watcher.test.ts": 22, + "src/main/ports/local-workspace-port-scanner.test.ts": 44, + "src/main/ports/port-scan-command-client.test.ts": 1741, + "src/main/ports/port-scan-command-execution.test.ts": 5223, + "src/main/ports/port-scan-command-import-boundary.test.ts": 6, + "src/main/ports/ssh-advertised-url-enrichment.test.ts": 13, + "src/main/ports/workspace-port-ownership.test.ts": 9, + "src/main/powershell-osc133-bootstrap.test.ts": 14, + "src/main/project-groups/folder-workspace-path-status.test.ts": 19, + "src/main/project-groups/nested-repo-discovery.test.ts": 103, + "src/main/project-groups/nested-repo-import-target.test.ts": 10, + "src/main/project-groups/nested-repo-import.test.ts": 17, + "src/main/project-runtime-git-options.test.ts": 15, + "src/main/protected-secret-persistence.test.ts": 20, + "src/main/providers/agent-foreground-process-batch.test.ts": 15, + "src/main/providers/agent-foreground-process-pi.test.ts": 12, + "src/main/providers/agent-foreground-process-ps-scan-volume.test.ts": 24, + "src/main/providers/agent-foreground-process-real-rows.test.ts": 13, + "src/main/providers/agent-foreground-process-remote-evidence.test.ts": 11, + "src/main/providers/agent-foreground-process-windows-job-rejection.test.ts": 4, + "src/main/providers/agent-foreground-process.test.ts": 30, + "src/main/providers/execution-host-provider-dispatch.test.ts": 12, + "src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts": 10, + "src/main/providers/local-pty-provider-foreground-process.test.ts": 110, + "src/main/providers/local-pty-provider-io-events.test.ts": 31, + "src/main/providers/local-pty-provider-session-inventory.test.ts": 29, + "src/main/providers/local-pty-provider-shell-readiness.test.ts": 39, + "src/main/providers/local-pty-provider-shutdown.test.ts": 351, + "src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts": 21, + "src/main/providers/local-pty-provider-spawn-env.test.ts": 40, + "src/main/providers/local-pty-provider-spawn-session.test.ts": 33, + "src/main/providers/local-pty-provider-windows-shell-launch.test.ts": 72, + "src/main/providers/local-pty-shell-ready-marker-scan.test.ts": 8, + "src/main/providers/local-pty-shell-ready-startup-command.test.ts": 11, + "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts": 411, + "src/main/providers/local-pty-shell-startup-command.node-pty.test.ts": 52, + "src/main/providers/local-pty-utils-windows-fallback.test.ts": 9, + "src/main/providers/local-pty-utils.test.ts": 12, + "src/main/providers/macos-login-session-pty-probe.test.ts": 12, + "src/main/providers/macos-tcc-login-shell.test.ts": 77, + "src/main/providers/posix-pane-foreground-fingerprint.test.ts": 9, + "src/main/providers/process-cwd.test.ts": 100, + "src/main/providers/provider-dispatch.test.ts": 38, + "src/main/providers/pty-default-cwd.test.ts": 9, + "src/main/providers/pty-process-inspection.test.ts": 15, + "src/main/providers/pty-process-list-admission.test.ts": 76, + "src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts": 1855, + "src/main/providers/settled-pty-writer-census.test.ts": 79, + "src/main/providers/ssh-filesystem-dispatch.test.ts": 9, + "src/main/providers/ssh-filesystem-doc-preview.test.ts": 7, + "src/main/providers/ssh-filesystem-download.test.ts": 145, + "src/main/providers/ssh-filesystem-provider-capabilities.test.ts": 15, + "src/main/providers/ssh-filesystem-provider-download-folder.test.ts": 22, + "src/main/providers/ssh-filesystem-provider-range.test.ts": 13, + "src/main/providers/ssh-filesystem-provider-stream.test.ts": 52, + "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts": 437, + "src/main/providers/ssh-filesystem-provider.test.ts": 59, + "src/main/providers/ssh-filesystem-watch-notifications.test.ts": 11, + "src/main/providers/ssh-git-dispatch.test.ts": 4, + "src/main/providers/ssh-git-provider-api.test.ts": 8, + "src/main/providers/ssh-git-provider-commit-message.test.ts": 18, + "src/main/providers/ssh-git-provider-diff.test.ts": 32, + "src/main/providers/ssh-git-provider-exec.test.ts": 26, + "src/main/providers/ssh-git-provider-merge.test.ts": 9, + "src/main/providers/ssh-git-provider-remote-sync.test.ts": 21, + "src/main/providers/ssh-git-provider-staging.test.ts": 16, + "src/main/providers/ssh-git-provider-status-lease.test.ts": 24, + "src/main/providers/ssh-git-provider-status.test.ts": 22, + "src/main/providers/ssh-git-provider-upstream-lease.test.ts": 16, + "src/main/providers/ssh-git-provider-worktree.test.ts": 29, + "src/main/providers/ssh-git-worktree-list-dedupe.test.ts": 32, + "src/main/providers/ssh-pty-inspect-observation-identity.test.ts": 6, + "src/main/providers/ssh-pty-live-source-restore-respawn.test.ts": 15, + "src/main/providers/ssh-pty-notification-rejection-routing.test.ts": 162, + "src/main/providers/ssh-pty-notification-routing.test.ts": 22, + "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts": 35, + "src/main/providers/ssh-pty-provider-claim-incarnation.test.ts": 7, + "src/main/providers/ssh-pty-provider-exit-race.test.ts": 19, + "src/main/providers/ssh-pty-provider-process-events.test.ts": 18, + "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts": 9, + "src/main/providers/ssh-pty-provider-spawn.test.ts": 33, + "src/main/providers/ssh-pty-provider-terminal-repair.test.ts": 36, + "src/main/providers/ssh-pty-provider.test.ts": 31, + "src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts": 23, + "src/main/providers/ssh-pty-relay-absence-verdict.test.ts": 19, + "src/main/providers/ssh-pty-source-delivery-ledger.test.ts": 14, + "src/main/providers/ssh-pty-write.test.ts": 25, + "src/main/providers/ssh-worktree-catalog-authority.test.ts": 29, + "src/main/providers/stable-foreground-process.test.ts": 5, + "src/main/providers/windows-agent-foreground-process-scan-volume.test.ts": 37, + "src/main/providers/windows-cached-agent-revalidation.test.ts": 11, + "src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts": 7, + "src/main/providers/windows-console-attached-processes.test.ts": 16, + "src/main/providers/windows-foreground-process-inspection-cost.test.ts": 15, + "src/main/providers/windows-foreground-process-rows.test.ts": 13, + "src/main/providers/windows-powershell-executable.test.ts": 18, + "src/main/providers/windows-powershell.test.ts": 8, + "src/main/providers/windows-pty-job-membership.test.ts": 13, + "src/main/providers/windows-shell-args.test.ts": 48, + "src/main/providers/windows-shell-fallback-chain.test.ts": 10, + "src/main/providers/working-directory-validation.test.ts": 45, + "src/main/provisioned-root-ssh-adoption.test.ts": 140, + "src/main/proxy-guarded-fetch-call-site-audit.test.ts": 76, + "src/main/pty-descendant-termination-job-coverage.test.ts": 5, + "src/main/pty-descendant-termination.test.ts": 59, + "src/main/pty/appimage-terminal-env.test.ts": 6, + "src/main/pty/build-mode-env.test.ts": 6, + "src/main/pty/codex-home-wsl-env.test.ts": 5, + "src/main/pty/codex-preflight-profile-path-rewrite.test.ts": 134, + "src/main/pty/codex-shell-launch-preflight.test.ts": 4086, + "src/main/pty/conda-activation-env.test.ts": 8, + "src/main/pty/legacy-terminal-shim-dir.test.ts": 1197, + "src/main/pty/legacy-terminal-windows-tombstone.test.ts": 21, + "src/main/pty/node-pty-master-fd-retirement.test.ts": 5158, + "src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts": 6, + "src/main/pty/omp-shell-wrapper-alias-safety.test.ts": 16, + "src/main/pty/omp-sqlite-overlay.test.ts": 5, + "src/main/pty/overlay-mirror.test.ts": 17, + "src/main/pty/posix-pty-foreground-group.test.ts": 9, + "src/main/pty/posix-pty-process-groups.integration.test.ts": 106, + "src/main/pty/posix-pty-process-groups.test.ts": 10, + "src/main/pty/shell-startup-env.test.ts": 19, + "src/main/pty/terminal-color-env.test.ts": 8, + "src/main/pty/windows-environment-path-main-loop.test.ts": 367, + "src/main/pty/windows-environment-path.test.ts": 17, + "src/main/pty/windows-path-registry-change.test.ts": 6, + "src/main/pty/windows-path-registry-fallback.test.ts": 7, + "src/main/pty/windows-path-registry-reader.test.ts": 9, + "src/main/pty/wsl-orca-env.test.ts": 20, + "src/main/pwsh.test.ts": 24, + "src/main/quit-path-durable-write-blocking.test.ts": 1453, + "src/main/quit-teardown-agent-browser-daemons.test.ts": 6, + "src/main/quit-teardown-deadline.test.ts": 11, + "src/main/quit-teardown-start-gate.test.ts": 8, + "src/main/rate-limits/account-runtime-target-sync.test.ts": 12, + "src/main/rate-limits/antigravity-usage-mirror.test.ts": 7, + "src/main/rate-limits/auth-filesystem-operation.test.ts": 11, + "src/main/rate-limits/claude-fetcher-cli-fallback.test.ts": 86, + "src/main/rate-limits/claude-fetcher-fable-usage.test.ts": 65, + "src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts": 100, + "src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts": 50, + "src/main/rate-limits/claude-oauth-usage-error.test.ts": 44, + "src/main/rate-limits/claude-pty.test.ts": 64, + "src/main/rate-limits/claude-usage-error-classification.test.ts": 9, + "src/main/rate-limits/claude-usage-refresh-plan.test.ts": 7, + "src/main/rate-limits/codex-auth-presence.test.ts": 17, + "src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 15, + "src/main/rate-limits/codex-fetcher-backend.test.ts": 76, + "src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 38, + "src/main/rate-limits/codex-fetcher-process-contract.test.ts": 43, + "src/main/rate-limits/codex-fetcher-pty-settle.test.ts": 38, + "src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 175, + "src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts": 17, + "src/main/rate-limits/codex-fetcher-session-supplement.test.ts": 20, + "src/main/rate-limits/codex-fetcher.test.ts": 86, + "src/main/rate-limits/codex-probe-termination.test.ts": 16, + "src/main/rate-limits/codex-pty-rate-limit-probe.test.ts": 10, + "src/main/rate-limits/codex-pty-status-parser.test.ts": 7, + "src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 11, + "src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 60, + "src/main/rate-limits/gemini-bucket-formatting.test.ts": 6, + "src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts": 10, + "src/main/rate-limits/gemini-usage-fetcher.test.ts": 19, + "src/main/rate-limits/grok-auth.test.ts": 41, + "src/main/rate-limits/grok-fetcher.test.ts": 27, + "src/main/rate-limits/hidden-pty-cleanup.test.ts": 12, + "src/main/rate-limits/initial-account-rate-limit-target.test.ts": 14, + "src/main/rate-limits/kimi-fetcher-wsl-home.test.ts": 12, + "src/main/rate-limits/kimi-fetcher.test.ts": 34, + "src/main/rate-limits/minimax/minimax-fetcher.test.ts": 46, + "src/main/rate-limits/minimax/minimax-request-context.test.ts": 26, + "src/main/rate-limits/opencode-go-usage-fetcher.test.ts": 34, + "src/main/rate-limits/service-account-target-selection.test.ts": 142, + "src/main/rate-limits/service-antigravity-usage.test.ts": 11, + "src/main/rate-limits/service-inactive-account-previews.test.ts": 27, + "src/main/rate-limits/service-live-claude-usage.test.ts": 37, + "src/main/rate-limits/service-minimax-usage.test.ts": 17, + "src/main/rate-limits/service-refresh-orchestration.test.ts": 29, + "src/main/rate-limits/service-window-activation.test.ts": 22, + "src/main/refused-tree-kill-root-termination.test.ts": 24, + "src/main/remote-agent-trust-presets.test.ts": 13, + "src/main/remote-worktree-history-cleanup.test.ts": 12, + "src/main/repo-git-remote-identity-enrichment.test.ts": 22, + "src/main/repo-git-remote-identity.test.ts": 23, + "src/main/repo-git-username-enrichment.test.ts": 11, + "src/main/repo-icon-autodetect.test.ts": 242, + "src/main/repo-icon-file-detection.test.ts": 70, + "src/main/repo-icon-source-href.test.ts": 73, + "src/main/repo-maintenance-idle-gate.test.ts": 5, + "src/main/repo-worktrees.test.ts": 20, + "src/main/runtime/agent-prompt-receipt-correlation.test.ts": 65, + "src/main/runtime/agent-prompt-request-correlation.test.ts": 9, + "src/main/runtime/agent-prompt-submission-runtime.test.ts": 1762, + "src/main/runtime/agent-prompt-submission-verification.test.ts": 125, + "src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts": 192, + "src/main/runtime/agent-session-backup-recovery.test.ts": 116, + "src/main/runtime/agent-session-claim-identity.test.ts": 12, + "src/main/runtime/agent-session-conversation-name-store.test.ts": 76, + "src/main/runtime/agent-session-eviction-settlement-latch.test.ts": 9, + "src/main/runtime/agent-session-handoff-lease-transitions.test.ts": 8, + "src/main/runtime/agent-session-launch-env-admission.test.ts": 165, + "src/main/runtime/agent-session-launch-env-backfill.test.ts": 40, + "src/main/runtime/agent-session-lease-renewal.test.ts": 221, + "src/main/runtime/agent-session-orphan-child-reaper.test.ts": 12, + "src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts": 5, + "src/main/runtime/agent-session-process-identity-probe.test.ts": 17, + "src/main/runtime/agent-session-provider-handle-transition.test.ts": 7, + "src/main/runtime/agent-session-pty-write-enforcement.test.ts": 2113, + "src/main/runtime/agent-session-pty-write-gate.test.ts": 15, + "src/main/runtime/agent-session-record-conversation-name.test.ts": 14, + "src/main/runtime/agent-session-record-options.test.ts": 33, + "src/main/runtime/agent-session-record-store-security.test.ts": 23, + "src/main/runtime/agent-session-record-store.test.ts": 744, + "src/main/runtime/agent-session-record-unsupported-schema.test.ts": 26, + "src/main/runtime/agent-session-recovery-publish-fault.test.ts": 25, + "src/main/runtime/agent-session-reservation-admission.test.ts": 9, + "src/main/runtime/agent-session-restart-handoff-adjudication.test.ts": 13, + "src/main/runtime/agent-session-resume-args.test.ts": 5, + "src/main/runtime/agent-session-spawn-token-process-scan.test.ts": 8, + "src/main/runtime/agent-session-spawn-token-readback.test.ts": 9, + "src/main/runtime/agent-session-unreadable-record-salvage.test.ts": 68, + "src/main/runtime/agent-terminal-launch-trust-host.test.ts": 27, + "src/main/runtime/antigravity-readiness-transcripts.test.ts": 9380, + "src/main/runtime/automation-change-publication.test.ts": 4436, + "src/main/runtime/browser-client-download-transfers.test.ts": 80, + "src/main/runtime/browser-execution-host-key-resolution.test.ts": 25, + "src/main/runtime/browser-host-capability-selection.test.ts": 12, + "src/main/runtime/browser-host-client-page-adoption-grants.test.ts": 9, + "src/main/runtime/browser-host-client-page-adoption.test.ts": 9, + "src/main/runtime/browser-host-client-page-creation.test.ts": 28, + "src/main/runtime/browser-host-command-ledger-capacity.test.ts": 14, + "src/main/runtime/browser-host-command-ledger.test.ts": 30, + "src/main/runtime/browser-host-file-channel-admission.test.ts": 7, + "src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts": 114, + "src/main/runtime/browser-host-lease-fenced-page-release.test.ts": 30, + "src/main/runtime/browser-host-lease-placement-retirement.test.ts": 33, + "src/main/runtime/browser-host-lease-registry.test.ts": 47, + "src/main/runtime/browser-host-page-placement-replacement.test.ts": 11, + "src/main/runtime/browser-host-page-placement.test.ts": 16, + "src/main/runtime/browser-host-page-reconciliation-executor.test.ts": 44, + "src/main/runtime/browser-host-page-reconciliation-orchestration.test.ts": 245, + "src/main/runtime/browser-host-page-reconciliation-plan.test.ts": 22, + "src/main/runtime/browser-host-page-reconciliation-preconditions.test.ts": 7, + "src/main/runtime/browser-host-page-retirement.test.ts": 9, + "src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts": 411, + "src/main/runtime/browser-screencast-driver-attribution.test.ts": 503, + "src/main/runtime/browser-screencast-driver-scope.test.ts": 5, + "src/main/runtime/browser-screencast-ghost-subscriber-eviction.test.ts": 14, + "src/main/runtime/browser-screencast-remote-viewer-retention.test.ts": 379, + "src/main/runtime/browser-session-tab-selection-snapshot.test.ts": 12, + "src/main/runtime/browser-tab-create-caller-navigation.test.ts": 641, + "src/main/runtime/browser-tab-create-publication.test.ts": 998, + "src/main/runtime/claude-agent-teams-pty-exit-leak.test.ts": 23, + "src/main/runtime/claude-agent-teams-service.test.ts": 29, + "src/main/runtime/claude-agent-teams-shim-env.test.ts": 41, + "src/main/runtime/claude-structured-session-integration.test.ts": 943, + "src/main/runtime/cli-terminal-create-host-session-binding.test.ts": 22, + "src/main/runtime/client-hosted-browser-page-persistence.integration.test.ts": 39, + "src/main/runtime/client-hosted-browser-page-persistence.test.ts": 24, + "src/main/runtime/client-hosted-browser-row-hydration-census.test.ts": 427, + "src/main/runtime/client-hosted-browser-row-projection.test.ts": 9, + "src/main/runtime/client-hosted-browser-row-publication.test.ts": 30, + "src/main/runtime/client-hosted-browser-row-push.integration.test.ts": 41, + "src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts": 16, + "src/main/runtime/client-hosted-page-reconciliation-window.test.ts": 23, + "src/main/runtime/client-session-tab-selection.test.ts": 11, + "src/main/runtime/decorative-title-fact-emission.test.ts": 4, + "src/main/runtime/exit-provenance-audit.test.ts": 394, + "src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts": 22, + "src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts": 203, + "src/main/runtime/fetch-remote-cache.test.ts": 271, + "src/main/runtime/file-watcher-host.test.ts": 600, + "src/main/runtime/fit-override-integration.test.ts": 56, + "src/main/runtime/folder-workspace-pty-identity.test.ts": 54, + "src/main/runtime/graph-sync-deletion-fence.test.ts": 43, + "src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts": 95, + "src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts": 94, + "src/main/runtime/graph-sync-payload-partition.test.ts": 33, + "src/main/runtime/headless-tab-group-split-layout.test.ts": 15, + "src/main/runtime/headless-tab-order-stability.test.ts": 29, + "src/main/runtime/headless-terminal-dispose-write-ordering.test.ts": 25, + "src/main/runtime/headless-terminal-query-reply-policy.test.ts": 4, + "src/main/runtime/headless-terminal-split-layout.test.ts": 9, + "src/main/runtime/hidden-output-restored-provider-snapshot.test.ts": 74, + "src/main/runtime/host-terminal-close-persistence-durability.test.ts": 59, + "src/main/runtime/linear-save-issue.test.ts": 34, + "src/main/runtime/managed-worktree-create-execution-host.test.ts": 24, + "src/main/runtime/missing-worktree-terminal-reconciliation.test.ts": 14, + "src/main/runtime/mobile-agent-status-permission-renewal.test.ts": 34, + "src/main/runtime/mobile-notification-dismissal-read-failure.test.ts": 4, + "src/main/runtime/mobile-notification-dismissal-store.test.ts": 12, + "src/main/runtime/mobile-notification-replay.test.ts": 11, + "src/main/runtime/mobile-pairing-qr.test.ts": 1086, + "src/main/runtime/mobile-pairing-userdata-path.test.ts": 1112, + "src/main/runtime/mobile-presence-lock.test.ts": 230, + "src/main/runtime/mobile-rpc-allowlist.test.ts": 84, + "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts": 21, + "src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts": 26, + "src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts": 17, + "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts": 19, + "src/main/runtime/mobile-session-terminal-retirement-proof.test.ts": 5, + "src/main/runtime/mobile-session-terminal-retirement.test.ts": 12, + "src/main/runtime/mobile-subscribe-integration.test.ts": 303, + "src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 551, + "src/main/runtime/opencode-finished-session-authority.test.ts": 356, + "src/main/runtime/orca-runtime-agent-session-operation.test.ts": 84, + "src/main/runtime/orca-runtime-agent-skill-share.test.ts": 224, + "src/main/runtime/orca-runtime-automations.test.ts": 35, + "src/main/runtime/orca-runtime-browser-client-hosted.test.ts": 778, + "src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts": 416, + "src/main/runtime/orca-runtime-browser-headless.test.ts": 450, + "src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts": 316, + "src/main/runtime/orca-runtime-browser.test.ts": 604, + "src/main/runtime/orca-runtime-create-base-prefetch.test.ts": 27, + "src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts": 76, + "src/main/runtime/orca-runtime-exact-worker-provider-session.test.ts": 8, + "src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts": 15, + "src/main/runtime/orca-runtime-files-preview-budget.test.ts": 104, + "src/main/runtime/orca-runtime-files-rename-authority.test.ts": 29, + "src/main/runtime/orca-runtime-files-search.test.ts": 25, + "src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts": 68, + "src/main/runtime/orca-runtime-files-ssh-rearm.test.ts": 60, + "src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts": 62, + "src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts": 99, + "src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts": 30, + "src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts": 22, + "src/main/runtime/orca-runtime-files-watch-host-scope.test.ts": 15, + "src/main/runtime/orca-runtime-files-watch.test.ts": 106, + "src/main/runtime/orca-runtime-git-branch-diff.test.ts": 9, + "src/main/runtime/orca-runtime-git-diff-budget.test.ts": 25, + "src/main/runtime/orca-runtime-git.test.ts": 31, + "src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts": 32, + "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts": 297, + "src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts": 17, + "src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts": 44, + "src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts": 191, + "src/main/runtime/orca-runtime-module-size.test.ts": 20, + "src/main/runtime/orca-runtime-path-candidate-history.test.ts": 16, + "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts": 27, + "src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts": 21, + "src/main/runtime/orca-runtime-skill-recovery.test.ts": 20, + "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts": 21, + "src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts": 20, + "src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts": 17, + "src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts": 19, + "src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts": 13, + "src/main/runtime/orca-runtime-structured-session-restore.test.ts": 59, + "src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts": 26, + "src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts": 302, + "src/main/runtime/orca-runtime-tab-id-collision.test.ts": 18, + "src/main/runtime/orca-runtime-tail-wait-memo.test.ts": 89, + "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts": 231, + "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts": 77, + "src/main/runtime/orca-runtime-terminal-cwd.test.ts": 62, + "src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts": 78, + "src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts": 54, + "src/main/runtime/orca-runtime-terminal-retirement.test.ts": 255, + "src/main/runtime/orca-runtime-terminal-split-authority.test.ts": 384, + "src/main/runtime/orca-runtime-test-fragment-coverage.test.ts": 5, + "src/main/runtime/orca-runtime.test.ts": 12320, + "src/main/runtime/orchestration-codex-completion-title.test.ts": 664, + "src/main/runtime/orchestration-compatibility-authority.test.ts": 42, + "src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts": 190, + "src/main/runtime/orchestration-mailbox-cold-park-idle.test.ts": 119, + "src/main/runtime/orchestration-mailbox-crash-recovery.test.ts": 155, + "src/main/runtime/orchestration-mailbox-detached-routing.test.ts": 534, + "src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts": 270, + "src/main/runtime/orchestration-mailbox-notification-consistency.test.ts": 1187, + "src/main/runtime/orchestration-mailbox-pointer-cli-command.test.ts": 107, + "src/main/runtime/orchestration-mailbox-pty-write-gate.test.ts": 162, + "src/main/runtime/orchestration-mailbox-routing-races.test.ts": 642, + "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts": 224, + "src/main/runtime/orchestration-message-delivery-identity.test.ts": 244, + "src/main/runtime/orchestration-messages-fake-parity.test.ts": 55, + "src/main/runtime/orchestration-structured-chat-lease.test.ts": 8461, + "src/main/runtime/orchestration-worker-workspace-resolution.test.ts": 57, + "src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts": 13, + "src/main/runtime/orchestration/cli-command.test.ts": 7, + "src/main/runtime/orchestration/coordinator-decision-gates.test.ts": 62, + "src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts": 59, + "src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts": 186, + "src/main/runtime/orchestration/coordinator-escalation-triage.test.ts": 28, + "src/main/runtime/orchestration/coordinator-stale-base-flag.test.ts": 9, + "src/main/runtime/orchestration/coordinator.test.ts": 3333, + "src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts": 64, + "src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts": 31, + "src/main/runtime/orchestration/db-message-timestamp.test.ts": 18, + "src/main/runtime/orchestration/db-messages.test.ts": 115, + "src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts": 97, + "src/main/runtime/orchestration/db-task-create-readiness.test.ts": 174, + "src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts": 521, + "src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts": 716, + "src/main/runtime/orchestration/db-task-dispatch-races.test.ts": 172, + "src/main/runtime/orchestration/db-undelivered-mailboxes.test.ts": 44, + "src/main/runtime/orchestration/db.test.ts": 836, + "src/main/runtime/orchestration/db/attempt-outcome-projection.test.ts": 285, + "src/main/runtime/orchestration/db/decision-gate-lifecycle.test.ts": 46, + "src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.test.ts": 51, + "src/main/runtime/orchestration/db/dispatch-depth.test.ts": 320, + "src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts": 107, + "src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts": 587, + "src/main/runtime/orchestration/db/federated-worker-report-outcome.test.ts": 8, + "src/main/runtime/orchestration/db/federation/federated-dispatch-observation-fence.test.ts": 28, + "src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-release.test.ts": 75, + "src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts": 74, + "src/main/runtime/orchestration/db/legacy-question-identity.test.ts": 7, + "src/main/runtime/orchestration/db/lifecycle-rejection-marker.test.ts": 8, + "src/main/runtime/orchestration/db/lifecycle-transition-boundary.test.ts": 6, + "src/main/runtime/orchestration/db/lifecycle-transition.test.ts": 76, + "src/main/runtime/orchestration/db/pane-key-match.test.ts": 5, + "src/main/runtime/orchestration/db/row-column-lists.test.ts": 72, + "src/main/runtime/orchestration/db/run-list-cursor.test.ts": 5, + "src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts": 128, + "src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts": 60, + "src/main/runtime/orchestration/db/writer-run-required.test.ts": 54, + "src/main/runtime/orchestration/dispatch-consumer-generation-migration.test.ts": 143, + "src/main/runtime/orchestration/dispatch-creator-identity-migration.test.ts": 416, + "src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts": 48, + "src/main/runtime/orchestration/federation-ack-checkpoints.test.ts": 6, + "src/main/runtime/orchestration/federation-acknowledgment-integrity.test.ts": 74, + "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts": 67, + "src/main/runtime/orchestration/federation-lifecycle-settlement.test.ts": 4, + "src/main/runtime/orchestration/federation-sync.test.ts": 196, + "src/main/runtime/orchestration/federation-terminal-recovery.test.ts": 31, + "src/main/runtime/orchestration/formatter.test.ts": 10, + "src/main/runtime/orchestration/groups.test.ts": 8, + "src/main/runtime/orchestration/lifecycle-caller-edges.test.ts": 76, + "src/main/runtime/orchestration/lifecycle-reconciliation.test.ts": 237, + "src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts": 182, + "src/main/runtime/orchestration/mailbox-pointer-eligibility.test.ts": 67, + "src/main/runtime/orchestration/mailbox-pointer-release-query.test.ts": 120, + "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts": 129, + "src/main/runtime/orchestration/mailbox-pointer-submit.test.ts": 447, + "src/main/runtime/orchestration/message-batch-atomicity.test.ts": 107, + "src/main/runtime/orchestration/mutation-receipt-capacity.test.ts": 155, + "src/main/runtime/orchestration/nested-worker-depth-migration.test.ts": 233, + "src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts": 643, + "src/main/runtime/orchestration/orchestration-all-start-versions-migration.test.ts": 1759, + "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts": 517, + "src/main/runtime/orchestration/orchestration-db-permissions.test.ts": 37, + "src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts": 408, + "src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts": 309, + "src/main/runtime/orchestration/orchestration-legacy-coordinator-authority-db.test.ts": 371, + "src/main/runtime/orchestration/orchestration-legacy-question-migration-db.test.ts": 445, + "src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts": 1251, + "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts": 6, + "src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts": 75, + "src/main/runtime/orchestration/orchestration-peer-capability-cache.test.ts": 11, + "src/main/runtime/orchestration/orchestration-reset-db.test.ts": 84, + "src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts": 184, + "src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts": 32, + "src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts": 539, + "src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts": 352, + "src/main/runtime/orchestration/preamble.test.ts": 75, + "src/main/runtime/orchestration/r1-identity-migration.test.ts": 109, + "src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts": 148, + "src/main/runtime/orchestration/settled-question-threads-migration.test.ts": 51, + "src/main/runtime/orchestration/setup-completion-signal.test.ts": 10, + "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts": 44, + "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts": 14, + "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts": 13, + "src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts": 14, + "src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts": 9, + "src/main/runtime/orchestration/structured-worker-group-addressing.test.ts": 17, + "src/main/runtime/orchestration/structured-worker-journal-archive.test.ts": 32, + "src/main/runtime/orchestration/task-deps-flag.test.ts": 15, + "src/main/runtime/orchestration/worker-attention-context.test.ts": 10, + "src/main/runtime/orchestration/worker-output-archive-bounding.test.ts": 58, + "src/main/runtime/orchestration/worker-output-archive.test.ts": 18, + "src/main/runtime/orchestration/worker-output-cursor.test.ts": 8, + "src/main/runtime/orchestration/worker-provider-session.test.ts": 7, + "src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts": 63, + "src/main/runtime/orchestration/worker-transcript-payload.test.ts": 15, + "src/main/runtime/orchestration/worker-transcript-read.test.ts": 58, + "src/main/runtime/orchestration/worker-transcript-remote-read.test.ts": 60, + "src/main/runtime/pairing-endpoint.test.ts": 24, + "src/main/runtime/pty-exit-agent-status-reconciliation.test.ts": 35, + "src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts": 13, + "src/main/runtime/pty-inventory-liveness-verdict.test.ts": 58, + "src/main/runtime/pty-inventory-partial-relay-liveness.test.ts": 31, + "src/main/runtime/pty-shell-ownership-mirror.test.ts": 1507, + "src/main/runtime/pty-transcript-prune-wait-cache.test.ts": 24, + "src/main/runtime/pty-waiver-source-invariant.test.ts": 9, + "src/main/runtime/public-ssh-state.test.ts": 7, + "src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts": 32, + "src/main/runtime/push/desktop-push-service.test.ts": 79, + "src/main/runtime/push/push-agent-state.test.ts": 9, + "src/main/runtime/push/push-cleanup-auth-expiry.test.ts": 50, + "src/main/runtime/push/push-delivery-policy.test.ts": 9, + "src/main/runtime/push/push-device-registration-persistence.test.ts": 20, + "src/main/runtime/push/push-dispatcher.test.ts": 13, + "src/main/runtime/push/push-gateway-client.test.ts": 149, + "src/main/runtime/push/push-gateway-session.test.ts": 136, + "src/main/runtime/push/push-host-proof-vector.test.ts": 19, + "src/main/runtime/push/push-host-proof.test.ts": 100, + "src/main/runtime/push/push-outcome-counters.test.ts": 6, + "src/main/runtime/push/push-policy-pipeline.integration.test.ts": 38, + "src/main/runtime/push/push-preferences.test.ts": 10, + "src/main/runtime/push/push-registration-races.test.ts": 65, + "src/main/runtime/push/push-registration-rpc.test.ts": 45, + "src/main/runtime/push/push-unpair-persistence.test.ts": 37, + "src/main/runtime/push/push-unregister-outbox.test.ts": 12, + "src/main/runtime/quarter-circle-title-send-authorization.test.ts": 16463, + "src/main/runtime/recent-pty-output-buffer.test.ts": 63, + "src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts": 10, + "src/main/runtime/relay/desktop-relay-service.test.ts": 12, + "src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts": 83, + "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts": 18, + "src/main/runtime/relay/relay-auth-coordinator.test.ts": 1169, + "src/main/runtime/relay/relay-auth-host-close-reason.test.ts": 67, + "src/main/runtime/relay/relay-control-client.test.ts": 287, + "src/main/runtime/relay/relay-control-close-reason.test.ts": 40, + "src/main/runtime/relay/relay-control-origin.test.ts": 22, + "src/main/runtime/relay/relay-control-request-retirement.test.ts": 23, + "src/main/runtime/relay/relay-demand-ledger.test.ts": 12, + "src/main/runtime/relay/relay-host-proof.test.ts": 40, + "src/main/runtime/relay/relay-http-client.test.ts": 86, + "src/main/runtime/relay/relay-region-correction.test.ts": 89, + "src/main/runtime/relay/relay-region-preference.test.ts": 90, + "src/main/runtime/relay/relay-region-probe-log.test.ts": 79, + "src/main/runtime/relay/relay-region-refresh.test.ts": 17, + "src/main/runtime/relay/relay-renewal-jitter.test.ts": 506, + "src/main/runtime/relay/relay-revoke-outbox.test.ts": 13, + "src/main/runtime/relay/relay-session-broker.test.ts": 405, + "src/main/runtime/remote-agent-session-host-authority.integration.test.ts": 138, + "src/main/runtime/remote-browser-screencast-frame-admission.test.ts": 47, + "src/main/runtime/remote-desktop-driver.test.ts": 99, + "src/main/runtime/remote-runtime-close-intent.integration.test.ts": 90, + "src/main/runtime/remote-runtime-request-connection.integration.test.ts": 260, + "src/main/runtime/remote-server-updater.test.ts": 9, + "src/main/runtime/renderer-browser-session-reconciliation.test.ts": 11, + "src/main/runtime/repo-icon-fork-backfill.test.ts": 16, + "src/main/runtime/repo-worktree-admin-fingerprint.test.ts": 835, + "src/main/runtime/repo-worktree-resolution-scan.test.ts": 4, + "src/main/runtime/repo-worktree-row-resolution.test.ts": 16, + "src/main/runtime/retained-tail-redraw-window.equivalence.test.ts": 329, + "src/main/runtime/rpc/core-typed-method-contract.test.ts": 9, + "src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts": 23, + "src/main/runtime/rpc/dispatcher-computer-errors.test.ts": 20, + "src/main/runtime/rpc/dispatcher-feature-interactions.test.ts": 17, + "src/main/runtime/rpc/dispatcher-request-parsing.test.ts": 18, + "src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts": 55, + "src/main/runtime/rpc/e2ee-channel-v2.test.ts": 81, + "src/main/runtime/rpc/e2ee-channel.test.ts": 196, + "src/main/runtime/rpc/e2ee-crypto.test.ts": 41, + "src/main/runtime/rpc/e2ee-integration.test.ts": 57, + "src/main/runtime/rpc/errors.test.ts": 22, + "src/main/runtime/rpc/methods/accounts.test.ts": 21, + "src/main/runtime/rpc/methods/agent-hooks.test.ts": 12, + "src/main/runtime/rpc/methods/agent-session.test.ts": 38, + "src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts": 25, + "src/main/runtime/rpc/methods/ai-vault.test.ts": 156, + "src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts": 28, + "src/main/runtime/rpc/methods/artifacts.test.ts": 280, + "src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts": 31, + "src/main/runtime/rpc/methods/automations.test.ts": 45, + "src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts": 236, + "src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts": 26, + "src/main/runtime/rpc/methods/browser-client-host.test.ts": 30, + "src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts": 20, + "src/main/runtime/rpc/methods/browser-network-tunnel.test.ts": 233, + "src/main/runtime/rpc/methods/browser-tab-create-schema.test.ts": 12, + "src/main/runtime/rpc/methods/browser.test.ts": 58, + "src/main/runtime/rpc/methods/client-events.test.ts": 7, + "src/main/runtime/rpc/methods/client-native-chat-settings.test.ts": 22, + "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts": 41, + "src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts": 26, + "src/main/runtime/rpc/methods/client-ui.test.ts": 96, + "src/main/runtime/rpc/methods/clipboard.test.ts": 44, + "src/main/runtime/rpc/methods/computer-actions.test.ts": 15, + "src/main/runtime/rpc/methods/computer.test.ts": 33, + "src/main/runtime/rpc/methods/diagnostics.test.ts": 8, + "src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts": 26, + "src/main/runtime/rpc/methods/files-doc-preview.test.ts": 13, + "src/main/runtime/rpc/methods/files-list-all-page-size.test.ts": 17, + "src/main/runtime/rpc/methods/files-path-search.test.ts": 150, + "src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts": 17, + "src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts": 20, + "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts": 68, + "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts": 64, + "src/main/runtime/rpc/methods/files.test.ts": 196, + "src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts": 50, + "src/main/runtime/rpc/methods/git.test.ts": 72, + "src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts": 14, + "src/main/runtime/rpc/methods/github.test.ts": 65, + "src/main/runtime/rpc/methods/gitlab.test.ts": 58, + "src/main/runtime/rpc/methods/host-capabilities.test.ts": 10, + "src/main/runtime/rpc/methods/hosted-review.test.ts": 25, + "src/main/runtime/rpc/methods/jira.test.ts": 34, + "src/main/runtime/rpc/methods/linear-agent-access.test.ts": 77, + "src/main/runtime/rpc/methods/linear-agent-project-access.test.ts": 708, + "src/main/runtime/rpc/methods/linear.test.ts": 47, + "src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts": 6, + "src/main/runtime/rpc/methods/native-chat.test.ts": 29, + "src/main/runtime/rpc/methods/notification-preferences.test.ts": 6, + "src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts": 10, + "src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts": 105, + "src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts": 69, + "src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts": 14, + "src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts": 88, + "src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts": 16, + "src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts": 13, + "src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts": 89, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts": 188, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts": 9, + "src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts": 8, + "src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts": 96, + "src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.test.ts": 5, + "src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.test.ts": 30, + "src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts": 68, + "src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts": 105, + "src/main/runtime/rpc/methods/orchestration/federation/federated-transport-safety.test.ts": 31, + "src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts": 50, + "src/main/runtime/rpc/methods/orchestration/federation/federation-agent-launch.test.ts": 60, + "src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts": 281, + "src/main/runtime/rpc/methods/orchestration/federation/federation-effects.test.ts": 4, + "src/main/runtime/rpc/methods/orchestration/federation/federation-folder-placement.test.ts": 35, + "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts": 1435, + "src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts": 279, + "src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts": 755, + "src/main/runtime/rpc/methods/orchestration/federation/federation-setup.test.ts": 129, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-prompt-budget.test.ts": 31, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts": 11, + "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts": 1549, + "src/main/runtime/rpc/methods/orchestration/gates/gate-run-authorization.test.ts": 676, + "src/main/runtime/rpc/methods/orchestration/gates/gates.test.ts": 164, + "src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts": 430, + "src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts": 136, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts": 242, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts": 453, + "src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts": 473, + "src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts": 238, + "src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts": 138, + "src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts": 387, + "src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts": 55, + "src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts": 64, + "src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts": 49, + "src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts": 570, + "src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts": 100, + "src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts": 156, + "src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts": 7, + "src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts": 329, + "src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts": 394, + "src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts": 1032, + "src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts": 364, + "src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts": 84, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts": 15, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts": 9, + "src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts": 154, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts": 133, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts": 105, + "src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts": 55, + "src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts": 10, + "src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts": 111, + "src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts": 32, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-pagination.test.ts": 215, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts": 81, + "src/main/runtime/rpc/methods/orchestration/worker/worker-observation.test.ts": 16, + "src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts": 36, + "src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts": 60, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts": 213, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts": 285, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-liveness-verdict.test.ts": 17, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts": 283, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts": 151, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts": 390, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts": 651, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts": 5, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-outcome-classification.test.ts": 8, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts": 70, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts": 355, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts": 106, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts": 10, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-capability.test.ts": 14, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-exit-race.test.ts": 109, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts": 136, + "src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-custody-at-creation.test.ts": 505, + "src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts": 735, + "src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts": 238, + "src/main/runtime/rpc/methods/paired-caller-host-id.test.ts": 8, + "src/main/runtime/rpc/methods/pairing.test.ts": 29, + "src/main/runtime/rpc/methods/plugins.test.ts": 13, + "src/main/runtime/rpc/methods/preflight.test.ts": 14, + "src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts": 16, + "src/main/runtime/rpc/methods/repo-badge-color.test.ts": 14, + "src/main/runtime/rpc/methods/repo.test.ts": 48, + "src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts": 14, + "src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts": 25, + "src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts": 14, + "src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts": 18, + "src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts": 14, + "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts": 239, + "src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts": 41, + "src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts": 41, + "src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts": 20, + "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts": 18, + "src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts": 13, + "src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts": 16, + "src/main/runtime/rpc/methods/session-tabs.test.ts": 37, + "src/main/runtime/rpc/methods/skills.test.ts": 26, + "src/main/runtime/rpc/methods/speech.test.ts": 20, + "src/main/runtime/rpc/methods/ssh.test.ts": 17, + "src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts": 47, + "src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts": 87, + "src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts": 24, + "src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts": 954, + "src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts": 6, + "src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts": 23, + "src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts": 20, + "src/main/runtime/rpc/methods/structured-agent-session.test.ts": 127, + "src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts": 10, + "src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts": 56, + "src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts": 37, + "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts": 14, + "src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts": 165, + "src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts": 40, + "src/main/runtime/rpc/methods/terminal-navigation.test.ts": 15, + "src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts": 10, + "src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts": 20, + "src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts": 7, + "src/main/runtime/rpc/methods/updater.test.ts": 7, + "src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts": 17, + "src/main/runtime/rpc/methods/workspace-ports.test.ts": 19, + "src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts": 17, + "src/main/runtime/rpc/methods/worktree-create-args.test.ts": 14, + "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts": 26, + "src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts": 20, + "src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts": 13, + "src/main/runtime/rpc/methods/worktree-retired-names.test.ts": 12, + "src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts": 26, + "src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts": 10, + "src/main/runtime/rpc/methods/worktree-schemas.test.ts": 16, + "src/main/runtime/rpc/methods/worktree.test.ts": 70, + "src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts": 419, + "src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts": 25, + "src/main/runtime/rpc/mobile-e2ee-outbound-memory-budget.test.ts": 7, + "src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts": 33, + "src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts": 46, + "src/main/runtime/rpc/mobile-socket-wiring.test.ts": 117, + "src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts": 1475, + "src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts": 326, + "src/main/runtime/rpc/orchestration-contract-fence.test.ts": 121, + "src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts": 657, + "src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts": 3011, + "src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts": 963, + "src/main/runtime/rpc/orchestration-legacy-fence-jurisdiction.test.ts": 3247, + "src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts": 293, + "src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts": 363, + "src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts": 152, + "src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts": 199, + "src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts": 498, + "src/main/runtime/rpc/orchestration-mutation-executor.test.ts": 186, + "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts": 304, + "src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts": 94, + "src/main/runtime/rpc/orchestration-mutation-request-show.test.ts": 158, + "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts": 293, + "src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts": 332, + "src/main/runtime/rpc/relay-transport.test.ts": 146, + "src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts": 23, + "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts": 9, + "src/main/runtime/rpc/runtime-client-capabilities.test.ts": 6, + "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts": 33, + "src/main/runtime/rpc/runtime-close-attribution.test.ts": 19, + "src/main/runtime/rpc/schemas.test.ts": 38, + "src/main/runtime/rpc/streaming.test.ts": 16, + "src/main/runtime/rpc/terminal-agent-prompt-send.test.ts": 20, + "src/main/runtime/rpc/terminal-agent-send-guard.test.ts": 39, + "src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts": 22, + "src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts": 168, + "src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts": 11, + "src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts": 563, + "src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts": 1720, + "src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts": 695, + "src/main/runtime/rpc/terminal-multiplex-end-verdict.test.ts": 328, + "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts": 27, + "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts": 22, + "src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts": 294, + "src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts": 809, + "src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts": 455, + "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts": 446, + "src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts": 16, + "src/main/runtime/rpc/terminal-multiplex-round-robin.test.ts": 6, + "src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts": 644, + "src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts": 799, + "src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts": 177, + "src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts": 42, + "src/main/runtime/rpc/terminal-output-batching.test.ts": 366, + "src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts": 14856, + "src/main/runtime/rpc/terminal-output-frame-source-ranges.test.ts": 11, + "src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts": 318, + "src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts": 220, + "src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts": 531, + "src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts": 27, + "src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts": 18, + "src/main/runtime/rpc/terminal-send.test.ts": 284, + "src/main/runtime/rpc/terminal-source-range-ledger.test.ts": 11, + "src/main/runtime/rpc/terminal-stream-byte-length.test.ts": 5718, + "src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts": 66, + "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts": 605, + "src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts": 66, + "src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts": 226, + "src/main/runtime/rpc/terminal-subscribe-ownership.test.ts": 226, + "src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts": 139, + "src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts": 178, + "src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts": 66, + "src/main/runtime/rpc/unix-socket-transport.test.ts": 8, + "src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts": 9, + "src/main/runtime/rpc/worktree-catalog-snapshot.test.ts": 8, + "src/main/runtime/rpc/ws-fallback-port-store.test.ts": 4, + "src/main/runtime/rpc/ws-transport-accept-order.test.ts": 17, + "src/main/runtime/rpc/ws-transport-static-web.test.ts": 6055, + "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts": 635, + "src/main/runtime/rpc/ws-transport.test.ts": 976, + "src/main/runtime/runtime-binary-message-router.test.ts": 4, + "src/main/runtime/runtime-browser-client-automation.test.ts": 19, + "src/main/runtime/runtime-browser-client-page-adoption.test.ts": 32, + "src/main/runtime/runtime-browser-client-page-creation.test.ts": 20, + "src/main/runtime/runtime-browser-client-page-recovery.test.ts": 28, + "src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts": 14, + "src/main/runtime/runtime-browser-network-execution-host.test.ts": 7, + "src/main/runtime/runtime-browser-page-registry.test.ts": 13, + "src/main/runtime/runtime-client-settings-minimax-projection.test.ts": 7, + "src/main/runtime/runtime-extraction-regressions.test.ts": 20, + "src/main/runtime/runtime-file-target-connection-field-ratchet.test.ts": 11, + "src/main/runtime/runtime-file-target-execution-host.test.ts": 33, + "src/main/runtime/runtime-folder-workspace.test.ts": 10, + "src/main/runtime/runtime-git-api-contract.test.ts": 7, + "src/main/runtime/runtime-git-branch-compare-admission.test.ts": 7, + "src/main/runtime/runtime-git-command-target.test.ts": 8, + "src/main/runtime/runtime-git-conflict-operation-routing.test.ts": 11, + "src/main/runtime/runtime-git-execution-host-ownership.test.ts": 10, + "src/main/runtime/runtime-git-generation-admission.test.ts": 11, + "src/main/runtime/runtime-git-status-admission.test.ts": 14, + "src/main/runtime/runtime-git-sync-commands.test.ts": 10, + "src/main/runtime/runtime-git-target-execution-host.test.ts": 33, + "src/main/runtime/runtime-graph-reload-lifecycle.test.ts": 10, + "src/main/runtime/runtime-hook-agent-row-selection.test.ts": 11, + "src/main/runtime/runtime-linear-read-commands.test.ts": 9, + "src/main/runtime/runtime-local-worktree-materialization.test.ts": 7, + "src/main/runtime/runtime-local-worktree-terminal-startup.test.ts": 6, + "src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts": 29, + "src/main/runtime/runtime-managed-worktree-metadata.test.ts": 7, + "src/main/runtime/runtime-managed-worktree-queries.test.ts": 22, + "src/main/runtime/runtime-metadata-ownership-watch.test.ts": 32, + "src/main/runtime/runtime-metadata.test.ts": 38, + "src/main/runtime/runtime-mobile-agent-status-builder.test.ts": 5, + "src/main/runtime/runtime-mobile-file-path-search.test.ts": 15, + "src/main/runtime/runtime-owned-terminal-publication-lineage.test.ts": 28, + "src/main/runtime/runtime-project-group-controller-folder-delete.test.ts": 7, + "src/main/runtime/runtime-project-host-setup-controller.test.ts": 16, + "src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts": 63, + "src/main/runtime/runtime-remove-project-host-scope.test.ts": 23, + "src/main/runtime/runtime-resolved-worktree-cache.test.ts": 6, + "src/main/runtime/runtime-rpc-browser-host-admission.test.ts": 31, + "src/main/runtime/runtime-rpc-device-revocation.test.ts": 99, + "src/main/runtime/runtime-rpc-long-poll-transport.test.ts": 1931, + "src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts": 29, + "src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts": 53, + "src/main/runtime/runtime-rpc-mobile-native-chat-settings.test.ts": 5, + "src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts": 575, + "src/main/runtime/runtime-rpc-orchestration-db-migration.test.ts": 56, + "src/main/runtime/runtime-rpc-pairing-mode-persistence.test.ts": 139, + "src/main/runtime/runtime-rpc-pairing-offer.test.ts": 71, + "src/main/runtime/runtime-rpc-relay-pairing.test.ts": 431, + "src/main/runtime/runtime-rpc-request-authorization.test.ts": 83, + "src/main/runtime/runtime-rpc-startup-failure.test.ts": 19, + "src/main/runtime/runtime-rpc-terminal-list.test.ts": 641, + "src/main/runtime/runtime-rpc-websocket-bind-host.test.ts": 104, + "src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts": 67, + "src/main/runtime/runtime-rpc-worktree-queries.test.ts": 73, + "src/main/runtime/runtime-search-line-fragments.test.ts": 9, + "src/main/runtime/runtime-skill-install-authority.test.ts": 4, + "src/main/runtime/runtime-skill-install-commands.test.ts": 8, + "src/main/runtime/runtime-skill-install-queries.test.ts": 15, + "src/main/runtime/runtime-socket-sweep.test.ts": 8, + "src/main/runtime/runtime-terminal-idle-polls.test.ts": 14, + "src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts": 17, + "src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts": 9, + "src/main/runtime/runtime-worktree-agent-rows-structured.test.ts": 13, + "src/main/runtime/runtime-worktree-agent-sources.test.ts": 8, + "src/main/runtime/runtime-worktree-agent-startup.test.ts": 11, + "src/main/runtime/runtime-worktree-ps-summaries.test.ts": 7, + "src/main/runtime/runtime-worktree-selection.test.ts": 8, + "src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts": 87, + "src/main/runtime/saved-structured-agent-session-restoration.test.ts": 5, + "src/main/runtime/selected-review-branch.test.ts": 11, + "src/main/runtime/session-tabs-inventory-publication.test.ts": 47, + "src/main/runtime/settled-worker-process-replacement.test.ts": 96, + "src/main/runtime/structured-agent-session-close.test.ts": 10, + "src/main/runtime/structured-agent-session-integration-replay.test.ts": 400, + "src/main/runtime/structured-agent-session-integration.test.ts": 816, + "src/main/runtime/structured-agent-session-pty-binding.test.ts": 16, + "src/main/runtime/structured-agent-session-rollback-compatibility.test.ts": 39, + "src/main/runtime/structured-agent-session-runtime-exit.test.ts": 348, + "src/main/runtime/structured-agent-session-runtime.test.ts": 33, + "src/main/runtime/structured-agent-session-support-probe.test.ts": 25, + "src/main/runtime/structured-claude-auth-policy-wiring.test.ts": 8, + "src/main/runtime/structured-conversation-tab-replacement.test.ts": 6, + "src/main/runtime/structured-session-worktree-teardown.test.ts": 2436, + "src/main/runtime/structured-tui-exit-proof.test.ts": 14, + "src/main/runtime/structured-tui-idle-evidence.test.ts": 5, + "src/main/runtime/structured-tui-process-identity.test.ts": 26, + "src/main/runtime/structured-tui-recovery-claim-match.test.ts": 13, + "src/main/runtime/structured-worker-agent-presence.test.ts": 6, + "src/main/runtime/structured-worker-authority.test.ts": 7, + "src/main/runtime/structured-worker-child-identity-env.test.ts": 6, + "src/main/runtime/structured-worker-hook-attestation.test.ts": 7, + "src/main/runtime/structured-worker-identity.test.ts": 17, + "src/main/runtime/structured-worker-mail-routing.test.ts": 9, + "src/main/runtime/structured-worker-takeover-pane-key.test.ts": 6, + "src/main/runtime/structured-worker-terminal-read.test.ts": 14, + "src/main/runtime/structured-worker-terminal-refusal.test.ts": 8, + "src/main/runtime/terminal-ansi-pending-retention.test.ts": 464, + "src/main/runtime/terminal-focus-navigation-coalescer.test.ts": 21, + "src/main/runtime/terminal-identity-probe.test.ts": 8, + "src/main/runtime/terminal-interactive-wait-visibility.test.ts": 6198, + "src/main/runtime/terminal-leaf-tab-resolution.test.ts": 6, + "src/main/runtime/terminal-list-execution-host-scope.test.ts": 3128, + "src/main/runtime/terminal-list-payload-size.test.ts": 172, + "src/main/runtime/terminal-list-stale-leaf-liveness.test.ts": 42, + "src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts": 26, + "src/main/runtime/terminal-model-query-authority.test.ts": 9, + "src/main/runtime/terminal-orphan-owner.test.ts": 7, + "src/main/runtime/terminal-orphan-topology.test.ts": 12, + "src/main/runtime/terminal-pane-recovery-liveness-gate.test.ts": 46, + "src/main/runtime/terminal-projection.test.ts": 6, + "src/main/runtime/terminal-pty-exit-waiter.test.ts": 28, + "src/main/runtime/terminal-query-responder.test.ts": 178, + "src/main/runtime/terminal-restore-record-seed.test.ts": 69, + "src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts": 23, + "src/main/runtime/terminal-retirement-proof-publication.test.ts": 77, + "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts": 180, + "src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts": 24, + "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts": 870, + "src/main/runtime/terminal-tail-buffer.test.ts": 5851, + "src/main/runtime/terminal-tail-row-retention.test.ts": 167, + "src/main/runtime/terminal-tail-sentinel-index.test.ts": 4042, + "src/main/runtime/terminal-tail-whitespace.test.ts": 11, + "src/main/runtime/terminal-vertical-control-scan.test.ts": 8, + "src/main/runtime/terminal-wait-detection.test.ts": 20, + "src/main/runtime/terminal-wait-results.test.ts": 6, + "src/main/runtime/wait-blocked-check-state.test.ts": 112, + "src/main/runtime/wait-blocked-keyword-carry-retention.test.ts": 12, + "src/main/runtime/windows-default-route-interfaces.test.ts": 9, + "src/main/runtime/windows-drive-listing.test.ts": 12, + "src/main/runtime/windows-firewall-remote-scope.test.ts": 19, + "src/main/runtime/windows-mobile-firewall.test.ts": 14, + "src/main/runtime/workspace-session-membership-scaling.test.ts": 13, + "src/main/runtime/worktree-launch-host-repo.test.ts": 10, + "src/main/runtime/worktree-list-host-scope.test.ts": 32, + "src/main/runtime/worktree-path-selector-wsl-posix.test.ts": 46, + "src/main/runtime/worktree-ps-degraded-repo-scan.test.ts": 94, + "src/main/runtime/worktree-ps-host-scope.test.ts": 40, + "src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts": 47, + "src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts": 531, + "src/main/runtime/worktree-scan-execution-host-routing.test.ts": 33, + "src/main/runtime/worktree-teardown-unstopped-pty.test.ts": 204, + "src/main/runtime/worktree-teardown.test.ts": 235, + "src/main/runtime/worktree-terminal-mutation-lock.test.ts": 13, + "src/main/serve-update-handoff.app-environment.test.ts": 907, + "src/main/serve-update-handoff.test.ts": 8, + "src/main/server/serve-readiness.test.ts": 10, + "src/main/server/serve-stdout-boundary.test.ts": 8, + "src/main/shell-prompt-readiness-probe.test.ts": 28, + "src/main/shell-startup-identity-scanner.test.ts": 6, + "src/main/shell-startup-output-scanner.test.ts": 24, + "src/main/shell-wrapper-content-address.test.ts": 9, + "src/main/shell-wrapper-generated-file-snapshot.test.ts": 40, + "src/main/skills/agent-skill-selection.test.ts": 22, + "src/main/skills/claude-plugin-skill-sources-wsl.test.ts": 6, + "src/main/skills/claude-plugin-skill-sources.test.ts": 7, + "src/main/skills/discovery.test.ts": 108, + "src/main/skills/skill-bundle-artifacts.test.ts": 39, + "src/main/skills/skill-bundle-creation.test.ts": 628, + "src/main/skills/skill-bundle-install-service.test.ts": 196, + "src/main/skills/skill-bundle-observability-summary.test.ts": 4, + "src/main/skills/skill-bundle-ssh-relay-service.test.ts": 105, + "src/main/skills/skill-candidate-concurrency.test.ts": 29, + "src/main/skills/skill-client-mediated-transfer-cancellation.test.ts": 20, + "src/main/skills/skill-client-mediated-transfer.test.ts": 29, + "src/main/skills/skill-cloud-direct-upload.test.ts": 125, + "src/main/skills/skill-cloud-grant-installation.test.ts": 15, + "src/main/skills/skill-cloud-install-target.test.ts": 6, + "src/main/skills/skill-cloud-request.test.ts": 58, + "src/main/skills/skill-cloud-service.test.ts": 73, + "src/main/skills/skill-delete/plan.test.ts": 102, + "src/main/skills/skill-delete/recovery.test.ts": 86, + "src/main/skills/skill-delete/service.test.ts": 618, + "src/main/skills/skill-delete/staging-visibility.test.ts": 21, + "src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts": 12, + "src/main/skills/skill-discovery-concurrency.test.ts": 88, + "src/main/skills/skill-discovery-order.test.ts": 286, + "src/main/skills/skill-discovery-target.test.ts": 21, + "src/main/skills/skill-discovery-wsl-plugins.test.ts": 22, + "src/main/skills/skill-discovery-wsl-script-roundtrip.test.ts": 49, + "src/main/skills/skill-discovery-wsl.test.ts": 40, + "src/main/skills/skill-freshness-eligibility.test.ts": 10, + "src/main/skills/skill-freshness-inventory-limits.test.ts": 6, + "src/main/skills/skill-freshness-inventory.test.ts": 2182, + "src/main/skills/skill-git-tree-identity.test.ts": 40, + "src/main/skills/skill-install-destinations.test.ts": 26, + "src/main/skills/skill-install-discovery-verification.test.ts": 17, + "src/main/skills/skill-install-lock-release.test.ts": 7, + "src/main/skills/skill-install-lock.test.ts": 241, + "src/main/skills/skill-install-management-service.test.ts": 39, + "src/main/skills/skill-install-provenance.test.ts": 19, + "src/main/skills/skill-install-recovery.test.ts": 304, + "src/main/skills/skill-install-request-service.test.ts": 299, + "src/main/skills/skill-install-service.test.ts": 944, + "src/main/skills/skill-install-transaction.test.ts": 949, + "src/main/skills/skill-operation-observability.test.ts": 92, + "src/main/skills/skill-package-creation.test.ts": 312, + "src/main/skills/skill-package-deterministic-gzip.test.ts": 527, + "src/main/skills/skill-package-download.test.ts": 107, + "src/main/skills/skill-package-identity.test.ts": 86, + "src/main/skills/skill-package-tar.test.ts": 259, + "src/main/skills/skill-placement-alias-repair.test.ts": 43, + "src/main/skills/skill-placement-copy-drift.test.ts": 38, + "src/main/skills/skill-placement-reconciliation.test.ts": 74, + "src/main/skills/skill-placement-transaction.test.ts": 97, + "src/main/skills/skill-plugin-cache-scan.test.ts": 599, + "src/main/skills/skill-provider-destinations.test.ts": 8, + "src/main/skills/skill-provider-runtime-roots.test.ts": 9, + "src/main/skills/skill-remote-error-category-parity.test.ts": 15, + "src/main/skills/skill-remote-install-cancellation.test.ts": 4, + "src/main/skills/skill-remote-install-service.test.ts": 32, + "src/main/skills/skill-remove-transaction.test.ts": 577, + "src/main/skills/skill-root-file-walk.test.ts": 27, + "src/main/skills/skill-runtime-capability.test.ts": 9, + "src/main/skills/skill-scan-coalescer.test.ts": 12, + "src/main/skills/skill-share-preparation-service.test.ts": 237, + "src/main/skills/skill-ssh-relay-service.test.ts": 61, + "src/main/skills/skill-transaction-startup-recovery.test.ts": 206, + "src/main/skills/skill-update-convergence.test.ts": 32, + "src/main/skills/skill-update-outcome.test.ts": 49, + "src/main/skills/skill-update-registration.test.ts": 14, + "src/main/skills/skill-update-run.test.ts": 28, + "src/main/skills/skill-upload-session-admission-regression.test.ts": 24, + "src/main/skills/skill-upload-session-service.test.ts": 157, + "src/main/skills/skill-wsl-install-filesystem.test.ts": 9, + "src/main/skills/skill-wsl-provider-detection.test.ts": 10, + "src/main/source-control/forge-provider.test.ts": 52, + "src/main/source-control/hosted-review-azure-devops.integration.test.ts": 153, + "src/main/source-control/hosted-review-base-ref-suffix.test.ts": 12, + "src/main/source-control/hosted-review-bitbucket.integration.test.ts": 368, + "src/main/source-control/hosted-review-branch-cache.test.ts": 186, + "src/main/source-control/hosted-review-creation-eligibility.test.ts": 26, + "src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts": 14, + "src/main/source-control/hosted-review-creation-shared-symlinks.test.ts": 17, + "src/main/source-control/hosted-review-creation.test.ts": 29, + "src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts": 5, + "src/main/source-control/hosted-review-execution-host-routing.test.ts": 35, + "src/main/source-control/hosted-review-gitea.integration.test.ts": 321, + "src/main/source-control/hosted-review.test.ts": 14, + "src/main/source-control/pull-request-linked-issue.test.ts": 10, + "src/main/source-control/repo-default-branch.test.ts": 18, + "src/main/source-control/stacked-hosted-review-creation.test.ts": 7, + "src/main/speech/model-catalog.test.ts": 5, + "src/main/speech/model-manager-download-error.test.ts": 17, + "src/main/speech/model-manager-download-resume.test.ts": 3020, + "src/main/speech/model-manager-progress-callback.test.ts": 9, + "src/main/speech/model-manager-stream-cleanup.test.ts": 30, + "src/main/speech/model-manager-windows-path.test.ts": 584, + "src/main/speech/model-manager.test.ts": 45, + "src/main/speech/openai-api-key-store.test.ts": 18, + "src/main/speech/openai-transcription-client.test.ts": 5, + "src/main/speech/speech-model-deletion.test.ts": 8, + "src/main/speech/speech-model-download-response.test.ts": 7, + "src/main/speech/stt-offline-audio-chunker.test.ts": 164, + "src/main/speech/stt-service.test.ts": 24, + "src/main/speech/stt-worker-model-config.test.ts": 8, + "src/main/speech/stt-worker.test.ts": 7, + "src/main/sqlite/sqlite-read-failure.test.ts": 16, + "src/main/sqlite/sync-database.test.ts": 1184, + "src/main/ssh-expired-lease-pane-readoption.test.ts": 58, + "src/main/ssh-reattach-pane-cardinality.test.ts": 181, + "src/main/ssh/orcad-activation-gate.test.ts": 8, + "src/main/ssh/orcad-activation-record.test.ts": 9, + "src/main/ssh/orcad-remote-deploy.test.ts": 63, + "src/main/ssh/orcad-remote-gc.test.ts": 11, + "src/main/ssh/orcad-remote-launch.test.ts": 15, + "src/main/ssh/orcad-remote-rollback.test.ts": 61, + "src/main/ssh/orcad-remote-shell-commands.integration.test.ts": 1254, + "src/main/ssh/orcad-state-snapshot.test.ts": 7, + "src/main/ssh/orcad-update-plan.test.ts": 10, + "src/main/ssh/relay-native-dependency-coverage.test.ts": 333, + "src/main/ssh/relay-protocol-backpressure.test.ts": 31, + "src/main/ssh/relay-protocol.test.ts": 79, + "src/main/ssh/relay-socket-path-limit-shell.integration.test.ts": 77, + "src/main/ssh/relay-socket-path-limit.test.ts": 17, + "src/main/ssh/remote-install-coexistence.test.ts": 22, + "src/main/ssh/remote-install-model.test.ts": 13, + "src/main/ssh/removed-ssh-target-tombstone-retention.test.ts": 7, + "src/main/ssh/sftp-namespace-resolution.test.ts": 36, + "src/main/ssh/sftp-stream-late-error.test.ts": 27, + "src/main/ssh/sftp-upload.test.ts": 139, + "src/main/ssh/ssh-agent-identity-filter.test.ts": 14, + "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts": 41, + "src/main/ssh/ssh-channel-multiplexer-saturation-wedge.test.ts": 14, + "src/main/ssh/ssh-channel-multiplexer-settlement.test.ts": 8, + "src/main/ssh/ssh-channel-multiplexer.test.ts": 52, + "src/main/ssh/ssh-config-alias-claim.test.ts": 12, + "src/main/ssh/ssh-config-host-picker.test.ts": 199, + "src/main/ssh/ssh-config-loader-regression.test.ts": 80, + "src/main/ssh/ssh-config-loader.test.ts": 77, + "src/main/ssh/ssh-config-parser-host-patterns.test.ts": 7, + "src/main/ssh/ssh-config-parser.test.ts": 262, + "src/main/ssh/ssh-config-resolver.test.ts": 12, + "src/main/ssh/ssh-connect-attempt-cancellation.test.ts": 7, + "src/main/ssh/ssh-connection-auth-fallback.test.ts": 125, + "src/main/ssh/ssh-connection-channel-open.test.ts": 746, + "src/main/ssh/ssh-connection-direct-startup.test.ts": 32, + "src/main/ssh/ssh-connection-file-transfer.test.ts": 17, + "src/main/ssh/ssh-connection-generation.test.ts": 9, + "src/main/ssh/ssh-connection-github-probe.test.ts": 16, + "src/main/ssh/ssh-connection-gssapi-fallback.test.ts": 127, + "src/main/ssh/ssh-connection-host-key-store-wiring.test.ts": 246, + "src/main/ssh/ssh-connection-host-key-verification.test.ts": 66, + "src/main/ssh/ssh-connection-manager-registry.test.ts": 67, + "src/main/ssh/ssh-connection-manager.test.ts": 9, + "src/main/ssh/ssh-connection-reconnect-ladder.test.ts": 335, + "src/main/ssh/ssh-connection-sftp-namespace.test.ts": 834, + "src/main/ssh/ssh-connection-sftp-wire.test.ts": 2413, + "src/main/ssh/ssh-connection-store.test.ts": 36, + "src/main/ssh/ssh-connection-system-transport.test.ts": 58, + "src/main/ssh/ssh-connection-utils.test.ts": 83, + "src/main/ssh/ssh-connection.test.ts": 72, + "src/main/ssh/ssh-control-socket.test.ts": 15, + "src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts": 17, + "src/main/ssh/ssh-file-transfer-abort.test.ts": 16, + "src/main/ssh/ssh-g-config-resolution.test.ts": 67, + "src/main/ssh/ssh-git-response-stream-reader.test.ts": 11, + "src/main/ssh/ssh-git-stream-idle-timer.test.ts": 52, + "src/main/ssh/ssh-host-key-decision.test.ts": 19, + "src/main/ssh/ssh-host-key-store.test.ts": 134, + "src/main/ssh/ssh-host-key-verifier.test.ts": 21, + "src/main/ssh/ssh-known-hosts-source.test.ts": 57, + "src/main/ssh/ssh-known-hosts.test.ts": 23, + "src/main/ssh/ssh-multi-factor-authentication.test.ts": 1247, + "src/main/ssh/ssh-multi-key-authentication.test.ts": 30, + "src/main/ssh/ssh-multiplexer-transport-writer.test.ts": 26, + "src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts": 21, + "src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts": 21, + "src/main/ssh/ssh-owner-recovery-retry.test.ts": 15, + "src/main/ssh/ssh-pending-pty-kill-replay.test.ts": 13, + "src/main/ssh/ssh-port-forward.test.ts": 25, + "src/main/ssh/ssh-port-scanner.test.ts": 31, + "src/main/ssh/ssh-posix-command-wrapper.test.ts": 27, + "src/main/ssh/ssh-provider-authority.test.ts": 18, + "src/main/ssh/ssh-proxy-command.test.ts": 15, + "src/main/ssh/ssh-pty-consumer-recovery.test.ts": 10, + "src/main/ssh/ssh-pty-consumer-session.test.ts": 13, + "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts": 4, + "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts": 11, + "src/main/ssh/ssh-reconnect-error-classification.test.ts": 10, + "src/main/ssh/ssh-reconnect-ladder.test.ts": 8, + "src/main/ssh/ssh-relay-build-toolchain.test.ts": 12, + "src/main/ssh/ssh-relay-cross-version-isolation.test.ts": 72, + "src/main/ssh/ssh-relay-deploy-helpers.test.ts": 296, + "src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts": 16, + "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts": 166, + "src/main/ssh/ssh-relay-deploy.test.ts": 209, + "src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts": 2169, + "src/main/ssh/ssh-relay-endpoint-incumbent.test.ts": 15, + "src/main/ssh/ssh-relay-endpoint-takeover.test.ts": 15, + "src/main/ssh/ssh-relay-gc-retry.test.ts": 82, + "src/main/ssh/ssh-relay-install-lock.test.ts": 4, + "src/main/ssh/ssh-relay-install-namespace.test.ts": 16, + "src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts": 1273, + "src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts": 323, + "src/main/ssh/ssh-relay-native-deps-cache.test.ts": 24, + "src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts": 659, + "src/main/ssh/ssh-relay-native-deps-install.test.ts": 13644, + "src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts": 254, + "src/main/ssh/ssh-relay-node-headers.test.ts": 2346, + "src/main/ssh/ssh-relay-node-pty-repair.test.ts": 15, + "src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts": 25, + "src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts": 117, + "src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts": 1911, + "src/main/ssh/ssh-relay-reset.test.ts": 27, + "src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts": 21, + "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts": 596, + "src/main/ssh/ssh-relay-session-data-delivery.test.ts": 55, + "src/main/ssh/ssh-relay-session-incarnation.test.ts": 19, + "src/main/ssh/ssh-relay-session-managed-hooks.test.ts": 14, + "src/main/ssh/ssh-relay-session-model-migration.test.ts": 170, + "src/main/ssh/ssh-relay-session-orphan-sweep.test.ts": 43, + "src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts": 14, + "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts": 120, + "src/main/ssh/ssh-relay-session-recovery-durability.test.ts": 1066, + "src/main/ssh/ssh-relay-session-recovery-races.test.ts": 325, + "src/main/ssh/ssh-relay-session-rejected-delivery.test.ts": 793, + "src/main/ssh/ssh-relay-session-relay-loss.test.ts": 37, + "src/main/ssh/ssh-relay-session-terminal-error.test.ts": 29, + "src/main/ssh/ssh-relay-session.test.ts": 311, + "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts": 198, + "src/main/ssh/ssh-relay-superseded-endpoints.test.ts": 17, + "src/main/ssh/ssh-relay-upload-stage-commands.test.ts": 39445, + "src/main/ssh/ssh-relay-versioned-install.test.ts": 85, + "src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts": 16, + "src/main/ssh/ssh-remote-cli-format.test.ts": 7, + "src/main/ssh/ssh-remote-cli-host-passthrough.test.ts": 39, + "src/main/ssh/ssh-remote-cli-launcher.test.ts": 10, + "src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts": 20, + "src/main/ssh/ssh-remote-commands.test.ts": 11428, + "src/main/ssh/ssh-remote-linear-activity-output.test.ts": 4, + "src/main/ssh/ssh-remote-linear-cli.test.ts": 53, + "src/main/ssh/ssh-remote-linear-list-issues.test.ts": 14, + "src/main/ssh/ssh-remote-linear-relation-write.test.ts": 15, + "src/main/ssh/ssh-remote-linear-save-issue.test.ts": 24, + "src/main/ssh/ssh-remote-linear-truncation-output.test.ts": 7, + "src/main/ssh/ssh-remote-node-resolution.test.ts": 118, + "src/main/ssh/ssh-remote-node-toolchain-probe.test.ts": 8, + "src/main/ssh/ssh-remote-orca-cli.test.ts": 140, + "src/main/ssh/ssh-remote-orchestration-compatibility.test.ts": 289, + "src/main/ssh/ssh-remote-orchestration-send.test.ts": 8, + "src/main/ssh/ssh-remote-platform-detection.test.ts": 27, + "src/main/ssh/ssh-remote-platform.test.ts": 16, + "src/main/ssh/ssh-remote-powershell.test.ts": 72, + "src/main/ssh/ssh-remote-windows-command-line-limit.test.ts": 23, + "src/main/ssh/ssh-request-outcome-verdict.test.ts": 5, + "src/main/ssh/ssh-security-key-identity.test.ts": 41, + "src/main/ssh/ssh-session-limit-error.test.ts": 5, + "src/main/ssh/ssh-system-fallback.test.ts": 90, + "src/main/ssh/ssh-system-transport.integration.test.ts": 637, + "src/main/ssh/ssh-target-id-migration.test.ts": 6, + "src/main/ssh/ssh-target-readoption.test.ts": 10, + "src/main/ssh/system-ssh-binary.test.ts": 12, + "src/main/ssh/system-ssh-dynamic-forward-process.test.ts": 61, + "src/main/ssh/system-ssh-forward-process.test.ts": 40, + "src/main/ssh/system-ssh-sftp-args.test.ts": 8, + "src/main/ssh/system-ssh-sftp-path.test.ts": 11, + "src/main/ssh/system-ssh-windows-upload.test.ts": 112, + "src/main/ssh/system-ssh-windows-write-capabilities.test.ts": 8, + "src/main/ssh/vscode-ssh-authority.test.ts": 8, + "src/main/star-nag/service-direct-star.test.ts": 14, + "src/main/star-nag/service-force-show-races.test.ts": 17, + "src/main/star-nag/service-outcome-telemetry.test.ts": 11, + "src/main/star-nag/service-prompt-moments.test.ts": 18, + "src/main/star-nag/service-threshold-prompt.test.ts": 15, + "src/main/startup/bootstrap-fatal-exit-guard.test.ts": 6, + "src/main/startup/branch-rename-hook-structured-session.test.ts": 10, + "src/main/startup/cli-launch-redirect.test.ts": 11, + "src/main/startup/configure-process-dev-parent-shutdown.test.ts": 65, + "src/main/startup/configure-process.test.ts": 123, + "src/main/startup/desktop-startup-ordering.test.ts": 14, + "src/main/startup/dev-education-suppression.test.ts": 10, + "src/main/startup/dev-instance-identity.test.ts": 7, + "src/main/startup/ensure-virtual-display.test.ts": 5092, + "src/main/startup/first-window-startup-services.test.ts": 16, + "src/main/startup/gpu-fallback-marker.test.ts": 7, + "src/main/startup/gpu-fallback-switches.test.ts": 6, + "src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts": 55, + "src/main/startup/headless-pty-hydration-ordering.test.ts": 10, + "src/main/startup/host-port-bootstrap-wiring.test.ts": 11, + "src/main/startup/hydrate-shell-path.test.ts": 228, + "src/main/startup/hydrate-shell-path.windows.test.ts": 284, + "src/main/startup/legacy-worker-renderer-recovery.test.ts": 16, + "src/main/startup/login-shell-environment.test.ts": 67, + "src/main/startup/main-process-error-guards.test.ts": 29, + "src/main/startup/main-process-ready-phase-ordering.test.ts": 9, + "src/main/startup/main-window-structured-status-filter.test.ts": 7, + "src/main/startup/os-opened-markdown-delivery.test.ts": 10, + "src/main/startup/os-opened-markdown-files.test.ts": 116, + "src/main/startup/os-opened-markdown-wiring.test.ts": 5, + "src/main/startup/pre-gone-crash-sampling-wiring.test.ts": 5, + "src/main/startup/renderer-heap-headroom.test.ts": 10, + "src/main/startup/run-electron-vite-dev-web.test.ts": 432, + "src/main/startup/run-electron-vite-dev.test.ts": 943, + "src/main/startup/secret-protection-report-deferral-wiring.test.ts": 6, + "src/main/startup/secure-dns-census.test.ts": 407, + "src/main/startup/serve-desktop-activation-wiring.test.ts": 9, + "src/main/startup/serve-desktop-activation.test.ts": 5, + "src/main/startup/serve-mode-argv-cli-redirect-order.test.ts": 9, + "src/main/startup/serve-mode-argv.test.ts": 32, + "src/main/startup/serve-options.test.ts": 20, + "src/main/startup/serve-signal-handlers.test.ts": 4, + "src/main/startup/single-instance-lock-exit.electron.test.ts": 275, + "src/main/startup/single-instance-lock-headless-exit.test.ts": 11, + "src/main/startup/single-instance-lock.test.ts": 19, + "src/main/startup/skill-share-deep-link-state.test.ts": 8, + "src/main/startup/startup-diagnostics.test.ts": 7, + "src/main/startup/window-all-closed-quit-policy.test.ts": 5, + "src/main/startup/windows-desktop-shell-path-startup.test.ts": 8, + "src/main/startup/windows-install-dir-acl-probe.test.ts": 20, + "src/main/startup/windows-install-dir-acl-recovery.test.ts": 460, + "src/main/startup/windows-install-dir-acl-startup-wiring.test.ts": 6, + "src/main/startup/windows-install-dir-package-acl-repair.test.ts": 43, + "src/main/startup/windows-shell-path-hydration.test.ts": 315, + "src/main/startup/windows-shell-path-ownership.test.ts": 10, + "src/main/startup/windows-user-data-acl.test.ts": 15, + "src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts": 9, + "src/main/stats/agent-session-transition-recorder.test.ts": 23, + "src/main/stats/collector-async-save.test.ts": 375, + "src/main/stats/stats-title-detection-independence.test.ts": 6, + "src/main/synthetic-title-frame-routing.test.ts": 4, + "src/main/synthetic-title-spinner.test.ts": 4, + "src/main/synthetic-title-visibility.test.ts": 5, + "src/main/system-fonts.test.ts": 16, + "src/main/system-power-lifecycle.test.ts": 8, + "src/main/system-resume-broadcast.test.ts": 24, + "src/main/telemetry/burst-cap.test.ts": 27, + "src/main/telemetry/classify-error.test.ts": 7, + "src/main/telemetry/client-lifecycle.test.ts": 17, + "src/main/telemetry/client.test.ts": 42, + "src/main/telemetry/cohort-classifier.test.ts": 13, + "src/main/telemetry/consent.test.ts": 15, + "src/main/telemetry/install-id.test.ts": 7, + "src/main/telemetry/onboarding-cohort-classifier.test.ts": 12, + "src/main/telemetry/onboarding-feature-setup-validator.test.ts": 18, + "src/main/telemetry/validator-warn-cache.test.ts": 30, + "src/main/telemetry/validator.test.ts": 20, + "src/main/terminal-history-async-delete.test.ts": 289, + "src/main/terminal-history-gc-fs-call-count.test.ts": 54, + "src/main/terminal-history-gc.test.ts": 877, + "src/main/terminal-history-tombstone-retry.test.ts": 4798, + "src/main/terminal-history.test.ts": 34, + "src/main/text-generation/agent-failure-output.test.ts": 11, + "src/main/text-generation/commit-message-agent-environment.test.ts": 31, + "src/main/text-generation/commit-message-command-backslash-mode.test.ts": 6, + "src/main/text-generation/commit-message-text-generation-branch-name.test.ts": 16, + "src/main/text-generation/commit-message-text-generation-cancellation.test.ts": 439, + "src/main/text-generation/commit-message-text-generation-failure-sanitization.test.ts": 15, + "src/main/text-generation/commit-message-text-generation-generated-output.test.ts": 15, + "src/main/text-generation/commit-message-text-generation-linked-issue.test.ts": 14, + "src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts": 28, + "src/main/text-generation/commit-message-text-generation-model-discovery.test.ts": 48, + "src/main/text-generation/commit-message-text-generation-regression.test.ts": 10, + "src/main/text-generation/commit-message-text-generation-remote-execution.test.ts": 30, + "src/main/text-generation/commit-message-text-generation-settings.test.ts": 19, + "src/main/text-generation/pull-request-context-errors.test.ts": 25, + "src/main/text-generation/pull-request-context.test.ts": 30, + "src/main/tray/system-tray.test.ts": 92, + "src/main/tray/tray-attention-icon.test.ts": 13, + "src/main/tray/tray-dev-badge.test.ts": 11, + "src/main/update-install-exit-watchdog.test.ts": 12, + "src/main/updater-changelog.test.ts": 7, + "src/main/updater-events.test.ts": 204, + "src/main/updater-lifecycle-diagnostics.test.ts": 5, + "src/main/updater-linux-package-recovery-actions.test.ts": 2079, + "src/main/updater-nudge.test.ts": 12, + "src/main/updater-prerelease-feed-readiness.test.ts": 114, + "src/main/updater-prerelease-feed.test.ts": 148, + "src/main/updater-release-builds.test.ts": 30, + "src/main/updater-test-harness.leaked-timers.test.ts": 2596, + "src/main/updater-test-module-loader.test.ts": 508, + "src/main/updater.build-channel-selection.test.ts": 1674, + "src/main/updater.check-failure.test.ts": 1910, + "src/main/updater.check-preflight.test.ts": 2719, + "src/main/updater.check-settlement.test.ts": 2274, + "src/main/updater.fallback.test.ts": 22, + "src/main/updater.headless-serve-install.test.ts": 272, + "src/main/updater.install-failure-cause.test.ts": 2517, + "src/main/updater.linux-externally-managed.test.ts": 1400, + "src/main/updater.linux-root-package-install.test.ts": 1772, + "src/main/updater.nudge-campaign.test.ts": 1490, + "src/main/updater.prerelease-fallback.test.ts": 4396, + "src/main/updater.publishing-window-feed.test.ts": 3071, + "src/main/updater.quit-and-install.test.ts": 2199, + "src/main/updater.startup-scheduling.test.ts": 1573, + "src/main/usage-worktree-canonicalizer.test.ts": 10, + "src/main/usage-worktree-metadata.test.ts": 9, + "src/main/usage/highest-usage-key.test.ts": 14, + "src/main/usage/usage-breakdown-scaling.test.ts": 55, + "src/main/usage/usage-calendar-range.test.ts": 8, + "src/main/usage/usage-provider-store-lifecycle.test.ts": 293, + "src/main/usage/usage-worktree-refs.test.ts": 7, + "src/main/warp-themes/discovery.test.ts": 30, + "src/main/warp-themes/index.test.ts": 447, + "src/main/warp-themes/manual-warp-theme-files.test.ts": 36, + "src/main/warp-themes/parser-runner.test.ts": 9, + "src/main/warp-themes/parser.test.ts": 23, + "src/main/warp-themes/theme-file-scanner.test.ts": 22, + "src/main/win32-utils-async-acl.test.ts": 16, + "src/main/win32-utils.test.ts": 12, + "src/main/window/attach-main-window-services.test.ts": 195, + "src/main/window/clipboard-dashboard-popout-access.test.ts": 15, + "src/main/window/clipboard-file-copy.test.ts": 11, + "src/main/window/clipboard-image-temp-file.test.ts": 8, + "src/main/window/clipboard-image-thumbnail.test.ts": 8, + "src/main/window/clipboard-ipc-handlers.test.ts": 94, + "src/main/window/clipboard-remote-file-copy.test.ts": 18, + "src/main/window/clipboard-remote-file-staging-fs.test.ts": 23, + "src/main/window/clipboard-remote-file-staging.test.ts": 37, + "src/main/window/clipboard-runtime-owned-ssh-paste.test.ts": 15, + "src/main/window/clipboard-text-write-verify.test.ts": 8, + "src/main/window/clipboard-windows-image-file.test.ts": 17, + "src/main/window/createMainWindow-close-confirmation.test.ts": 29, + "src/main/window/createMainWindow-markdown-editor-focus.test.ts": 21, + "src/main/window/createMainWindow-recovery-reload-watchdog.test.ts": 48, + "src/main/window/createMainWindow-renderer-crash-recovery.test.ts": 51, + "src/main/window/createMainWindow-startup-reveal.test.ts": 35, + "src/main/window/createMainWindow-system-resume-relay.test.ts": 11, + "src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts": 46, + "src/main/window/createMainWindow-tray-minimize-close.test.ts": 31, + "src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts": 17, + "src/main/window/createMainWindow.test.ts": 35, + "src/main/window/dashboard-popout-window.test.ts": 26, + "src/main/window/editable-context-menu.test.ts": 20, + "src/main/window/focus-existing-window.test.ts": 10, + "src/main/window/foreground-activation-policy.test.ts": 15, + "src/main/window/history-gc-profile-worktree-ids.test.ts": 13, + "src/main/window/history-gc-worktree-ids.test.ts": 8, + "src/main/window/macos-app-activation.test.ts": 5, + "src/main/window/macos-tahoe-release.test.ts": 6, + "src/main/window/main-window-visibility.test.ts": 5, + "src/main/window/main-window-webview-security.test.ts": 12, + "src/main/window/mobile-markdown-request-relay.test.ts": 26, + "src/main/window/privileged-window-navigation.test.ts": 14, + "src/main/window/renderer-document-navigation.test.ts": 9, + "src/main/window/renderer-publication-throttle.test.ts": 5, + "src/main/window/renderer-recovery-prompt.test.ts": 21, + "src/main/window/renderer-recovery-reload-watchdog.test.ts": 16, + "src/main/window/runtime-renderer-notification-sender.test.ts": 9, + "src/main/window/session-tab-close-request-relay.test.ts": 40, + "src/main/window/terminal-tab-close-request-relay.test.ts": 24, + "src/main/window/updater-package-recovery-ipc.test.ts": 14, + "src/main/window/window-close-decision.test.ts": 4, + "src/main/windows-descendant-exit-verification.test.ts": 17, + "src/main/windows-process-tree-kill.test.ts": 7, + "src/main/windows-pty-root-identity.test.ts": 13, + "src/main/windows/windows-command-line-recovery-health.test.ts": 10, + "src/main/windows/windows-process-table-cim-scan.test.ts": 6, + "src/main/windows/windows-process-table.test.ts": 64, + "src/main/windows/windows-process-tree-command-line-patch.test.ts": 11, + "src/main/windows/windows-pty-job.test.ts": 14, + "src/main/workspace-cleanup-removal-snapshot-prune.test.ts": 13, + "src/main/workspace-cleanup-scan-snapshot.test.ts": 88, + "src/main/workspace-space-analysis-capacity.test.ts": 38, + "src/main/workspace-space-analysis-du-timeout.test.ts": 236, + "src/main/workspace-space-analysis-snapshot.test.ts": 66, + "src/main/workspace-space-analysis.test.ts": 227, + "src/main/workspace-space-repo-scan.test.ts": 5, + "src/main/workspace-space-scan-control.test.ts": 36, + "src/main/worktree-create-base-prefetch.test.ts": 225, + "src/main/worktree-create-base.test.ts": 5, + "src/main/worktree-create-candidates.test.ts": 5, + "src/main/worktree-create-execution-host-route.test.ts": 10, + "src/main/worktree-create-preparation-cancellation.test.ts": 29, + "src/main/worktree-create-preparation-claim.test.ts": 8, + "src/main/worktree-create-preparation-wsl-root.test.ts": 13, + "src/main/worktree-create-preparation.test.ts": 110, + "src/main/worktree-create-timing.test.ts": 7, + "src/main/worktree-identity-persistence.test.ts": 207, + "src/main/worktree-lineage-pruning.test.ts": 12, + "src/main/worktree-metadata-ownership.test.ts": 4, + "src/main/worktree-name-retirement.test.ts": 18, + "src/main/worktree-removal-authority.test.ts": 10, + "src/main/worktree-removal-execution-host-route.test.ts": 6, + "src/main/worktree-removal-repo-owner.test.ts": 7, + "src/main/worktree-removal-safety.test.ts": 18, + "src/main/worktree-removal-session-partition-fencing.test.ts": 1223, + "src/main/worktree-retirement-backfill-scan.test.ts": 20, + "src/main/worktree-retirement-backfill-stall.test.ts": 15, + "src/main/worktree-retirement-discovery-wsl.test.ts": 32, + "src/main/worktree-retirement-discovery.test.ts": 37, + "src/main/worktree-retirement-namespace.test.ts": 28, + "src/main/worktree-root-preparation.test.ts": 11, + "src/main/worktree-trash.test.ts": 53, + "src/main/wsl-availability-missing-kernel.test.ts": 10, + "src/main/wsl-bash-command.test.ts": 4, + "src/main/wsl-distro-list-output.test.ts": 8, + "src/main/wsl-distro-list-single-flight.test.ts": 14, + "src/main/wsl-fish-history-cleanup.test.ts": 15, + "src/main/wsl-interop-spawn-directory.test.ts": 10, + "src/main/wsl-running-distros.test.ts": 125, + "src/main/wsl-unc-delete-symlink-repro.test.ts": 7, + "src/main/wsl-unc-delete.test.ts": 12, + "src/main/wsl.test.ts": 35, + "src/main/wsl/wsl-guest-environment.test.ts": 52, + "src/main/wsl/wsl-invocation-boundary.test.ts": 6, + "src/main/wsl/wsl-probe-failure-semantics.test.ts": 587, + "src/main/wsl/wsl-runner.test.ts": 32, + "src/main/wsl/wsl-w1-w3-contract.test.ts": 26, + "src/preload/api/platform-bridge.test.ts": 12, + "src/preload/app-restart-checkpoint-routing.test.ts": 651, + "src/preload/browser-client-page-renderer-requests.test.ts": 22, + "src/preload/browser-find-subscriptions.test.ts": 11, + "src/preload/browser-window-close.test.ts": 6, + "src/preload/close-active-tab-payload-admission.test.ts": 5, + "src/preload/doc-preview-link-interception.test.ts": 29, + "src/preload/pty-snapshot-capability-ipc.test.ts": 652, + "src/preload/renderer-heap-statistics-reader.test.ts": 5, + "src/preload/renderer-process-memory-reader.test.ts": 7, + "src/preload/renderer-restart-wiring.test.ts": 13, + "src/preload/runtime-environment-subscriptions.test.ts": 11, + "src/preload/ssh-authority-forwarding.test.ts": 682, + "src/preload/updater-package-recovery.test.ts": 273, + "src/preload/usage-provider-api.test.ts": 9, + "src/relay/agent-exec-handler-windows.test.ts": 11, + "src/relay/agent-exec-handler.test.ts": 28, + "src/relay/agent-hook-envelope-publication.test.ts": 215, + "src/relay/agent-hook-integration.test.ts": 116, + "src/relay/agent-hook-retired-pane-suppression.test.ts": 125, + "src/relay/agent-hook-server-codex-subagent-transcript.test.ts": 1068, + "src/relay/agent-hook-server.test.ts": 1481, + "src/relay/ai-vault-handler.test.ts": 208, + "src/relay/ai-vault-service-client.test.ts": 28, + "src/relay/ai-vault-service-restart-policy.test.ts": 10, + "src/relay/ai-vault-service-spawn.test.ts": 9, + "src/relay/context.test.ts": 5, + "src/relay/dispatcher-capacity-degradation.test.ts": 112, + "src/relay/dispatcher-client-close-cause.test.ts": 15, + "src/relay/dispatcher-client-writer.test.ts": 13, + "src/relay/dispatcher-frame-guard-regressions.test.ts": 7, + "src/relay/dispatcher-json-payload.test.ts": 51, + "src/relay/dispatcher-notification-ownership.test.ts": 5, + "src/relay/dispatcher-silent-client-reaper.test.ts": 19, + "src/relay/dispatcher-structured-error.test.ts": 10, + "src/relay/dispatcher-timeout.test.ts": 19, + "src/relay/dispatcher-writer-admission.test.ts": 94, + "src/relay/dispatcher.test.ts": 198, + "src/relay/external-automation-provider-catalog.test.ts": 27, + "src/relay/external-automations-handler-log-path.test.ts": 24, + "src/relay/external-automations-handler.test.ts": 21, + "src/relay/fs-handler-doc-preview.test.ts": 16, + "src/relay/fs-handler-file-range-dispatch.test.ts": 79, + "src/relay/fs-handler-file-range.test.ts": 61, + "src/relay/fs-handler-git-search.test.ts": 7, + "src/relay/fs-handler-install-rg.test.ts": 9, + "src/relay/fs-handler-list-files-cancel.test.ts": 28, + "src/relay/fs-handler-list-files-ignored.test.ts": 648, + "src/relay/fs-handler-list-files-result-limit.test.ts": 12, + "src/relay/fs-handler-readdir-fallback.test.ts": 8, + "src/relay/fs-handler-ripgrep-fallback.test.ts": 19, + "src/relay/fs-handler-stream.test.ts": 475, + "src/relay/fs-handler.test.ts": 172, + "src/relay/fs-list-files-cancel.integration.test.ts": 26, + "src/relay/fs-list-files-large-response.integration.test.ts": 87, + "src/relay/fs-list-files-scan-coordinator.test.ts": 14, + "src/relay/fs-path-metadata-requests.test.ts": 10, + "src/relay/fs-path-metadata-symlink-concurrency.test.ts": 11, + "src/relay/fs-search-line-fragments.test.ts": 84, + "src/relay/fs-stream-pty-echo-backpressure.integration.test.ts": 135, + "src/relay/git-branch-delete-refusal-parity.test.ts": 10, + "src/relay/git-exec-validator.test.ts": 18, + "src/relay/git-handler-blob-readers.test.ts": 9, + "src/relay/git-handler-branch-cleanup.test.ts": 19, + "src/relay/git-handler-branch-compare.test.ts": 58, + "src/relay/git-handler-branch-diff-equivalence.test.ts": 908, + "src/relay/git-handler-branch-diff.test.ts": 174, + "src/relay/git-handler-check-ignore.test.ts": 8, + "src/relay/git-handler-diff-read-coalescing.test.ts": 103, + "src/relay/git-handler-file-diff.test.ts": 1278, + "src/relay/git-handler-fork-remote-exec.test.ts": 75, + "src/relay/git-handler-pull-reconciliation.test.ts": 712, + "src/relay/git-handler-push-target.test.ts": 16, + "src/relay/git-handler-remote-sync.test.ts": 1241, + "src/relay/git-handler-staging.test.ts": 402, + "src/relay/git-handler-status-ops.test.ts": 63, + "src/relay/git-handler-submodule-cache-invalidation.test.ts": 8, + "src/relay/git-handler-submodule-ops.test.ts": 47, + "src/relay/git-handler-submodule-status-cancellation.test.ts": 11, + "src/relay/git-handler-termination.test.ts": 9, + "src/relay/git-handler-utils.test.ts": 4, + "src/relay/git-handler-working-tree-changes.test.ts": 1001, + "src/relay/git-handler-worktree-clean.test.ts": 9, + "src/relay/git-handler-worktree-git-capabilities.test.ts": 12, + "src/relay/git-handler-worktree-inspection.test.ts": 819, + "src/relay/git-handler-worktree-list-authority.test.ts": 12, + "src/relay/git-handler-worktree-list.test.ts": 11, + "src/relay/git-handler-worktree-ops.test.ts": 27, + "src/relay/git-handler-worktree-paths.test.ts": 5, + "src/relay/git-handler-worktree-provisioning.test.ts": 723, + "src/relay/git-handler.test.ts": 638, + "src/relay/git-porcelain-local-parity.test.ts": 21, + "src/relay/git-push-target-local-parity.test.ts": 21, + "src/relay/git-response-pty-echo-backpressure.integration.test.ts": 751, + "src/relay/git-response-stream-ownership.test.ts": 18, + "src/relay/git-status-branch-line-total.test.ts": 197, + "src/relay/git-status-upstream-negative-cache.test.ts": 260, + "src/relay/git-stdout-stream.test.ts": 21, + "src/relay/git-working-file-read.test.ts": 27, + "src/relay/hermes-run-correlation.test.ts": 8, + "src/relay/hermes-run-history.test.ts": 13, + "src/relay/integration.test.ts": 1222, + "src/relay/legacy-relay-publication-ledger.test.ts": 7, + "src/relay/managed-hook-installer.test.ts": 13, + "src/relay/node-pty-binding-survey.test.ts": 132, + "src/relay/node-pty-unavailable-diagnosis.test.ts": 13, + "src/relay/plugin-overlay-env.test.ts": 14, + "src/relay/plugin-overlay.test.ts": 24, + "src/relay/plugin-source-limit.test.ts": 8, + "src/relay/port-scan-handler.test.ts": 70, + "src/relay/preflight-handler.test.ts": 18, + "src/relay/protocol-backpressure.test.ts": 51, + "src/relay/protocol-handshake.test.ts": 7, + "src/relay/protocol-json-payload.test.ts": 178, + "src/relay/pty-handler-attach-replay.test.ts": 49, + "src/relay/pty-handler-dispose-lifecycle.test.ts": 37, + "src/relay/pty-handler-grace-timer.test.ts": 19, + "src/relay/pty-handler-inventory-process-evidence.test.ts": 30, + "src/relay/pty-handler-output-drain-differential.test.ts": 135, + "src/relay/pty-handler-output-streaming.test.ts": 420, + "src/relay/pty-handler-ownership-attestation.test.ts": 87, + "src/relay/pty-handler-resize-stale-pty.test.ts": 30, + "src/relay/pty-handler-retired-pane-surface.test.ts": 82, + "src/relay/pty-handler-revive.test.ts": 91, + "src/relay/pty-handler-shell-resolution.test.ts": 33, + "src/relay/pty-handler-shutdown-signals.test.ts": 54, + "src/relay/pty-handler-source-publication.test.ts": 260, + "src/relay/pty-handler-spawn-admission.test.ts": 127, + "src/relay/pty-handler-spawn-cwd.test.ts": 46, + "src/relay/pty-handler-spawn-environment.test.ts": 108, + "src/relay/pty-handler-startup-command-delivery.test.ts": 111, + "src/relay/pty-handler-windows-child-process-evidence.test.ts": 35, + "src/relay/pty-replay-buffer-equivalence.test.ts": 77, + "src/relay/pty-shell-launch.test.ts": 312, + "src/relay/pty-shell-utils.test.ts": 58, + "src/relay/pty-source-credit-ledger.test.ts": 122, + "src/relay/pty-source-credit-scheduler.test.ts": 12, + "src/relay/pty-source-sent-boundaries.test.ts": 88, + "src/relay/relay-command-env.test.ts": 14, + "src/relay/relay-daemon-fatal-reap.test.ts": 540, + "src/relay/relay-diagnostic-log.test.ts": 7, + "src/relay/relay-endpoint-credential-publication.test.ts": 2634, + "src/relay/relay-filesystem-watch-registry.test.ts": 128, + "src/relay/relay-grace-branch.test.ts": 7, + "src/relay/relay-handshake-roundtrip.test.ts": 317, + "src/relay/relay-launch-options.test.ts": 12, + "src/relay/relay-oversized-notification-survival.test.ts": 67, + "src/relay/relay-pty-consumer-owner-displacement.test.ts": 19, + "src/relay/relay-pty-publication-admission.test.ts": 26, + "src/relay/relay-pty-source-cancellation-exit.test.ts": 54, + "src/relay/relay-pty-source-exit-publication.test.ts": 27, + "src/relay/relay-pty-source-publication.test.ts": 69, + "src/relay/relay-pty-source-recovery-completion.test.ts": 9, + "src/relay/relay-pty-source-recovery-interleavings.test.ts": 25, + "src/relay/relay-pty-source-recovery-window.test.ts": 33, + "src/relay/relay-pty-source-restore-retry.test.ts": 15, + "src/relay/relay-pty-source-send-scheduler.test.ts": 9, + "src/relay/relay-pty-source-superseded-activation.test.ts": 21, + "src/relay/relay-reconnect-listener-credential-gate.test.ts": 28, + "src/relay/relay-watch-root-capacity.test.ts": 15, + "src/relay/relay-watcher-event-emitter.test.ts": 842, + "src/relay/relay-watcher-frame-chunking.test.ts": 632, + "src/relay/relay-watcher-parent-removal.test.ts": 20, + "src/relay/relay-watcher-pending-setup-waiters.test.ts": 233, + "src/relay/relay-watcher-setup-wait.test.ts": 320, + "src/relay/remote-artifact-cli-input.test.ts": 124, + "src/relay/remote-cli-env.test.ts": 5, + "src/relay/remote-cli-stdin.test.ts": 7, + "src/relay/remote-cli-timeout.test.ts": 9, + "src/relay/retired-pane-surfaces.test.ts": 7, + "src/relay/rotating-log-writer.test.ts": 33, + "src/relay/skill-install-handler.test.ts": 306, + "src/relay/skill-upload-multi-relay.integration.test.ts": 549, + "src/relay/ssh-pty-consumer-session-adapter.test.ts": 48, + "src/relay/ssh-pty-source-credit-adapter.test.ts": 29, + "src/relay/subprocess-tree-termination.test.ts": 6, + "src/relay/subprocess.test.ts": 22239, + "src/relay/terminal-history-wsl.test.ts": 11, + "src/relay/terminal-history.test.ts": 14, + "src/relay/windows-port-scan.test.ts": 66, + "src/relay/workspace-session-handler.test.ts": 12, + "src/relay/workspace-snapshot-publication.test.ts": 29, + "src/relay/workspace-space-scan-du-capacity.test.ts": 34, + "src/relay/workspace-space-scan.test.ts": 37, + "src/relay/wsl-agent-hook-relay.test.ts": 45, + "src/relay/wsl-hook-fs-bridge.test.ts": 11, + "src/relay/wsl-install-plugins-handler.test.ts": 20, + "src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts": 13, + "src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts": 10, + "src/renderer/src/app-shell/app-root-surface-settings.test.tsx": 25, + "src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts": 6, + "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx": 92, + "src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts": 16, + "src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts": 20, + "src/renderer/src/app-shell/startup-actions-selector.test.ts": 56, + "src/renderer/src/app-shell/use-document-appearance.test.tsx": 33, + "src/renderer/src/app-shell/window-visibility-actions-selector.test.ts": 42, + "src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx": 120, + "src/renderer/src/app-startup-routing.test.ts": 13, + "src/renderer/src/assets/mobile-page-qr-layout.test.ts": 5, + "src/renderer/src/assets/rich-markdown-task-list-style.test.ts": 4, + "src/renderer/src/assets/terminal-container-geometry.test.ts": 7, + "src/renderer/src/assets/terminal-scrollbar-style.test.ts": 4, + "src/renderer/src/assets/worktree-card-active-style.test.ts": 4, + "src/renderer/src/components/AgentStateDot.test.ts": 38, + "src/renderer/src/components/AgentWorkingSpinner.test.tsx": 43, + "src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx": 343, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx": 141, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx": 229, + "src/renderer/src/components/NewWorkspaceComposerCard.test.tsx": 1044, + "src/renderer/src/components/QuickOpen.mount-gating.test.tsx": 647, + "src/renderer/src/components/SelectedTextCopyMenu.test.tsx": 49, + "src/renderer/src/components/StarNagCard.test.tsx": 60, + "src/renderer/src/components/StateIndicatorTooltip.test.tsx": 12, + "src/renderer/src/components/TerminalSearch.test.tsx": 106, + "src/renderer/src/components/TerminalTitlebarTabs.test.tsx": 28, + "src/renderer/src/components/TerminalWorkbenchContainer.test.tsx": 40, + "src/renderer/src/components/UpdateCard.error-card.test.tsx": 449, + "src/renderer/src/components/UpdateCard.test.ts": 60, + "src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx": 146, + "src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx": 865, + "src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx": 489, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx": 466, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx": 907, + "src/renderer/src/components/WorktreeJumpPalette.test.tsx": 423, + "src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts": 8, + "src/renderer/src/components/activity/ActivityPrototypePage.test.ts": 21, + "src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts": 31, + "src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx": 388, + "src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx": 950, + "src/renderer/src/components/activity/activity-clear-completed.test.ts": 28, + "src/renderer/src/components/activity/activity-event-builder-agent-context.test.ts": 13, + "src/renderer/src/components/activity/activity-event-builder.bounded-history.test.ts": 12, + "src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts": 17, + "src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts": 15, + "src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts": 14, + "src/renderer/src/components/activity/activity-portal-churn-budget.test.ts": 8, + "src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx": 33, + "src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx": 144, + "src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts": 5, + "src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx": 131, + "src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts": 9, + "src/renderer/src/components/activity/activity-scope-filter.test.ts": 9, + "src/renderer/src/components/activity/activity-terminal-portal-publication-loop.react185.test.tsx": 21, + "src/renderer/src/components/activity/activity-terminal-portal.test.tsx": 19, + "src/renderer/src/components/activity/activity-thread-actions.test.ts": 17, + "src/renderer/src/components/activity/activity-thread-child-agent.test.ts": 7, + "src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts": 8, + "src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts": 17, + "src/renderer/src/components/activity/activity-thread-hover-card.test.tsx": 228, + "src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx": 218, + "src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx": 852, + "src/renderer/src/components/activity/activity-thread-presentation.test.ts": 9, + "src/renderer/src/components/activity/activity-thread-virtual-items.test.ts": 12, + "src/renderer/src/components/activity/dev-activity-fixture.test.ts": 7, + "src/renderer/src/components/activity/event-time-clock-refresh.test.tsx": 40, + "src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx": 22, + "src/renderer/src/components/activity/useActivityUnreadCount.freshness.test.tsx": 42, + "src/renderer/src/components/activity/useActivityUnreadCount.test.ts": 7, + "src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx": 42, + "src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.test.ts": 5, + "src/renderer/src/components/agent/AgentCombobox.test.tsx": 855, + "src/renderer/src/components/agent/AgentSettingsDialog.test.tsx": 68, + "src/renderer/src/components/agent/agent-combobox-command-state.test.ts": 6, + "src/renderer/src/components/artifacts/ArtifactCollection.test.tsx": 439, + "src/renderer/src/components/artifacts/ArtifactPreview.test.tsx": 69, + "src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx": 272, + "src/renderer/src/components/artifacts/ArtifactsPage.test.tsx": 1134, + "src/renderer/src/components/artifacts/artifact-display-labels.test.ts": 30, + "src/renderer/src/components/artifacts/artifact-list-search.test.ts": 11, + "src/renderer/src/components/artifacts/artifact-publish-flow.test.ts": 62, + "src/renderer/src/components/artifacts/artifact-published-link-client.test.ts": 13, + "src/renderer/src/components/automations/AutomationDestinationField.test.tsx": 285, + "src/renderer/src/components/automations/AutomationDetail.host.test.tsx": 104, + "src/renderer/src/components/automations/AutomationDetail.test.tsx": 81, + "src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx": 35, + "src/renderer/src/components/automations/AutomationHostBadges.test.tsx": 191, + "src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx": 125, + "src/renderer/src/components/automations/AutomationListEmptyView.test.tsx": 53, + "src/renderer/src/components/automations/AutomationListHostGroups.test.tsx": 71, + "src/renderer/src/components/automations/AutomationListLocalRows.test.tsx": 298, + "src/renderer/src/components/automations/AutomationListSearchField.test.tsx": 74, + "src/renderer/src/components/automations/AutomationListTableHeader.test.tsx": 131, + "src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx": 60, + "src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx": 334, + "src/renderer/src/components/automations/AutomationRunHistory.test.tsx": 122, + "src/renderer/src/components/automations/AutomationRunsDashboardSurface.test.tsx": 23, + "src/renderer/src/components/automations/AutomationRunsTable.test.tsx": 60, + "src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx": 915, + "src/renderer/src/components/automations/AutomationSchedulePicker.test.ts": 839, + "src/renderer/src/components/automations/AutomationTimeField.test.tsx": 248, + "src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx": 115, + "src/renderer/src/components/automations/AutomationsDetailPane.test.tsx": 178, + "src/renderer/src/components/automations/AutomationsListPanel.test.tsx": 271, + "src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx": 412, + "src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx": 137, + "src/renderer/src/components/automations/AutomationsPage.escape-precedence.test.tsx": 232, + "src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx": 212, + "src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx": 198, + "src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx": 376, + "src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx": 133, + "src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx": 195, + "src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx": 233, + "src/renderer/src/components/automations/AutomationsPage.test.tsx": 644, + "src/renderer/src/components/automations/AutomationsPageBreadcrumb.test.tsx": 62, + "src/renderer/src/components/automations/CreateFromPicker.test.tsx": 75, + "src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx": 247, + "src/renderer/src/components/automations/automation-authority-identity.test.ts": 8, + "src/renderer/src/components/automations/automation-captured-owner.test.ts": 8, + "src/renderer/src/components/automations/automation-create-destination.test.ts": 10, + "src/renderer/src/components/automations/automation-create-projects.test.ts": 6, + "src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts": 16, + "src/renderer/src/components/automations/automation-editor-prompt-options.test.ts": 6, + "src/renderer/src/components/automations/automation-external-target-match.test.ts": 8, + "src/renderer/src/components/automations/automation-host-cache-controller.test.ts": 21, + "src/renderer/src/components/automations/automation-host-cache-health.test.ts": 9, + "src/renderer/src/components/automations/automation-host-cache.test.ts": 18, + "src/renderer/src/components/automations/automation-host-catalog-generation.test.ts": 14, + "src/renderer/src/components/automations/automation-host-catalog-order.test.ts": 19, + "src/renderer/src/components/automations/automation-host-catalog-source.test.ts": 11, + "src/renderer/src/components/automations/automation-host-catalog.test.ts": 64, + "src/renderer/src/components/automations/automation-host-client.test.ts": 7, + "src/renderer/src/components/automations/automation-host-detail-display.test.ts": 4, + "src/renderer/src/components/automations/automation-host-diagnostics.test.ts": 28, + "src/renderer/src/components/automations/automation-host-filter-resolution.test.ts": 28, + "src/renderer/src/components/automations/automation-host-health.test.ts": 7, + "src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts": 12, + "src/renderer/src/components/automations/automation-host-invalidation.test.ts": 17, + "src/renderer/src/components/automations/automation-host-list-rows.test.ts": 8, + "src/renderer/src/components/automations/automation-host-orphan-entry.test.ts": 22, + "src/renderer/src/components/automations/automation-host-picker-groups.test.ts": 19, + "src/renderer/src/components/automations/automation-host-recovery.test.ts": 5, + "src/renderer/src/components/automations/automation-host-scale-gates.test.ts": 195, + "src/renderer/src/components/automations/automation-host-scheduler.test.ts": 18, + "src/renderer/src/components/automations/automation-host-status-descriptors.test.ts": 24, + "src/renderer/src/components/automations/automation-list-empty-state.test.ts": 18, + "src/renderer/src/components/automations/automation-list-focus-recovery.test.ts": 7, + "src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts": 8, + "src/renderer/src/components/automations/automation-list-last-run.test.ts": 26, + "src/renderer/src/components/automations/automation-list-row-identity.test.ts": 7, + "src/renderer/src/components/automations/automation-list-search-rows.test.ts": 12, + "src/renderer/src/components/automations/automation-list-search.test.ts": 12, + "src/renderer/src/components/automations/automation-list-view-sort.test.ts": 175, + "src/renderer/src/components/automations/automation-list-view.test.ts": 15, + "src/renderer/src/components/automations/automation-project-groups.test.ts": 8, + "src/renderer/src/components/automations/automation-run-completion-evidence.test.ts": 7, + "src/renderer/src/components/automations/automation-run-context.test.ts": 5, + "src/renderer/src/components/automations/automation-run-history-keyboard-navigation.test.ts": 14, + "src/renderer/src/components/automations/automation-run-open-target.test.ts": 10, + "src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts": 69, + "src/renderer/src/components/automations/automation-run-output-snapshot.test.ts": 47, + "src/renderer/src/components/automations/automation-run-view-state.test.ts": 11, + "src/renderer/src/components/automations/automation-runs-dashboard-model.test.ts": 6, + "src/renderer/src/components/automations/automation-scoped-list-client.test.ts": 76, + "src/renderer/src/components/automations/automation-setup-decision.test.ts": 8, + "src/renderer/src/components/automations/automation-source-display.test.ts": 10, + "src/renderer/src/components/automations/automation-target-availability.test.ts": 12, + "src/renderer/src/components/automations/automation-usage-model.test.ts": 6, + "src/renderer/src/components/automations/automation-write-invalidation.test.ts": 8, + "src/renderer/src/components/automations/external-automation-display.test.ts": 6, + "src/renderer/src/components/automations/external-automation-list-entries.test.ts": 7, + "src/renderer/src/components/automations/external-automation-run-table-state.test.ts": 9, + "src/renderer/src/components/automations/external-automation-scope-gating.test.ts": 11, + "src/renderer/src/components/automations/external-automation-scope-keys.test.ts": 7, + "src/renderer/src/components/automations/external-automation-source-availability.test.ts": 5, + "src/renderer/src/components/automations/hermes-cron-output-parse.test.ts": 27, + "src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx": 58, + "src/renderer/src/components/automations/use-automation-host-catalog.test.tsx": 56, + "src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx": 26, + "src/renderer/src/components/automations/use-automation-list-search.test.tsx": 33, + "src/renderer/src/components/automations/use-automation-runs-dashboard.test.tsx": 53, + "src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx": 19, + "src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx": 33, + "src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx": 99, + "src/renderer/src/components/browser-favicon.test.tsx": 55, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx": 391, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx": 346, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx": 295, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.drag-focus.test.tsx": 551, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.failure-remount.test.tsx": 274, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx": 111, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx": 370, + "src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx": 102, + "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx": 10, + "src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.test.ts": 7, + "src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts": 36, + "src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx": 326, + "src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts": 15, + "src/renderer/src/components/browser-pane/annotate/markup-screenshot-compose.test.ts": 8, + "src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts": 7, + "src/renderer/src/components/browser-pane/annotate/use-markup-draw-hint.test.ts": 17, + "src/renderer/src/components/browser-pane/annotate/useGrabMode.test.ts": 369, + "src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts": 26, + "src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts": 18, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx": 79, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserFind.test.tsx": 152, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.remote-link-routing.test.ts": 6, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts": 6, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts": 20, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx": 951, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-expansion.test.ts": 7, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts": 23, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx": 139, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts": 4, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.test.tsx": 234, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-import-hint-visibility.test.ts": 4, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx": 57, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-pane-page-selection.test.ts": 4, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx": 1919, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx": 93, + "src/renderer/src/components/browser-pane/assemble-chrome/context-menu-positioning.test.ts": 10, + "src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx": 175, + "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx": 118, + "src/renderer/src/components/browser-pane/browser-client-page-metadata-publisher.test.ts": 262, + "src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts": 6, + "src/renderer/src/components/browser-pane/browser-client-page-position-driver.test.ts": 47, + "src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts": 12, + "src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts": 57, + "src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts": 52, + "src/renderer/src/components/browser-pane/browser-download-destination-toast.test.ts": 7, + "src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts": 13, + "src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts": 8, + "src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts": 11, + "src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts": 7, + "src/renderer/src/components/browser-pane/describe-page/browser-overlay-shortcut-target.test.ts": 7, + "src/renderer/src/components/browser-pane/describe-page/browser-page-url-display.test.ts": 15, + "src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts": 6, + "src/renderer/src/components/browser-pane/host-guest/browser-automation-visibility.test.ts": 16, + "src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts": 9, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts": 10, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts": 6, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.test.ts": 33, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts": 592, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.test.ts": 15, + "src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts": 20, + "src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts": 105, + "src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts": 12, + "src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts": 338, + "src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.test.ts": 5, + "src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts": 28, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts": 21, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts": 5, + "src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts": 16, + "src/renderer/src/components/browser-pane/host-guest/browser-system-resume.test.ts": 14, + "src/renderer/src/components/browser-pane/host-guest/browser-worktree-surface-paintability.test.ts": 6, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts": 21, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-viewport-scroll-reporting.test.tsx": 35, + "src/renderer/src/components/browser-pane/host-guest/use-webview-drag-passthrough-active.test.tsx": 35, + "src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts": 44, + "src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts": 12, + "src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts": 7, + "src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx": 150, + "src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts": 19, + "src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.test.ts": 16, + "src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts": 9, + "src/renderer/src/components/browser-pane/navigate/browser-webview-load-failure.test.ts": 11, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts": 40, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.test.ts": 5, + "src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx": 19, + "src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx": 308, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts": 7, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.test.ts": 4, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts": 8, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx": 293, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx": 434, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts": 10, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts": 61, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts": 12, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts": 6, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts": 37, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts": 54, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx": 2857, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx": 4032, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts": 28, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts": 8, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts": 67, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts": 14, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx": 29, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts": 29, + "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx": 41, + "src/renderer/src/components/browser-profile-user-agent-option.test.tsx": 82, + "src/renderer/src/components/browser-webauthn-account-dialog.test.tsx": 166, + "src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts": 7, + "src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx": 26, + "src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts": 15, + "src/renderer/src/components/cmd-j/palette-filter-options.test.ts": 23, + "src/renderer/src/components/cmd-j/palette-filter.test.ts": 12, + "src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts": 11, + "src/renderer/src/components/cmd-j/palette-host-badge.test.ts": 15, + "src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts": 6, + "src/renderer/src/components/cmd-j/palette-live-status.test.tsx": 183, + "src/renderer/src/components/cmd-j/palette-query-tokens.test.ts": 9, + "src/renderer/src/components/cmd-j/palette-results.test.ts": 42, + "src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts": 12, + "src/renderer/src/components/cmd-j/palette-session-age.test.ts": 16, + "src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts": 9, + "src/renderer/src/components/cmd-j/quick-action-context.test.ts": 20, + "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 27, + "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 5, + "src/renderer/src/components/codex-restart-chip.test.tsx": 120, + "src/renderer/src/components/codex-restart-notice-key.test.ts": 4, + "src/renderer/src/components/comment-code-context-state.test.ts": 5, + "src/renderer/src/components/comment-reply-target-state.test.ts": 6, + "src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts": 44, + "src/renderer/src/components/confirmation-dialog.test.tsx": 445, + "src/renderer/src/components/confirmation-skip-preference.test.ts": 109, + "src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts": 8, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx": 313, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx": 136, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx": 2421, + "src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx": 191, + "src/renderer/src/components/contextual-tours/contextual-tour-floating-position.test.ts": 1879, + "src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts": 14, + "src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts": 91, + "src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts": 6, + "src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts": 15, + "src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts": 8, + "src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts": 9, + "src/renderer/src/components/crash-report/CrashReportDialogSurface.overflow.test.tsx": 109, + "src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts": 13, + "src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 21, + "src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 469, + "src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 393, + "src/renderer/src/components/dashboard-popout/AgentMap.test.tsx": 1741, + "src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx": 75, + "src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.test.tsx": 126, + "src/renderer/src/components/dashboard-popout/AgentMapMotion.test.tsx": 189, + "src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx": 106, + "src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx": 289, + "src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx": 732, + "src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx": 1678, + "src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts": 7, + "src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx": 2066, + "src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 234, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 1314, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx": 149, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx": 1273, + "src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx": 64, + "src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.test.tsx": 26, + "src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts": 8, + "src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts": 9, + "src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts": 12, + "src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts": 7, + "src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts": 12, + "src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts": 152, + "src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts": 26, + "src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts": 9, + "src/renderer/src/components/dashboard-popout/agent-map-node-metadata.test.ts": 10, + "src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts": 4, + "src/renderer/src/components/dashboard-popout/agent-map-quick-views.test.ts": 11, + "src/renderer/src/components/dashboard-popout/agent-map-time-filter.test.ts": 6, + "src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts": 5, + "src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts": 8, + "src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts": 161, + "src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts": 1600, + "src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts": 9, + "src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts": 18, + "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts": 228, + "src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts": 11, + "src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts": 63, + "src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts": 13, + "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts": 11, + "src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts": 10, + "src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx": 36, + "src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 59, + "src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 227, + "src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx": 68, + "src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx": 97, + "src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts": 6, + "src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts": 5, + "src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts": 6, + "src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts": 7, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.allocation.test.ts": 97, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.cache.test.ts": 11, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts": 40, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts": 21, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts": 15, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts": 30, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts": 34, + "src/renderer/src/components/dashboard/dashboard-card-context.test.ts": 14, + "src/renderer/src/components/dashboard/dashboard-card-labels.test.ts": 5, + "src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts": 11, + "src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts": 19, + "src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts": 7, + "src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts": 10, + "src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts": 46, + "src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx": 35, + "src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx": 57, + "src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts": 63, + "src/renderer/src/components/dashboard/useRetainedAgents.test.ts": 66, + "src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts": 6, + "src/renderer/src/components/dictation/DictationIndicator.localization.test.ts": 7, + "src/renderer/src/components/dictation/DictationIndicator.test.tsx": 86, + "src/renderer/src/components/dictation/dictation-audio-meter.test.ts": 5, + "src/renderer/src/components/dictation/dictation-final-segments.test.ts": 5, + "src/renderer/src/components/dictation/dictation-insertion-target.test.ts": 288, + "src/renderer/src/components/dictation/dictation-meter-store.test.tsx": 15, + "src/renderer/src/components/dictation/dictation-stopped-sessions.test.ts": 9, + "src/renderer/src/components/dictation/microphone-devices.test.ts": 16, + "src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx": 43, + "src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx": 127, + "src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx": 59, + "src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts": 8, + "src/renderer/src/components/diff-comments/diff-comment-zone-mouse-events.test.ts": 5, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx": 43, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx": 28, + "src/renderer/src/components/editor/ChangesModeView.test.tsx": 27, + "src/renderer/src/components/editor/CheckRunCopyButton.test.tsx": 70, + "src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx": 116, + "src/renderer/src/components/editor/ConflictComponents.test.tsx": 4, + "src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx": 60, + "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx": 49, + "src/renderer/src/components/editor/EditorContent.test.tsx": 37, + "src/renderer/src/components/editor/EditorPanel.markdown-classification-memoization.test.tsx": 462, + "src/renderer/src/components/editor/EditorPanelHeader.test.tsx": 34, + "src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx": 21, + "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx": 18, + "src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx": 56, + "src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx": 186, + "src/renderer/src/components/editor/ImageViewer.test.tsx": 105, + "src/renderer/src/components/editor/LargeDiffLoadPrompt.test.tsx": 42, + "src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx": 106, + "src/renderer/src/components/editor/MarkdownPreview.test.ts": 9, + "src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx": 107, + "src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx": 23, + "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx": 56, + "src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx": 46, + "src/renderer/src/components/editor/NotesSendMenu.test.tsx": 34, + "src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx": 30, + "src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx": 104, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx": 106, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.test.ts": 3, + "src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx": 187, + "src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx": 14, + "src/renderer/src/components/editor/RichMarkdownTableControls.test.tsx": 272, + "src/renderer/src/components/editor/check-annotation-open.test.ts": 9, + "src/renderer/src/components/editor/check-annotation-path.test.ts": 10, + "src/renderer/src/components/editor/check-job-step-status.test.ts": 5, + "src/renderer/src/components/editor/check-run-clipboard-text.test.ts": 8, + "src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts": 16, + "src/renderer/src/components/editor/check-run-details-tab.test.ts": 7, + "src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts": 10, + "src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts": 48, + "src/renderer/src/components/editor/combined-diff-on-demand-load.test.ts": 9, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-collapsed-work.test.tsx": 96, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx": 241, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-windowing.test.tsx": 212, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts": 13, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.test.tsx": 44, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.test.ts": 17, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.test.ts": 5, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts": 7, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts": 11, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts": 10, + "src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.test.tsx": 29, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts": 12, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts": 5, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-scaling.test.tsx": 157, + "src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx": 23, + "src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.test.ts": 17, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 291, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 8, + "src/renderer/src/components/editor/csv-parse.test.ts": 38, + "src/renderer/src/components/editor/details-markdown-html.test.ts": 24, + "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 8, + "src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.test.ts": 14, + "src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts": 6, + "src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts": 4, + "src/renderer/src/components/editor/diff-line-stats.test.ts": 10, + "src/renderer/src/components/editor/diff-model-swap-view-state.test.ts": 8, + "src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts": 12, + "src/renderer/src/components/editor/diff-navigation-context.test.tsx": 45, + "src/renderer/src/components/editor/diff-section-layout.test.ts": 30, + "src/renderer/src/components/editor/diff-section-preview.test.ts": 9, + "src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts": 7, + "src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts": 37, + "src/renderer/src/components/editor/editor-autosave-controller.test.ts": 40, + "src/renderer/src/components/editor/editor-autosave.test.ts": 17, + "src/renderer/src/components/editor/editor-cmd-save-target.test.ts": 10, + "src/renderer/src/components/editor/editor-content-dirty-state.test.ts": 21, + "src/renderer/src/components/editor/editor-external-watch-path-index.test.ts": 9, + "src/renderer/src/components/editor/editor-file-save-attempt.test.ts": 11, + "src/renderer/src/components/editor/editor-header.test.ts": 8, + "src/renderer/src/components/editor/editor-labels.test.ts": 6, + "src/renderer/src/components/editor/editor-panel-diff-reload.test.ts": 8, + "src/renderer/src/components/editor/editor-panel-draft-selector.test.ts": 20, + "src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts": 24, + "src/renderer/src/components/editor/editor-panel-render-model.test.ts": 29, + "src/renderer/src/components/editor/editor-path-move-inflight.test.ts": 9, + "src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts": 38, + "src/renderer/src/components/editor/editor-self-write-registry.test.ts": 20, + "src/renderer/src/components/editor/editor-shortcuts.test.ts": 23, + "src/renderer/src/components/editor/export-active-markdown.test.ts": 7, + "src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts": 4, + "src/renderer/src/components/editor/image-viewer-zoom.test.ts": 10, + "src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts": 138, + "src/renderer/src/components/editor/ipynb-parse.test.ts": 26, + "src/renderer/src/components/editor/large-diff-render-limit.test.ts": 103, + "src/renderer/src/components/editor/line-copy-path.test.ts": 5, + "src/renderer/src/components/editor/local-log-tail-decoder.test.ts": 8, + "src/renderer/src/components/editor/markdown-artifact-upload.test.ts": 7, + "src/renderer/src/components/editor/markdown-dirty-state.test.ts": 154, + "src/renderer/src/components/editor/markdown-doc-completions.test.ts": 11, + "src/renderer/src/components/editor/markdown-doc-links.test.ts": 29, + "src/renderer/src/components/editor/markdown-document-list-request.test.ts": 14, + "src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts": 758, + "src/renderer/src/components/editor/markdown-export-extract.test.ts": 21, + "src/renderer/src/components/editor/markdown-export-html.test.ts": 5, + "src/renderer/src/components/editor/markdown-frontmatter.test.ts": 7, + "src/renderer/src/components/editor/markdown-heading-slug.test.ts": 6, + "src/renderer/src/components/editor/markdown-internal-links.test.ts": 10, + "src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts": 25, + "src/renderer/src/components/editor/markdown-preview-controls.test.ts": 10, + "src/renderer/src/components/editor/markdown-preview-links.test.ts": 8, + "src/renderer/src/components/editor/markdown-preview-local-images.test.ts": 67, + "src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx": 24, + "src/renderer/src/components/editor/markdown-preview-search.test.ts": 16, + "src/renderer/src/components/editor/markdown-preview-url-transform.test.ts": 14, + "src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts": 25, + "src/renderer/src/components/editor/markdown-render-mode.test.ts": 5, + "src/renderer/src/components/editor/markdown-rich-mode-eligibility-cache.test.ts": 225, + "src/renderer/src/components/editor/markdown-rich-mode.test.ts": 276, + "src/renderer/src/components/editor/markdown-rich-size-limit.test.ts": 7, + "src/renderer/src/components/editor/markdown-round-trip.test.ts": 388, + "src/renderer/src/components/editor/markdown-table-of-contents.test.ts": 57, + "src/renderer/src/components/editor/markdown-toc-collapse-state.test.ts": 5, + "src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts": 18, + "src/renderer/src/components/editor/mermaid-config.test.ts": 6, + "src/renderer/src/components/editor/monaco-auto-height.test.ts": 42, + "src/renderer/src/components/editor/monaco-codebase-search.test.ts": 8, + "src/renderer/src/components/editor/monaco-conflict-decorations.test.ts": 12, + "src/renderer/src/components/editor/monaco-content-sync.test.ts": 14, + "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts": 44, + "src/renderer/src/components/editor/monaco-context-menu-paste.test.ts": 59, + "src/renderer/src/components/editor/monaco-find-options.test.ts": 5, + "src/renderer/src/components/editor/monaco-find-widget.test.ts": 12, + "src/renderer/src/components/editor/monaco-large-text-paste.test.ts": 19, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts": 72, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts": 15, + "src/renderer/src/components/editor/monaco-markdown-selection-annotation.test.ts": 9, + "src/renderer/src/components/editor/monaco-programmatic-sync.test.ts": 5, + "src/renderer/src/components/editor/monaco-reveal-range.test.ts": 4, + "src/renderer/src/components/editor/monaco-view-state-persistence.test.ts": 5, + "src/renderer/src/components/editor/pdf-scale-preference.test.ts": 8, + "src/renderer/src/components/editor/pdf-view-position.test.ts": 19, + "src/renderer/src/components/editor/pending-editor-focus-request.test.ts": 6, + "src/renderer/src/components/editor/position-stable-node-view-update.test.ts": 7, + "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts": 321, + "src/renderer/src/components/editor/rich-markdown-auto-focus.test.ts": 12, + "src/renderer/src/components/editor/rich-markdown-code-block-languages.test.ts": 8, + "src/renderer/src/components/editor/rich-markdown-commands.test.ts": 95, + "src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts": 56, + "src/renderer/src/components/editor/rich-markdown-cut.test.ts": 142, + "src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts": 83, + "src/renderer/src/components/editor/rich-markdown-doc-link.test.ts": 7, + "src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts": 13, + "src/renderer/src/components/editor/rich-markdown-editor-config.test.ts": 15, + "src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts": 57, + "src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts": 581, + "src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts": 270, + "src/renderer/src/components/editor/rich-markdown-image-insert.test.ts": 54, + "src/renderer/src/components/editor/rich-markdown-inline-image-paragraph.test.ts": 90, + "src/renderer/src/components/editor/rich-markdown-key-handler.test.ts": 83, + "src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts": 17, + "src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts": 7, + "src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts": 96, + "src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts": 35, + "src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts": 11, + "src/renderer/src/components/editor/rich-markdown-local-image.test.ts": 128, + "src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts": 80, + "src/renderer/src/components/editor/rich-markdown-lowlight.test.ts": 2575, + "src/renderer/src/components/editor/rich-markdown-normalize.test.ts": 43, + "src/renderer/src/components/editor/rich-markdown-paragraph.test.ts": 9, + "src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts": 8, + "src/renderer/src/components/editor/rich-markdown-paste-image.test.ts": 11, + "src/renderer/src/components/editor/rich-markdown-range-bounds.test.ts": 66, + "src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts": 15, + "src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts": 6, + "src/renderer/src/components/editor/rich-markdown-review-rail-blocks.test.ts": 203, + "src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts": 26, + "src/renderer/src/components/editor/rich-markdown-search-matches-cache.test.ts": 38, + "src/renderer/src/components/editor/rich-markdown-search.test.ts": 10, + "src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts": 21, + "src/renderer/src/components/editor/rich-markdown-slash-command-filter.test.ts": 7, + "src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts": 332, + "src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts": 130, + "src/renderer/src/components/editor/rich-markdown-table-actions.test.ts": 475, + "src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts": 4, + "src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts": 105, + "src/renderer/src/components/editor/rich-markdown-terminal-path-paste.test.ts": 11, + "src/renderer/src/components/editor/rich-markdown-toc-heading-target.test.ts": 11, + "src/renderer/src/components/editor/selection-copy.test.ts": 8, + "src/renderer/src/components/editor/setup-contextual-copy.test.ts": 10, + "src/renderer/src/components/editor/untitled-file-rename-path.test.ts": 6, + "src/renderer/src/components/editor/use-monaco-editor-decorations.doc-link-refresh.test.tsx": 42, + "src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx": 86, + "src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx": 83, + "src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx": 16, + "src/renderer/src/components/editor/useEditorCmdSaveRequest.test.tsx": 34, + "src/renderer/src/components/editor/useEditorPanelContentState.test.tsx": 823, + "src/renderer/src/components/editor/useEditorPanelExternalContentEvents.test.tsx": 32, + "src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx": 53, + "src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx": 25, + "src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx": 151, + "src/renderer/src/components/editor/useIpynbCellExecution.test.tsx": 78, + "src/renderer/src/components/editor/useIpynbDocumentEditing.test.tsx": 29, + "src/renderer/src/components/editor/useLinkBubble.test.tsx": 37, + "src/renderer/src/components/editor/useLocalImageSrc.test.ts": 74, + "src/renderer/src/components/editor/useLocalLogTail.test.tsx": 234, + "src/renderer/src/components/editor/useMarkdownDocuments.test.ts": 9, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx": 71, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx": 23, + "src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts": 20, + "src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts": 77, + "src/renderer/src/components/editor/useRichMarkdownReviewController.open-guard.test.ts": 17, + "src/renderer/src/components/editor/useRichMarkdownSearch.reuse.test.tsx": 55, + "src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx": 31, + "src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx": 60, + "src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts": 10, + "src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx": 158, + "src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx": 188, + "src/renderer/src/components/emulator-pane/emulator-device-state.test.ts": 5, + "src/renderer/src/components/emulator-pane/emulator-keyboard-paste.test.ts": 16, + "src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts": 11, + "src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx": 43, + "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.test.ts": 6, + "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-visibility.test.ts": 4, + "src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx": 10, + "src/renderer/src/components/emulator-pane/mobile-emulator-tab-intro-visibility.test.ts": 7, + "src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx": 53, + "src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx": 43, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx": 86, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx": 51, + "src/renderer/src/components/error-boundaries/react-185-bystander-attribution.test.tsx": 100, + "src/renderer/src/components/feature-interaction-writer-boundaries.test.ts": 16, + "src/renderer/src/components/feature-tips/CliSkillSetupTerminal.test.tsx": 33, + "src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx": 71, + "src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.test.tsx": 35, + "src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx": 72, + "src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx": 44, + "src/renderer/src/components/feature-tips/feature-tip-cli-install-action.test.ts": 9, + "src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts": 10, + "src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts": 12, + "src/renderer/src/components/feature-tips/feature-tip-telemetry.test.ts": 8, + "src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts": 6, + "src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx": 181, + "src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx": 74, + "src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts": 113, + "src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx": 99, + "src/renderer/src/components/feature-wall/browser-animated-visual-sequence.test.ts": 6, + "src/renderer/src/components/feature-wall/feature-wall-animation-visibility.test.tsx": 163, + "src/renderer/src/components/feature-wall/feature-wall-rail-navigation.test.ts": 9, + "src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts": 9, + "src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts": 12, + "src/renderer/src/components/feature-wall/feature-wall-shortcut-labels.test.tsx": 72, + "src/renderer/src/components/feature-wall/feature-wall-usage-tracking.test.ts": 13, + "src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts": 8, + "src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts": 8, + "src/renderer/src/components/feature-wall/use-integration-connection-status.test.ts": 15, + "src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.test.ts": 9, + "src/renderer/src/components/file-path-cursor-tooltip.test.ts": 5, + "src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx": 35, + "src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx": 163, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx": 365, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx": 526, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx": 615, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx": 782, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx": 878, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx": 693, + "src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx": 84, + "src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx": 10, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts": 17, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts": 6, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-view-state.test.ts": 9, + "src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts": 17, + "src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts": 73, + "src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts": 11, + "src/renderer/src/components/github-body-draft-state.test.ts": 5, + "src/renderer/src/components/github-checks-tab-state.test.ts": 11, + "src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts": 6, + "src/renderer/src/components/github-item-dialog-source-boundary.test.ts": 13, + "src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx": 49, + "src/renderer/src/components/github-link-copy-state.test.ts": 6, + "src/renderer/src/components/github-pr-merge-state.test.ts": 25, + "src/renderer/src/components/github-pr-reviewer-display.test.ts": 11, + "src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts": 57, + "src/renderer/src/components/github-project/ProjectRoadmap.test.tsx": 257, + "src/renderer/src/components/github-project/github-project-picker-filter.test.ts": 10, + "src/renderer/src/components/github-project/group-sort.test.ts": 13, + "src/renderer/src/components/github-project/project-dialog-state.test.ts": 10, + "src/renderer/src/components/github-project/project-picker-browse-cache.test.ts": 14, + "src/renderer/src/components/github-project/project-picker-input.test.ts": 10, + "src/renderer/src/components/github-project/project-row-filtering.test.ts": 14, + "src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts": 11, + "src/renderer/src/components/github-project/project-visible-table-cache.test.ts": 6, + "src/renderer/src/components/github/PRFilterPickers.test.ts": 6, + "src/renderer/src/components/github/github-markdown-image-url.test.ts": 5, + "src/renderer/src/components/github/github-mention-option-filter.test.ts": 10, + "src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts": 18, + "src/renderer/src/components/github/github-user-avatar.test.tsx": 66, + "src/renderer/src/components/github/github-work-item-assignee-filter.test.ts": 10, + "src/renderer/src/components/github/github-work-item-label-filter.test.ts": 7, + "src/renderer/src/components/github/pr-comment-code-context.test.ts": 29, + "src/renderer/src/components/github/pr-file-content-size.test.ts": 5, + "src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts": 13, + "src/renderer/src/components/github/use-image-input.test.ts": 186, + "src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts": 12, + "src/renderer/src/components/jira-create-adf.test.ts": 10, + "src/renderer/src/components/jira-project-picker-filter.test.ts": 11, + "src/renderer/src/components/landing-preflight-dismissal.test.ts": 12, + "src/renderer/src/components/landing-preflight-issues.test.ts": 14, + "src/renderer/src/components/landing-preflight-runtime-boundary.test.ts": 59, + "src/renderer/src/components/linear-api-key-dialog-state.test.ts": 6, + "src/renderer/src/components/linear-issue-attribute-filter-coverage-agreement.test.ts": 1157, + "src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx": 1107, + "src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts": 21, + "src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts": 22, + "src/renderer/src/components/linear-issue-project-selector.test.tsx": 88, + "src/renderer/src/components/linear-issue-text-draft-state.test.ts": 4, + "src/renderer/src/components/linear-issue-text-save-plan.test.ts": 6, + "src/renderer/src/components/linear-issue-view-storage.test.ts": 11, + "src/renderer/src/components/linear-issue-workspace-api-parity.test.ts": 9, + "src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx": 47, + "src/renderer/src/components/linear-issue-workspace-header.test.ts": 5, + "src/renderer/src/components/linear-issue-workspace-text.test.ts": 7, + "src/renderer/src/components/linear-project-presentation.test.ts": 9, + "src/renderer/src/components/linear-project-search-query.test.ts": 5, + "src/renderer/src/components/linear-project-view-surfaces.test.tsx": 104, + "src/renderer/src/components/linear-scope-selector.test.ts": 8, + "src/renderer/src/components/link-actions/LinkActionPopover.test.tsx": 167, + "src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts": 13, + "src/renderer/src/components/mobile/MobileHero.test.tsx": 637, + "src/renderer/src/components/mobile/MobilePage.test.tsx": 685, + "src/renderer/src/components/mobile/MobilePageToolbar.test.tsx": 41, + "src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx": 77, + "src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx": 173, + "src/renderer/src/components/mobile/mobile-page-stage.test.ts": 4, + "src/renderer/src/components/mobile/paired-mobile-devices.test.ts": 5, + "src/renderer/src/components/mobile/use-mobile-page-paired-devices.test.ts": 85, + "src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx": 35, + "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx": 69, + "src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx": 142, + "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx": 164, + "src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx": 64, + "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx": 72, + "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx": 207, + "src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx": 20, + "src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx": 893, + "src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx": 515, + "src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx": 376, + "src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx": 2900, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx": 355, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx": 374, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx": 126, + "src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx": 3124, + "src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx": 180, + "src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx": 359, + "src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx": 192, + "src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx": 137, + "src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx": 153, + "src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx": 268, + "src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx": 155, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx": 509, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx": 15241, + "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx": 15300, + "src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx": 187, + "src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx": 175, + "src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx": 253, + "src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx": 438, + "src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx": 107, + "src/renderer/src/components/native-chat/NativeChatView.test.tsx": 58, + "src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx": 65, + "src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx": 49, + "src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx": 410, + "src/renderer/src/components/native-chat/background-task-roster.test.ts": 21, + "src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts": 14, + "src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts": 12, + "src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-availability.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx": 178, + "src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx": 469, + "src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 153, + "src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 71, + "src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": 25, + "src/renderer/src/components/native-chat/native-chat-diff.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-dismiss-key.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-file-link.test.ts": 10, + "src/renderer/src/components/native-chat/native-chat-font-scale.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-http-link-source-owner.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-image-paste.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts": 17, + "src/renderer/src/components/native-chat/native-chat-launch-default-adoption.test.ts": 14, + "src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-live-status.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-noise.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-pagination.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts": 15, + "src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-pending.test.ts": 36, + "src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts": 267, + "src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts": 12, + "src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts": 235, + "src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts": 88, + "src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts": 1210, + "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts": 187, + "src/renderer/src/components/native-chat/native-chat-scrape-fallback.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-send-eligibility.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-send.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts": 325, + "src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-session-transport.test.ts": 49, + "src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-shortcut.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-split-shortcut.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts": 4, + "src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx": 271, + "src/renderer/src/components/native-chat/native-chat-tab-agent-entry.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts": 10, + "src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts": 12, + "src/renderer/src/components/native-chat/native-chat-tool-summary.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts": 25, + "src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-view-state.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-web-link-actions.test.ts": 18, + "src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx": 55, + "src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts": 8, + "src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts": 8, + "src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts": 24, + "src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx": 74, + "src/renderer/src/components/native-chat/use-native-chat-composer-app-menu-selection.test.tsx": 65, + "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx": 83, + "src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx": 45, + "src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx": 30, + "src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx": 27, + "src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx": 45, + "src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx": 46, + "src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts": 4, + "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx": 34, + "src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx": 51, + "src/renderer/src/components/native-chat/use-native-chat-link-actions.test.tsx": 320, + "src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts": 63, + "src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts": 75, + "src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts": 158, + "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx": 26, + "src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts": 32, + "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx": 17, + "src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx": 30, + "src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts": 248, + "src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx": 396, + "src/renderer/src/components/native-chat/use-native-chat-skills.test.ts": 8, + "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx": 193, + "src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts": 10, + "src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx": 88, + "src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx": 199, + "src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx": 24, + "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx": 870, + "src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx": 596, + "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx": 605, + "src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx": 23, + "src/renderer/src/components/network/CustomAddressDialog.test.tsx": 72, + "src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.test.tsx": 175, + "src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx": 499, + "src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx": 245, + "src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx": 65, + "src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx": 999, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-provider-boundaries.test.ts": 7, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-repo-slug-routing.test.ts": 14, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts": 5, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx": 253, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx": 500, + "src/renderer/src/components/new-workspace/project-combobox-matching.test.ts": 29, + "src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts": 7, + "src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts": 59, + "src/renderer/src/components/new-workspace/smart-workspace-source-popover-focus.test.ts": 17, + "src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts": 16, + "src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx": 46, + "src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx": 39, + "src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts": 6, + "src/renderer/src/components/onboarding/AgentStep.test.tsx": 78, + "src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx": 31, + "src/renderer/src/components/onboarding/NotificationStep.test.tsx": 61, + "src/renderer/src/components/onboarding/OnboardingFlow.test.tsx": 128, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx": 47, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts": 4, + "src/renderer/src/components/onboarding/ThemeStep.test.ts": 8, + "src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx": 51, + "src/renderer/src/components/onboarding/agent-picked-payload.test.ts": 10, + "src/renderer/src/components/onboarding/onboarding-dismiss-target.test.ts": 4, + "src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts": 23, + "src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts": 10, + "src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts": 5, + "src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts": 34, + "src/renderer/src/components/onboarding/use-onboarding-flow.test.ts": 21, + "src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.test.ts": 5, + "src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx": 21, + "src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx": 50, + "src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx": 217, + "src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx": 37, + "src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx": 75, + "src/renderer/src/components/pet/PetOverlay.test.ts": 5, + "src/renderer/src/components/pet/pet-agent-state.test.ts": 14, + "src/renderer/src/components/pet/pet-blob-cache.test.ts": 12, + "src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx": 49, + "src/renderer/src/components/pet/pet-overlay-position.test.ts": 6, + "src/renderer/src/components/pet/pet-overlay-visibility.test.ts": 6, + "src/renderer/src/components/pet/sprite-animation-css.test.ts": 9, + "src/renderer/src/components/pet/usePetPointerInteraction.test.ts": 23, + "src/renderer/src/components/pet/usePetUrl.test.tsx": 15, + "src/renderer/src/components/ports/WorkspacePortScanner.test.tsx": 215, + "src/renderer/src/components/pr-check-counts.test.ts": 11, + "src/renderer/src/components/pr-checks-fix-prompt.test.ts": 20, + "src/renderer/src/components/pr-comments-resolution-prompt.test.ts": 9, + "src/renderer/src/components/provider-check-classification-parity.test.ts": 14, + "src/renderer/src/components/pull-request-page-host-boundary.test.ts": 17, + "src/renderer/src/components/pull-request-page/cache/file-content.test.ts": 11, + "src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx": 22, + "src/renderer/src/components/pull-request-page/mentions/options.test.ts": 4, + "src/renderer/src/components/pull-request-page/mentions/query.test.ts": 7, + "src/renderer/src/components/pull-request-page/presentation/state-badge.test.ts": 6, + "src/renderer/src/components/quick-open-file-list.react.test.tsx": 90, + "src/renderer/src/components/quick-open-file-list.test.ts": 9, + "src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx": 57, + "src/renderer/src/components/quick-open-install-rg-guidance.test.ts": 9, + "src/renderer/src/components/quick-open-search.test.ts": 248, + "src/renderer/src/components/repo/NestedRepoChecklist.test.tsx": 17, + "src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts": 5, + "src/renderer/src/components/repo/repo-icon.emoji-centering.test.tsx": 45, + "src/renderer/src/components/repo/repo-icon.test.tsx": 61, + "src/renderer/src/components/right-sidebar/ActionButton.test.tsx": 17, + "src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx": 294, + "src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx": 67, + "src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx": 28, + "src/renderer/src/components/right-sidebar/ChecksPanel.updated-at-metadata.test.tsx": 46, + "src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx": 78, + "src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx": 87, + "src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx": 92, + "src/renderer/src/components/right-sidebar/CommitArea.test.tsx": 373, + "src/renderer/src/components/right-sidebar/FileExplorerNameFilter.test.tsx": 12, + "src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx": 19, + "src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx": 45, + "src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.test.tsx": 10, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.row-handlers.test.tsx": 16, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx": 14, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx": 300, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx": 72, + "src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx": 69, + "src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx": 60, + "src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx": 75, + "src/renderer/src/components/right-sidebar/HostedReviewActions.draft.test.tsx": 22, + "src/renderer/src/components/right-sidebar/PluginPanel.test.tsx": 220, + "src/renderer/src/components/right-sidebar/PortsPanel.test.tsx": 1028, + "src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx": 753, + "src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx": 18, + "src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx": 54, + "src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx": 774, + "src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts": 18, + "src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts": 9, + "src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts": 14, + "src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx": 19, + "src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx": 546, + "src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts": 15, + "src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx": 833, + "src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts": 9, + "src/renderer/src/components/right-sidebar/SourceControl.resolve-conflicts-prompt.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx": 2498, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx": 425, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx": 74, + "src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts": 94, + "src/renderer/src/components/right-sidebar/active-checks-status.test.ts": 9, + "src/renderer/src/components/right-sidebar/activity-bar-buttons.test.tsx": 59, + "src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx": 137, + "src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts": 32, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts": 16, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts": 8, + "src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts": 12, + "src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts": 8, + "src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts": 10, + "src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts": 13, + "src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts": 19, + "src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts": 9, + "src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts": 15, + "src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts": 22, + "src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts": 17, + "src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts": 193, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts": 9, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts": 10, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts": 19, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx": 61, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts": 19, + "src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts": 10, + "src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts": 13, + "src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts": 6, + "src/renderer/src/components/right-sidebar/check-details-resize.test.ts": 4, + "src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts": 10, + "src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx": 327, + "src/renderer/src/components/right-sidebar/checks-panel-async-result-key.test.ts": 4, + "src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx": 100, + "src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts": 28, + "src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts": 14, + "src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts": 10, + "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts": 11, + "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.test.ts": 9, + "src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts": 16, + "src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts": 11, + "src/renderer/src/components/right-sidebar/checks-panel-review.test.ts": 8, + "src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts": 12, + "src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts": 9, + "src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx": 90, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx": 33, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx": 22, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx": 17, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx": 37, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx": 15, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx": 38, + "src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts": 18, + "src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts": 4, + "src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts": 21, + "src/renderer/src/components/right-sidebar/create-review-draft-title.test.ts": 7, + "src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts": 6, + "src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts": 19, + "src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts": 7, + "src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts": 140, + "src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts": 42, + "src/renderer/src/components/right-sidebar/file-explorer-dir-load-tracker.test.ts": 4, + "src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts": 11, + "src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx": 56, + "src/renderer/src/components/right-sidebar/file-explorer-entries.test.ts": 9, + "src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts": 23, + "src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx": 62, + "src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx": 113, + "src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts": 11, + "src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts": 35, + "src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts": 4, + "src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts": 8, + "src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts": 9, + "src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts": 8, + "src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts": 12, + "src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts": 193, + "src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts": 37, + "src/renderer/src/components/right-sidebar/file-search-include-pattern.test.ts": 8, + "src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts": 16, + "src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts": 6, + "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts": 32, + "src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts": 19, + "src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts": 23, + "src/renderer/src/components/right-sidebar/git-status-refresh.test.ts": 66, + "src/renderer/src/components/right-sidebar/github-pr-link-modal.test.ts": 9, + "src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts": 12, + "src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts": 10, + "src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts": 8, + "src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts": 77, + "src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts": 32, + "src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts": 37, + "src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts": 5, + "src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts": 45, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog-visibility.test.ts": 12, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog.test.ts": 12, + "src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts": 9, + "src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts": 11, + "src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts": 4, + "src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts": 23, + "src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx": 791, + "src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts": 11, + "src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts": 11, + "src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx": 103, + "src/renderer/src/components/right-sidebar/right-panel-comment-focus-timers.test.ts": 8, + "src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-effective-tab.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx": 126, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-width.test.ts": 5, + "src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts": 11, + "src/renderer/src/components/right-sidebar/search-match-open.test.ts": 12, + "src/renderer/src/components/right-sidebar/search-rows.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts": 11, + "src/renderer/src/components/right-sidebar/source-control-actions.test.ts": 5, + "src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.test.ts": 4, + "src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx": 147, + "src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts": 17, + "src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx": 23, + "src/renderer/src/components/right-sidebar/source-control-commit-eligibility.test.ts": 7, + "src/renderer/src/components/right-sidebar/source-control-commit-message-rows.test.ts": 99, + "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts": 18, + "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts": 7, + "src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts": 10, + "src/renderer/src/components/right-sidebar/source-control-created-review-link.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts": 17, + "src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx": 168, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts": 24, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts": 34, + "src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts": 5, + "src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx": 17, + "src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx": 100, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts": 17, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-huge-repo-warning-dismissals.test.ts": 237, + "src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts": 13, + "src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts": 29, + "src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts": 34, + "src/renderer/src/components/right-sidebar/source-control-push-recovery.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-section-order.test.ts": 9, + "src/renderer/src/components/right-sidebar/source-control-split-open.test.ts": 7, + "src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts": 16, + "src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-too-many-changes-banner.test.tsx": 87, + "src/renderer/src/components/right-sidebar/source-control-tree.test.ts": 13, + "src/renderer/src/components/right-sidebar/source-control-virtual-file-list.test.tsx": 148, + "src/renderer/src/components/right-sidebar/source-control/listing/use-file-projection-work.test.tsx": 31, + "src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx": 50, + "src/renderer/src/components/right-sidebar/source-control/review/suppressed-github-pr.test.ts": 10, + "src/renderer/src/components/right-sidebar/source-control/review/use-action-model.test.tsx": 20, + "src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-state.test.tsx": 38, + "src/renderer/src/components/right-sidebar/status-display.test.ts": 4, + "src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts": 87, + "src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts": 11, + "src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx": 47, + "src/renderer/src/components/right-sidebar/use-installed-plugin-route-reconciliation.test.tsx": 23, + "src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx": 34, + "src/renderer/src/components/right-sidebar/use-plugin-panel-theme-revision.test.tsx": 47, + "src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts": 6, + "src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx": 64, + "src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx": 45, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts": 28, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts": 48, + "src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.test.ts": 5, + "src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts": 14, + "src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts": 4, + "src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts": 20, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.refresh-projection-churn.test.tsx": 35, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx": 37, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx": 110, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts": 12, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx": 27, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts": 12, + "src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx": 27, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts": 57, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts": 585, + "src/renderer/src/components/right-sidebar/useHostedReviewStackParent.test.tsx": 33, + "src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts": 9, + "src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx": 39, + "src/renderer/src/components/settings/AccountsPane.test.tsx": 508, + "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts": 9, + "src/renderer/src/components/settings/AdvancedPane.test.tsx": 37, + "src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx": 63, + "src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx": 479, + "src/renderer/src/components/settings/AgentsPane.test.tsx": 445, + "src/renderer/src/components/settings/AppearancePane.test.tsx": 694, + "src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx": 358, + "src/renderer/src/components/settings/AutomationsSettingsPane.test.tsx": 117, + "src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx": 16, + "src/renderer/src/components/settings/BrowserClientHostedRemoteSetting.test.tsx": 116, + "src/renderer/src/components/settings/BrowserPane.test.ts": 6, + "src/renderer/src/components/settings/BrowserSshWorkspaceRoutingSetting.test.tsx": 111, + "src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx": 11, + "src/renderer/src/components/settings/CliSection.install-failure.test.tsx": 197, + "src/renderer/src/components/settings/CliSection.test.tsx": 87, + "src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx": 60, + "src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx": 28, + "src/renderer/src/components/settings/CommitMessageAiPane.test.tsx": 532, + "src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx": 28, + "src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx": 283, + "src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx": 57, + "src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx": 74, + "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx": 128, + "src/renderer/src/components/settings/EphemeralVmsPane.test.tsx": 103, + "src/renderer/src/components/settings/ExperimentalPane.test.tsx": 797, + "src/renderer/src/components/settings/FloatingWorkspacePane.test.tsx": 6, + "src/renderer/src/components/settings/GeneralPane.test.ts": 11, + "src/renderer/src/components/settings/GeneralRemoteServerUpdates.test.tsx": 55, + "src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx": 79, + "src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx": 308, + "src/renderer/src/components/settings/GhosttyImportModal.test.ts": 17, + "src/renderer/src/components/settings/GitPane.test.ts": 54, + "src/renderer/src/components/settings/GitPane.test.tsx": 109, + "src/renderer/src/components/settings/GrokAccountsSection.test.tsx": 61, + "src/renderer/src/components/settings/HiddenExperimentalGroup.test.tsx": 85, + "src/renderer/src/components/settings/KagiSessionLinkForm.test.ts": 5, + "src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx": 49, + "src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx": 27, + "src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx": 152, + "src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx": 227, + "src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx": 40, + "src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx": 243, + "src/renderer/src/components/settings/MobilePairingQrSection.test.tsx": 203, + "src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx": 748, + "src/renderer/src/components/settings/MobilePane.test.tsx": 885, + "src/renderer/src/components/settings/MobilePaneAddressSearch.test.tsx": 31, + "src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx": 114, + "src/renderer/src/components/settings/NotificationsPane.test.tsx": 55, + "src/renderer/src/components/settings/OpenInMenuSetting.test.ts": 23, + "src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx": 140, + "src/renderer/src/components/settings/OrchestrationPane.test.tsx": 266, + "src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx": 26, + "src/renderer/src/components/settings/PluginConsentDialog.test.tsx": 450, + "src/renderer/src/components/settings/PluginInstallDialog.test.tsx": 165, + "src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts": 7, + "src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx": 397, + "src/renderer/src/components/settings/PluginMarketplaceSourceDialog.test.tsx": 195, + "src/renderer/src/components/settings/PluginSettingsRow.test.tsx": 75, + "src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx": 748, + "src/renderer/src/components/settings/PrivacyPane.test.ts": 47, + "src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx": 159, + "src/renderer/src/components/settings/QuickCommandsList.test.tsx": 122, + "src/renderer/src/components/settings/QuickCommandsPane.test.ts": 8, + "src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx": 69, + "src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx": 62, + "src/renderer/src/components/settings/RepositoryHooksSection.test.ts": 120, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx": 354, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx": 71, + "src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx": 37, + "src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx": 47, + "src/renderer/src/components/settings/RepositoryPane.test.ts": 837, + "src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx": 74, + "src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts": 73, + "src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx": 130, + "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts": 17, + "src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx": 75, + "src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx": 41, + "src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx": 88, + "src/renderer/src/components/settings/Settings.load-performance.test.ts": 7, + "src/renderer/src/components/settings/SettingsConstants.test.ts": 9, + "src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx": 359, + "src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx": 66, + "src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx": 69, + "src/renderer/src/components/settings/SettingsSidebar.test.tsx": 79, + "src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx": 192, + "src/renderer/src/components/settings/ShortcutCommandBlock.test.tsx": 76, + "src/renderer/src/components/settings/ShortcutFilterRail.test.ts": 10, + "src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx": 137, + "src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx": 28, + "src/renderer/src/components/settings/SshTargetCard.test.tsx": 89, + "src/renderer/src/components/settings/SshTargetForm.test.tsx": 363, + "src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx": 77, + "src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx": 72, + "src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx": 32, + "src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx": 40, + "src/renderer/src/components/settings/TasksPane.test.tsx": 282, + "src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx": 85, + "src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts": 48, + "src/renderer/src/components/settings/TerminalContrastSetting.test.tsx": 128, + "src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx": 62, + "src/renderer/src/components/settings/TerminalPane.pwsh.test.ts": 38, + "src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx": 19, + "src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx": 89, + "src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts": 51, + "src/renderer/src/components/settings/VoicePane.test.tsx": 443, + "src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx": 85, + "src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx": 104, + "src/renderer/src/components/settings/accounts-search.test.ts": 7, + "src/renderer/src/components/settings/agent-availability-settings.test.ts": 19, + "src/renderer/src/components/settings/agent-default-env-draft.test.ts": 9, + "src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts": 103, + "src/renderer/src/components/settings/appearance-interface-summary.test.ts": 7, + "src/renderer/src/components/settings/appearance-search.test.ts": 315, + "src/renderer/src/components/settings/appearance-status-bar-search.test.ts": 8, + "src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts": 3, + "src/renderer/src/components/settings/browser-cookie-import-label.test.ts": 9, + "src/renderer/src/components/settings/browser-link-routing-localization.test.ts": 222, + "src/renderer/src/components/settings/browser-search.test.ts": 60, + "src/renderer/src/components/settings/browser-session-host-selection.test.ts": 4, + "src/renderer/src/components/settings/cli-install-failure.test.ts": 7, + "src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx": 41, + "src/renderer/src/components/settings/codex-account-auth-warning.test.ts": 11, + "src/renderer/src/components/settings/codex-config-sync-warning.test.ts": 7, + "src/renderer/src/components/settings/codex-session-source-home-control.test.tsx": 10, + "src/renderer/src/components/settings/developer-permissions-search.test.ts": 280, + "src/renderer/src/components/settings/host-scoped-setting-scope.test.ts": 9, + "src/renderer/src/components/settings/integrations-pane-status.test.ts": 10, + "src/renderer/src/components/settings/jira-integration-card.test.tsx": 71, + "src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx": 126, + "src/renderer/src/components/settings/mobile-network-interface-selection.test.ts": 9, + "src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts": 6, + "src/renderer/src/components/settings/mobile-pane-search.test.ts": 9, + "src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts": 11, + "src/renderer/src/components/settings/osc52-clipboard-copy.test.ts": 7, + "src/renderer/src/components/settings/plugin-install-source.test.ts": 9, + "src/renderer/src/components/settings/provider-account-scope.test.ts": 12, + "src/renderer/src/components/settings/provider-account-visibility.test.ts": 5, + "src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx": 31, + "src/renderer/src/components/settings/repository-hook-settings-draft.test.ts": 7, + "src/renderer/src/components/settings/repository-host-setup-options.test.ts": 6, + "src/renderer/src/components/settings/repository-icon-github.test.ts": 8, + "src/renderer/src/components/settings/repository-source-control-ai-global-ux.test.ts": 52, + "src/renderer/src/components/settings/repository-source-control-ai-persist-queue.test.ts": 10, + "src/renderer/src/components/settings/setting-ownership.test.ts": 13, + "src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts": 18, + "src/renderer/src/components/settings/settings-form-option-filter.test.ts": 13, + "src/renderer/src/components/settings/settings-project-list.test.ts": 21, + "src/renderer/src/components/settings/settings-search-keywords.test.ts": 122, + "src/renderer/src/components/settings/settings-search.test.ts": 9, + "src/renderer/src/components/settings/settings-setup-guide-progress-hook.test.tsx": 12, + "src/renderer/src/components/settings/settings-setup-guide-progress.test.ts": 5, + "src/renderer/src/components/settings/shortcut-binding-list-mutations.test.ts": 10, + "src/renderer/src/components/settings/shortcut-definition-catalog.test.ts": 269, + "src/renderer/src/components/settings/shortcut-groups.test.ts": 14, + "src/renderer/src/components/settings/shortcut-recording-state.test.ts": 5, + "src/renderer/src/components/settings/shortcut-row-visibility.test.ts": 14, + "src/renderer/src/components/settings/sparse-preset-date.test.ts": 26, + "src/renderer/src/components/settings/sparse-preset-operation-error.test.ts": 3, + "src/renderer/src/components/settings/ssh-target-action-state.test.ts": 4, + "src/renderer/src/components/settings/ssh-target-draft.test.ts": 17, + "src/renderer/src/components/settings/ssh-target-remove.test.ts": 10, + "src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 10, + "src/renderer/src/components/settings/task-source-setup-state.test.ts": 11, + "src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx": 134, + "src/renderer/src/components/settings/terminal-preview-content.test.ts": 7, + "src/renderer/src/components/settings/terminal-search.test.ts": 56, + "src/renderer/src/components/settings/use-debounced-settings-text-draft.test.ts": 35, + "src/renderer/src/components/settings/use-mac-captured-digit-chords.test.ts": 393, + "src/renderer/src/components/settings/use-repository-hook-settings-draft.test.tsx": 34, + "src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx": 25, + "src/renderer/src/components/settings/useGhosttyImport.test.ts": 17, + "src/renderer/src/components/settings/useWarpThemeImport.test.ts": 31, + "src/renderer/src/components/settings/worktree-symlink-path-filter.test.ts": 11, + "src/renderer/src/components/setup-guide/SetupGuideModal.mount-gating.test.tsx": 1073, + "src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts": 10, + "src/renderer/src/components/setup-guide/use-setup-guide-telemetry.test.ts": 10, + "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts": 184, + "src/renderer/src/components/shared/useDaemonActions.test.tsx": 27, + "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx": 198, + "src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx": 603, + "src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx": 39, + "src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx": 54, + "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts": 8, + "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx": 71, + "src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx": 43, + "src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx": 146, + "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx": 283, + "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts": 103, + "src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx": 40, + "src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx": 229, + "src/renderer/src/components/sidebar/CacheTimer.test.tsx": 15, + "src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx": 68, + "src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx": 134, + "src/renderer/src/components/sidebar/CommentMarkdown.test.tsx": 309, + "src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts": 7, + "src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx": 523, + "src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx": 105, + "src/renderer/src/components/sidebar/FilterToggleRow.test.tsx": 30, + "src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx": 34, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx": 214, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx": 820, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx": 242, + "src/renderer/src/components/sidebar/MarkdownImageLightbox.test.tsx": 265, + "src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx": 109, + "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx": 80, + "src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx": 59, + "src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx": 24, + "src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx": 37, + "src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx": 194, + "src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx": 17, + "src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx": 145, + "src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx": 15, + "src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx": 65, + "src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts": 8, + "src/renderer/src/components/sidebar/SetupScriptPromptCardShell.test.tsx": 57, + "src/renderer/src/components/sidebar/Sidebar.test.tsx": 80, + "src/renderer/src/components/sidebar/SidebarAgentsList.test.tsx": 205, + "src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx": 312, + "src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx": 62, + "src/renderer/src/components/sidebar/SidebarHeader.test.tsx": 200, + "src/renderer/src/components/sidebar/SidebarNav.test.tsx": 749, + "src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.test.tsx": 507, + "src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx": 280, + "src/renderer/src/components/sidebar/SidebarToolbar.test.tsx": 129, + "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx": 123, + "src/renderer/src/components/sidebar/StatusIndicator.test.ts": 32, + "src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx": 30, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx": 522, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx": 179, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx": 128, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx": 14, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx": 70, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx": 78, + "src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx": 80, + "src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx": 211, + "src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx": 55, + "src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx": 80, + "src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx": 3366, + "src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx": 1185, + "src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx": 1294, + "src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx": 1219, + "src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx": 1041, + "src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx": 1063, + "src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx": 1918, + "src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx": 1401, + "src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx": 762, + "src/renderer/src/components/sidebar/WorktreeCard.test.ts": 9, + "src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx": 523, + "src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx": 470, + "src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx": 808, + "src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx": 673, + "src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx": 86, + "src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.test.tsx": 76, + "src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx": 305, + "src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx": 99, + "src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx": 1109, + "src/renderer/src/components/sidebar/WorktreeCardSshHostControl.test.tsx": 281, + "src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx": 53, + "src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx": 70, + "src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx": 733, + "src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts": 31, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenu.test.tsx": 23, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx": 73, + "src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx": 1046, + "src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts": 1389, + "src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts": 1843, + "src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts": 1975, + "src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx": 2544, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts": 1842, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx": 3279, + "src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx": 2529, + "src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx": 1032, + "src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx": 30, + "src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts": 21, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx": 78, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.editor-lifecycle.test.tsx": 147, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx": 24, + "src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx": 2347, + "src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx": 496, + "src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx": 107, + "src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts": 13, + "src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts": 18, + "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts": 9, + "src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts": 15, + "src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts": 6, + "src/renderer/src/components/sidebar/clone-defaults.test.ts": 6, + "src/renderer/src/components/sidebar/create-project-defaults.test.ts": 7, + "src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts": 12, + "src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts": 3, + "src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx": 44, + "src/renderer/src/components/sidebar/delete-worktree-flow.test.ts": 284, + "src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts": 37, + "src/renderer/src/components/sidebar/delete-worktree-toast.test.ts": 8, + "src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts": 10, + "src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts": 10, + "src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts": 18, + "src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts": 10, + "src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx": 48, + "src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts": 61, + "src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts": 6, + "src/renderer/src/components/sidebar/host-header-drag.test.tsx": 51, + "src/renderer/src/components/sidebar/host-header-menu-items.test.ts": 8, + "src/renderer/src/components/sidebar/host-rename-remove.test.ts": 8, + "src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts": 7, + "src/renderer/src/components/sidebar/host-section-order.test.ts": 5, + "src/renderer/src/components/sidebar/host-section-rows.test.ts": 12, + "src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts": 115, + "src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts": 13, + "src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts": 8, + "src/renderer/src/components/sidebar/linear-agent-skill-runtime.shell-override.test.ts": 5, + "src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts": 13, + "src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx": 52, + "src/renderer/src/components/sidebar/mobile-sidebar-onboarding-badge.test.ts": 34, + "src/renderer/src/components/sidebar/natural-worktree-ids.test.ts": 8, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts": 10, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts": 7, + "src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts": 363, + "src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx": 129, + "src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx": 47, + "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts": 22, + "src/renderer/src/components/sidebar/project-group-header-dom.test.ts": 6, + "src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts": 12, + "src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts": 21, + "src/renderer/src/components/sidebar/project-group-header-drag.test.ts": 16, + "src/renderer/src/components/sidebar/project-group-header-drop.test.ts": 13, + "src/renderer/src/components/sidebar/project-header-action-selector-lockstep.test.ts": 7, + "src/renderer/src/components/sidebar/project-header-color.test.ts": 7, + "src/renderer/src/components/sidebar/project-header-drag-commit.test.ts": 12, + "src/renderer/src/components/sidebar/project-header-drag-start.test.ts": 11, + "src/renderer/src/components/sidebar/project-header-drag.test.ts": 14, + "src/renderer/src/components/sidebar/project-header-drop.test.ts": 7, + "src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.test.ts": 7, + "src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts": 11, + "src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts": 5, + "src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts": 6, + "src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts": 39, + "src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts": 42, + "src/renderer/src/components/sidebar/repo-header-create-state.test.ts": 10, + "src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts": 4, + "src/renderer/src/components/sidebar/sidebar-filter-state.test.ts": 9, + "src/renderer/src/components/sidebar/sidebar-host-options.test.ts": 15, + "src/renderer/src/components/sidebar/sidebar-project-drop.test.ts": 10, + "src/renderer/src/components/sidebar/sidebar-resize-handle.test.ts": 8, + "src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts": 9, + "src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts": 17, + "src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts": 19, + "src/renderer/src/components/sidebar/smart-attention.test.ts": 26, + "src/renderer/src/components/sidebar/smart-sort.test.ts": 17, + "src/renderer/src/components/sidebar/ssh-host-remove-resolution.test.ts": 10, + "src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts": 8, + "src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts": 5, + "src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts": 13, + "src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx": 63, + "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts": 157, + "src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts": 5, + "src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx": 46, + "src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx": 28, + "src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts": 10, + "src/renderer/src/components/sidebar/use-workspace-kanban-drawer-lingering.test.tsx": 28, + "src/renderer/src/components/sidebar/use-workspace-kanban-outside-dismiss.test.ts": 7, + "src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx": 21, + "src/renderer/src/components/sidebar/use-workspace-reveal-body-redirect.test.tsx": 33, + "src/renderer/src/components/sidebar/use-workspace-status-drop.test.ts": 7, + "src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx": 23, + "src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts": 7, + "src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx": 30, + "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts": 114, + "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts": 127, + "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts": 13, + "src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts": 54, + "src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts": 30, + "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts": 105, + "src/renderer/src/components/sidebar/useRenderedSetupScriptPromptState.test.ts": 20, + "src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx": 63, + "src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx": 68, + "src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts": 21, + "src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts": 9, + "src/renderer/src/components/sidebar/visible-worktree-indexes.test.ts": 12, + "src/renderer/src/components/sidebar/visible-worktrees.test.ts": 22, + "src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 26, + "src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 6, + "src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts": 9, + "src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 13, + "src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts": 7, + "src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts": 11, + "src/renderer/src/components/sidebar/workspace-kanban-search.test.ts": 23, + "src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts": 21, + "src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts": 13, + "src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts": 16, + "src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts": 9, + "src/renderer/src/components/sidebar/workspace-status.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts": 25, + "src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts": 17, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts": 103, + "src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts": 26, + "src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx": 20, + "src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts": 31, + "src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx": 84, + "src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx": 26, + "src/renderer/src/components/sidebar/worktree-card-dom-events.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-card-markdown-isolation.test.ts": 344, + "src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-card-title-display.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts": 173, + "src/renderer/src/components/sidebar/worktree-delete-position-scaling.test.ts": 35, + "src/renderer/src/components/sidebar/worktree-delete-request.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-drag-units.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-filter-visibility.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-header-section-boundaries.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts": 15, + "src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts": 159, + "src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts": 17, + "src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts": 23, + "src/renderer/src/components/sidebar/worktree-list-groups-imported-worktrees.test.ts": 22, + "src/renderer/src/components/sidebar/worktree-list-groups-lineage-nesting.test.ts": 17, + "src/renderer/src/components/sidebar/worktree-list-groups-nested-project-groups.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-list-groups-pending-creations.test.ts": 13, + "src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-list-groups-project-groups.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts": 19, + "src/renderer/src/components/sidebar/worktree-list-groups-project-order.test.ts": 1284, + "src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts": 12, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts": 22, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts": 21, + "src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx": 35, + "src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx": 24, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx": 124, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-selection-host-collision.test.tsx": 18, + "src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx": 38, + "src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx": 57, + "src/renderer/src/components/sidebar/worktree-list/rows/indentation.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/rows/option-dom-host-collision.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx": 39, + "src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.test.ts": 14, + "src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts": 15, + "src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts": 26, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.ts": 45, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.tsx": 21, + "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts": 22, + "src/renderer/src/components/sidebar/worktree-manual-order.test.ts": 315, + "src/renderer/src/components/sidebar/worktree-meta-updates.test.ts": 14, + "src/renderer/src/components/sidebar/worktree-multi-selection.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-name-suggestions.test.ts": 73, + "src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx": 22, + "src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts": 4, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts": 15, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts": 27, + "src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts": 13, + "src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal-scroll-settle.test.ts": 3, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-sort-label-ordering.test.ts": 180, + "src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts": 20, + "src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-unnest.test.ts": 19, + "src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts": 9, + "src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx": 53, + "src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx": 51, + "src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx": 420, + "src/renderer/src/components/skills/SkillInstallAgentPicker.test.tsx": 226, + "src/renderer/src/components/skills/SkillInstallDialog.test.tsx": 1326, + "src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx": 1515, + "src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx": 169, + "src/renderer/src/components/skills/SkillInstallWorkspaceCombobox.test.tsx": 537, + "src/renderer/src/components/skills/SkillShareDialog.test.tsx": 649, + "src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx": 388, + "src/renderer/src/components/skills/SkillsPage.test.tsx": 1126, + "src/renderer/src/components/skills/skill-bundle-name.test.ts": 8, + "src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts": 6, + "src/renderer/src/components/skills/skill-delete-copy.test.ts": 23, + "src/renderer/src/components/skills/skill-delete-selection.test.ts": 10, + "src/renderer/src/components/skills/skill-description-length.test.ts": 4, + "src/renderer/src/components/skills/skill-freshness-grouping.test.ts": 18, + "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts": 11, + "src/renderer/src/components/skills/skill-install-progress-state.test.tsx": 23, + "src/renderer/src/components/skills/skill-install-provider-groups.test.ts": 11, + "src/renderer/src/components/skills/skill-install-workspace-choices.test.ts": 8, + "src/renderer/src/components/skills/skill-package-checklist-items.test.ts": 12, + "src/renderer/src/components/skills/skill-package-digest.test.ts": 5, + "src/renderer/src/components/skills/skill-package-install-risk.test.ts": 8, + "src/renderer/src/components/skills/skill-share-link.test.ts": 4, + "src/renderer/src/components/skills/skill-share-package-selection.test.ts": 5, + "src/renderer/src/components/skills/skill-share-preview-summary.test.ts": 21, + "src/renderer/src/components/skills/skill-share-selection.test.ts": 6, + "src/renderer/src/components/skills/skill-source-inventory.test.ts": 8, + "src/renderer/src/components/skills/skills-filter.test.ts": 5, + "src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx": 56, + "src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx": 86, + "src/renderer/src/components/star-nag/StarNagToastHost.test.tsx": 130, + "src/renderer/src/components/stats/GrokUsagePane.test.tsx": 106, + "src/renderer/src/components/stats/UsageBreakdownSection.test.tsx": 35, + "src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx": 230, + "src/renderer/src/components/stats/usage-daily-chart.test.tsx": 300, + "src/renderer/src/components/stats/usage-overview-model.test.ts": 202, + "src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx": 645, + "src/renderer/src/components/status-bar/PetStatusSegment.layout.test.ts": 4, + "src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx": 100, + "src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx": 36, + "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx": 62, + "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts": 6, + "src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx": 130, + "src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx": 64, + "src/renderer/src/components/status-bar/SshStatusSegment.test.ts": 10, + "src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx": 137, + "src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx": 102, + "src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts": 6, + "src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx": 830, + "src/renderer/src/components/status-bar/icons.test.tsx": 16, + "src/renderer/src/components/status-bar/inline-usage-bars.test.tsx": 742, + "src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts": 29, + "src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx": 50, + "src/renderer/src/components/status-bar/provider-account-sync-key.test.ts": 6, + "src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx": 672, + "src/renderer/src/components/status-bar/remote-host-connection-status.test.ts": 10, + "src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts": 7, + "src/renderer/src/components/status-bar/resource-manager-worktree-target.test.ts": 5, + "src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts": 7, + "src/renderer/src/components/status-bar/resource-session-bindings.test.ts": 8, + "src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts": 3, + "src/renderer/src/components/status-bar/resource-session-inventory.test.ts": 9, + "src/renderer/src/components/status-bar/resource-session-kill-confirmation.test.ts": 5, + "src/renderer/src/components/status-bar/resource-session-navigation.test.ts": 10, + "src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts": 10, + "src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts": 7, + "src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts": 5, + "src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts": 6, + "src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts": 4, + "src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx": 310, + "src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx": 773, + "src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts": 12, + "src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts": 12, + "src/renderer/src/components/status-bar/tooltip.test.ts": 51, + "src/renderer/src/components/status-bar/usage-error-copy.test.ts": 5, + "src/renderer/src/components/status-bar/usage-percentage-label.test.ts": 4, + "src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts": 6, + "src/renderer/src/components/status-bar/usage-roster-formatting.test.ts": 6, + "src/renderer/src/components/status-bar/usage-roster-row-state.test.ts": 6, + "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx": 677, + "src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx": 69, + "src/renderer/src/components/status-bar/workspace-space-delete-host-routing.test.ts": 8, + "src/renderer/src/components/status-bar/workspace-space-format.test.ts": 8, + "src/renderer/src/components/status-bar/workspace-space-layout.test.ts": 5, + "src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts": 8, + "src/renderer/src/components/status-bar/workspace-space-presentation.test.ts": 133, + "src/renderer/src/components/tab-bar/BrowserTab.test.tsx": 1412, + "src/renderer/src/components/tab-bar/ClientHostedBrowserTabRows.test.tsx": 106, + "src/renderer/src/components/tab-bar/EditorFileTab.test.tsx": 840, + "src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx": 505, + "src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts": 37, + "src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx": 46, + "src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx": 739, + "src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx": 113, + "src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx": 121, + "src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts": 1116, + "src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts": 2180, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts": 2043, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts": 1451, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts": 1914, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx": 1048, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx": 477, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx": 100, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx": 166, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx": 199, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx": 171, + "src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx": 102, + "src/renderer/src/components/tab-bar/TabBarQuickCommandItem.test.tsx": 106, + "src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts": 134, + "src/renderer/src/components/tab-bar/TabStripScrollIndicator.test.tsx": 63, + "src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx": 36, + "src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts": 6, + "src/renderer/src/components/tab-bar/drop-indicator.test.ts": 8, + "src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts": 5, + "src/renderer/src/components/tab-bar/group-tab-order.test.ts": 8, + "src/renderer/src/components/tab-bar/middle-button-default-guard.test.ts": 5, + "src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts": 9, + "src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts": 8, + "src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts": 20, + "src/renderer/src/components/tab-bar/open-tab-search.test.ts": 35, + "src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts": 12, + "src/renderer/src/components/tab-bar/query-token-match.test.ts": 7, + "src/renderer/src/components/tab-bar/recent-tab-switching.test.ts": 8, + "src/renderer/src/components/tab-bar/reconcile-order.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts": 15, + "src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts": 13, + "src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx": 14, + "src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx": 8, + "src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts": 35, + "src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts": 50, + "src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts": 36, + "src/renderer/src/components/tab-bar/tab-create-entry-forced-search.test.ts": 4, + "src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts": 285, + "src/renderer/src/components/tab-bar/tab-create-menu-options.test.ts": 16, + "src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts": 12, + "src/renderer/src/components/tab-bar/tab-strip-content-resize-observers.test.ts": 11, + "src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx": 35, + "src/renderer/src/components/tab-bar/tab-strip-scroll-metrics.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx": 28, + "src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts": 46, + "src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts": 15, + "src/renderer/src/components/tab-bar/use-open-tab-search.test.ts": 221, + "src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts": 29, + "src/renderer/src/components/tab-bar/windows-shell-launch.test.ts": 5, + "src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts": 7, + "src/renderer/src/components/tab-group/RetainedPaneHost.test.tsx": 75, + "src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts": 7, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx": 128, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts": 10, + "src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx": 7, + "src/renderer/src/components/tab-group/tab-drag-pointer.test.ts": 4, + "src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts": 10, + "src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx": 60, + "src/renderer/src/components/tab-group/tab-drop-zone.test.ts": 4, + "src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts": 4, + "src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts": 18, + "src/renderer/src/components/tab-group/tab-insertion.test.ts": 7, + "src/renderer/src/components/tab-group/useTabDragSplit.test.ts": 78, + "src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts": 679, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts": 269, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.test.tsx": 69, + "src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts": 1235, + "src/renderer/src/components/task-drawer-source-boundary.test.ts": 8, + "src/renderer/src/components/task-page-cache-selectors.test.ts": 11, + "src/renderer/src/components/task-page-checks-pill.test.ts": 5, + "src/renderer/src/components/task-page-default-repo-selection.test.ts": 18, + "src/renderer/src/components/task-page-empty-state.test.ts": 14, + "src/renderer/src/components/task-page-github-dialog-state-authority.test.ts": 10, + "src/renderer/src/components/task-page-github-issue-creation.test.ts": 5, + "src/renderer/src/components/task-page-github-list-scroll-restore.test.ts": 20, + "src/renderer/src/components/task-page-github-resume-cache.test.ts": 17, + "src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts": 6, + "src/renderer/src/components/task-page-github-status-actions.test.ts": 7, + "src/renderer/src/components/task-page-github-status-state.test.ts": 7, + "src/renderer/src/components/task-page-github-task-kind.test.ts": 15, + "src/renderer/src/components/task-page-github-work-item-filter-membership.test.ts": 10, + "src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts": 13, + "src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts": 22, + "src/renderer/src/components/task-page-github-work-item-mutations.test.ts": 24, + "src/renderer/src/components/task-page-github-work-item-status.test.ts": 9, + "src/renderer/src/components/task-page-gitlab-task-filters.test.ts": 5, + "src/renderer/src/components/task-page-initial-selection-scaling.test.tsx": 41, + "src/renderer/src/components/task-page-jira-cache-selectors.test.ts": 7, + "src/renderer/src/components/task-page-jira-create-fields.test.ts": 9, + "src/renderer/src/components/task-page-jira-grouping.test.ts": 194, + "src/renderer/src/components/task-page-jira-item-source-context.test.ts": 7, + "src/renderer/src/components/task-page-jira-load-state.test.ts": 7, + "src/renderer/src/components/task-page-jira-project-selection.test.ts": 8, + "src/renderer/src/components/task-page-jira-sort-controls.test.tsx": 186, + "src/renderer/src/components/task-page-jira-sorting.test.ts": 151, + "src/renderer/src/components/task-page-linear-in-orca-issues.test.ts": 14, + "src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts": 9, + "src/renderer/src/components/task-page-linear-issue-empty-state.test.ts": 6, + "src/renderer/src/components/task-page-linear-issue-grouping.test.ts": 11, + "src/renderer/src/components/task-page-linear-issue-request.test.ts": 6, + "src/renderer/src/components/task-page-linear-team-selection.test.ts": 9, + "src/renderer/src/components/task-page-list-chrome-visibility.test.ts": 7, + "src/renderer/src/components/task-page-localized-options.test.ts": 79, + "src/renderer/src/components/task-page-mutation-page-allocation.test.ts": 8, + "src/renderer/src/components/task-page-new-issue-draft.test.ts": 6, + "src/renderer/src/components/task-page-pagination-page-numbers.test.ts": 7, + "src/renderer/src/components/task-page-pr-check-summary.test.ts": 6, + "src/renderer/src/components/task-page-repo-source-context.test.ts": 10, + "src/renderer/src/components/task-page-repo-source-divergence.test.ts": 5, + "src/renderer/src/components/task-page-source-switch-boundary.test.ts": 9, + "src/renderer/src/components/task-page-task-creation-drafts.test.ts": 10, + "src/renderer/src/components/task-page-task-source-host-availability.test.ts": 11, + "src/renderer/src/components/task-page-work-item-pagination.test.ts": 18, + "src/renderer/src/components/task-page-workspace-composer-boundary.test.ts": 223, + "src/renderer/src/components/task-source-context-summary.test.ts": 17, + "src/renderer/src/components/task-source-provider-availability.test.ts": 10, + "src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx": 135, + "src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx": 12, + "src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx": 200, + "src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.test.tsx": 269, + "src/renderer/src/components/terminal-pane/SessionRestoredBanner.test.tsx": 33, + "src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx": 207, + "src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx": 58, + "src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts": 127, + "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx": 101, + "src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx": 24, + "src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx": 60, + "src/renderer/src/components/terminal-pane/TerminalProcessExitOverlay.test.tsx": 76, + "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx": 70, + "src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx": 192, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts": 39, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts": 20, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts": 13, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts": 28, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts": 34, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts": 19, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts": 18, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts": 44, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts": 24, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts": 20, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts": 23, + "src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts": 47, + "src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts": 185, + "src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts": 6, + "src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts": 11, + "src/renderer/src/components/terminal-pane/agent-hook-terminal-lifecycle.test.ts": 11, + "src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts": 30, + "src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts": 14, + "src/renderer/src/components/terminal-pane/agent-process-inspection-round.test.ts": 18, + "src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts": 14, + "src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts": 8, + "src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts": 112, + "src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts": 5, + "src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts": 305, + "src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts": 18, + "src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts": 8, + "src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts": 7, + "src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts": 173, + "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts": 15, + "src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts": 9, + "src/renderer/src/components/terminal-pane/direct-ssh-hidden-output-restore-unavailable-banner.test.ts": 1045, + "src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts": 31, + "src/renderer/src/components/terminal-pane/expand-collapse-render-stability.test.ts": 26, + "src/renderer/src/components/terminal-pane/expand-collapse.test.ts": 8, + "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts": 10, + "src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts": 7, + "src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts": 5, + "src/renderer/src/components/terminal-pane/git-bash-console-capacity.test.ts": 12, + "src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts": 34, + "src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts": 16, + "src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts": 7500, + "src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts": 21, + "src/renderer/src/components/terminal-pane/issue-4631-terminal-hover-link-provider.repro.test.ts": 177, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-composing-chord.test.tsx": 48, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx": 59, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx": 54, + "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts": 17, + "src/renderer/src/components/terminal-pane/layout-serialization.test.ts": 19, + "src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts": 8, + "src/renderer/src/components/terminal-pane/merge-captured-leaf-state.test.ts": 16, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts": 7, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-focus.test.ts": 5, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts": 6, + "src/renderer/src/components/terminal-pane/mouse-hide-while-typing.test.ts": 6, + "src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts": 13, + "src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts": 58, + "src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts": 69, + "src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts": 122, + "src/renderer/src/components/terminal-pane/override-affected-panes.test.ts": 7, + "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts": 1727, + "src/renderer/src/components/terminal-pane/pane-agent-session-id.test.ts": 5, + "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts": 59, + "src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts": 10, + "src/renderer/src/components/terminal-pane/pane-helpers.test.ts": 9, + "src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts": 6, + "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts": 2029, + "src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts": 56, + "src/renderer/src/components/terminal-pane/parse-osc7.test.ts": 5, + "src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts": 19, + "src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts": 1727, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts": 1162, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts": 699, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts": 709, + "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts": 1591, + "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts": 3135, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts": 1466, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts": 1225, + "src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts": 1131, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts": 1727, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts": 1570, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts": 1216, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts": 1659, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts": 624, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts": 1254, + "src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts": 1432, + "src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts": 1373, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts": 1032, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts": 939, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts": 3190, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts": 1467, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts": 1677, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts": 597, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts": 1298, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts": 589, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts": 814, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts": 1012, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts": 835, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts": 1536, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts": 1433, + "src/renderer/src/components/terminal-pane/pty-connection-hook-idle-arbitration.test.ts": 1039, + "src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts": 916, + "src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts": 922, + "src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts": 1134, + "src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts": 869, + "src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts": 1744, + "src/renderer/src/components/terminal-pane/pty-connection-post-dispose-restore-termination.test.ts": 771, + "src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts": 3670, + "src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts": 705, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts": 2385, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts": 1987, + "src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts": 1224, + "src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-source-grid.test.ts": 807, + "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts": 1489, + "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts": 1203, + "src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts": 835, + "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts": 925, + "src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts": 1912, + "src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts": 1083, + "src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts": 798, + "src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts": 897, + "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts": 47, + "src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts": 698, + "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts": 3547, + "src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts": 876, + "src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts": 1186, + "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts": 4079, + "src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts": 1778, + "src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts": 1757, + "src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts": 860, + "src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts": 1118, + "src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts": 1660, + "src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts": 8, + "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts": 4, + "src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts": 6, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts": 7, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts": 16, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts": 12, + "src/renderer/src/components/terminal-pane/pty-connection/ssh-session-gone-verdict.test.ts": 5, + "src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts": 6, + "src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts": 12, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts": 98, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts": 56, + "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts": 317, + "src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts": 89, + "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts": 750, + "src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts": 10, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer-warn-eviction.test.ts": 17, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts": 47, + "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts": 50, + "src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts": 10, + "src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts": 10, + "src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts": 68, + "src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts": 1985, + "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts": 161, + "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts": 12, + "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts": 14, + "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts": 383, + "src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts": 238, + "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts": 321, + "src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts": 311, + "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts": 759, + "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts": 310, + "src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts": 169, + "src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts": 165, + "src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts": 298, + "src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts": 157, + "src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts": 148, + "src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts": 22, + "src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts": 5, + "src/renderer/src/components/terminal-pane/remote-execution-host-pty.test.ts": 5, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts": 1241, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts": 1012, + "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts": 10, + "src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts": 1731, + "src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts": 1369, + "src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts": 2153, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts": 72, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts": 2204, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts": 4200, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts": 2087, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts": 26, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts": 1389, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts": 1605, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts": 2053, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts": 3506, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts": 2653, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts": 2166, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts": 1292, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts": 2463, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts": 2411, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts": 5095, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts": 3404, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts": 2595, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts": 2613, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts": 1727, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts": 1654, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts": 2871, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts": 2313, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts": 2038, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts": 3273, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts": 2711, + "src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts": 1695, + "src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts": 8, + "src/renderer/src/components/terminal-pane/replay-guard.test.ts": 37, + "src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts": 5, + "src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts": 14, + "src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts": 19, + "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts": 13, + "src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts": 5, + "src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx": 64, + "src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts": 6, + "src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts": 9, + "src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts": 7, + "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts": 8, + "src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts": 9, + "src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts": 16, + "src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts": 7, + "src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts": 34, + "src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts": 152, + "src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts": 30, + "src/renderer/src/components/terminal-pane/terminal-appearance.test.ts": 28, + "src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts": 68, + "src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-cold-park-exempt-flip.react185.test.tsx": 37, + "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx": 64, + "src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx": 28, + "src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx": 63, + "src/renderer/src/components/terminal-pane/terminal-cold-park-timer-rearm.test.tsx": 69, + "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx": 39, + "src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-command-lifecycle.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-context-menu-dismiss.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts": 88, + "src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts": 50, + "src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts": 34, + "src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts": 28, + "src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts": 21, + "src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-drop-shell.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-drop-write-failure.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts": 27, + "src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts": 36, + "src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-http-url-extraction.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts": 687, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts": 23, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts": 28, + "src/renderer/src/components/terminal-pane/terminal-ime-forwarder-space-claim.test.ts": 89, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-syllable-flush.test.ts": 261, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts": 22, + "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts": 134, + "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts": 35, + "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts": 160, + "src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts": 160, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts": 1021, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-cancelled-preedit-visibility.test.ts": 152, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-cancel.test.ts": 133, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts": 446, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-consumed-key-commit.test.ts": 157, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-korean-enter-commit-order.test.ts": 222, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-linux-native-trace-replay.test.ts": 2429, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts": 270, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts": 111, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts": 206, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts": 277, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts": 273, + "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts": 295, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts": 302, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts": 353, + "src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx": 27, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts": 67, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts": 26, + "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts": 29, + "src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts": 52, + "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts": 64, + "src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts": 27, + "src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-link-provider-link-detection.test.ts": 91, + "src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts": 35, + "src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts": 32, + "src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts": 40, + "src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts": 42, + "src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts": 19, + "src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-pane-attention-subscriptions.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts": 111, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state-memo.test.ts": 21, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts": 196, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts": 44, + "src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts": 20, + "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx": 128, + "src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts": 23, + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts": 25, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts": 19, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-worktree-driver-loop.test.tsx": 45, + "src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-eviction-exemption.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts": 92, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts": 69, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sole-newborn-exit.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts": 2548, + "src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts": 18, + "src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts": 39, + "src/renderer/src/components/terminal-pane/terminal-paste-executor-default-yield.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-paste-multiline-policy.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts": 30, + "src/renderer/src/components/terminal-pane/terminal-paste-payload-metadata.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-process-exit-restart.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.test.ts": 84, + "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-render-desync-frame.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts": 395, + "src/renderer/src/components/terminal-pane/terminal-render-desync-weight-probe.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts": 266, + "src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts": 70, + "src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts": 62, + "src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts": 25, + "src/renderer/src/components/terminal-pane/terminal-retention-exempt-growth.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts": 29, + "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts": 27, + "src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts": 139, + "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts": 287, + "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts": 31, + "src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts": 29, + "src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts": 23, + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts": 31, + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts": 19, + "src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts": 6, + "src/renderer/src/components/terminal-pane/title-agent-identity.test.ts": 6, + "src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts": 3, + "src/renderer/src/components/terminal-pane/use-mobile-overlay-ticks.perf.test.tsx": 31, + "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts": 44, + "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 59, + "src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 7, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 15, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts": 22, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 43, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 14, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 22, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts": 28, + "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts": 19, + "src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx": 26, + "src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts": 9, + "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts": 99, + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts": 55, + "src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.test.ts": 5, + "src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx": 29, + "src/renderer/src/components/terminal-pane/useTerminalFontZoom.test.ts": 10, + "src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts": 25, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts": 13, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts": 14, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts": 14, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts": 9, + "src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts": 24, + "src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx": 37, + "src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx": 660, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.test.ts": 10, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts": 11, + "src/renderer/src/components/terminal-scrollback-decoration-eviction.test.ts": 414, + "src/renderer/src/components/terminal-search-decoration-leak.test.ts": 1942, + "src/renderer/src/components/terminal-search-long-wrapped-line.test.ts": 4994, + "src/renderer/src/components/terminal-search-safe-find.test.ts": 7, + "src/renderer/src/components/terminal-workspace-keydown.test.ts": 246, + "src/renderer/src/components/terminal-workspace-surface-ids.test.tsx": 87, + "src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts": 13, + "src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx": 38, + "src/renderer/src/components/terminal/active-terminal-repair.test.ts": 6, + "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts": 24, + "src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts": 14, + "src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts": 37, + "src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx": 26, + "src/renderer/src/components/terminal/initial-terminal-wiring.test.ts": 4, + "src/renderer/src/components/terminal/initial-terminal.test.ts": 4, + "src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts": 9, + "src/renderer/src/components/terminal/running-terminal-close-guard.test.ts": 18, + "src/renderer/src/components/terminal/split-group-mount.test.ts": 8, + "src/renderer/src/components/terminal/tab-type-cycle.test.ts": 9, + "src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts": 12, + "src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts": 8, + "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts": 5, + "src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts": 74, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability-resettlement.test.ts": 15, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts": 15, + "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts": 15, + "src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts": 17, + "src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts": 32, + "src/renderer/src/components/terminal/terminal-tab-actions.test.ts": 32, + "src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts": 12, + "src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts": 21, + "src/renderer/src/components/terminal/unsaved-close-queue.test.ts": 5, + "src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx": 50, + "src/renderer/src/components/terminal/use-worktree-files.test.tsx": 52, + "src/renderer/src/components/terminal/window-close-running-work.test.ts": 23, + "src/renderer/src/components/ui/color-picker.test.tsx": 24, + "src/renderer/src/components/ui/popover-wheel-scroll.test.tsx": 243, + "src/renderer/src/components/ui/repo-multi-combobox.test.ts": 6, + "src/renderer/src/components/ui/scroll-area.test.tsx": 39, + "src/renderer/src/components/ui/slider.test.tsx": 111, + "src/renderer/src/components/ui/switch.test.tsx": 29, + "src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx": 206, + "src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts": 9, + "src/renderer/src/components/use-github-task-search-commit.test.ts": 35, + "src/renderer/src/components/use-task-creation-draft-retention.test.ts": 25, + "src/renderer/src/components/use-terminal-create-actions.test.tsx": 22, + "src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx": 28, + "src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx": 16, + "src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts": 37, + "src/renderer/src/components/window-close-request-coordinator.test.ts": 19, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx": 100, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx": 1307, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx": 43, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx": 58, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx": 60, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts": 26, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts": 11, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts": 15, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts": 63, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts": 31, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx": 47, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts": 6, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx": 214, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx": 118, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts": 9, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx": 59, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx": 357, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts": 39, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts": 12, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx": 237, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx": 280, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts": 16, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts": 27, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts": 7, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts": 9, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts": 26, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx": 470, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx": 82, + "src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.test.tsx": 83, + "src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx": 122, + "src/renderer/src/components/workspace-surface-projection.test.ts": 39, + "src/renderer/src/components/worktree-creation/WorktreeCreationPanel.test.tsx": 74, + "src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx": 1645, + "src/renderer/src/components/worktree-jump-palette-primitives.test.tsx": 131, + "src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx": 20, + "src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts": 9, + "src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts": 4, + "src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts": 6, + "src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts": 134, + "src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts": 109, + "src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts": 92, + "src/renderer/src/hooks/agent-hook-completion-notifications.test.ts": 264, + "src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts": 18, + "src/renderer/src/hooks/automation-agent-status-entry-change.test.ts": 7, + "src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts": 137, + "src/renderer/src/hooks/automations-changed-event-attribution.test.ts": 1424, + "src/renderer/src/hooks/composer-branch-selection.test.ts": 11, + "src/renderer/src/hooks/composer-drop-owner.test.ts": 4, + "src/renderer/src/hooks/composer-drop-upload-result.test.ts": 8, + "src/renderer/src/hooks/composer-native-file-drop.test.ts": 9, + "src/renderer/src/hooks/composer-state/composer-drop-listener.test.ts": 26, + "src/renderer/src/hooks/composer-state/draft-target-sync.test.ts": 24, + "src/renderer/src/hooks/composer-state/full-creation-execution.test.ts": 23, + "src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts": 7, + "src/renderer/src/hooks/composer-state/full-submit-orchestration.test.ts": 22, + "src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 29, + "src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 18, + "src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 10, + "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 24, + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 30, + "src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 11, + "src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 6, + "src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 43, + "src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 16, + "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 27, + "src/renderer/src/hooks/fork-push-warning.test.ts": 5, + "src/renderer/src/hooks/installed-agent-skill-discovery-cache.test.ts": 18, + "src/renderer/src/hooks/installed-agent-skill-discovery.test.ts": 9, + "src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts": 79, + "src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.test.ts": 272, + "src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts": 186, + "src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts": 10, + "src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts": 7, + "src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts": 8, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts": 12, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts": 21, + "src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts": 6, + "src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts": 111, + "src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts": 16, + "src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts": 32, + "src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts": 15, + "src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts": 6, + "src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts": 11, + "src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts": 11, + "src/renderer/src/hooks/ipc-tab-switch.test.ts": 17, + "src/renderer/src/hooks/legacy-worker-terminal-recovery-event.test.ts": 15, + "src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts": 23, + "src/renderer/src/hooks/metadata-request-cache.test.ts": 142, + "src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts": 2166, + "src/renderer/src/hooks/modal-return-focus-action.test.ts": 7, + "src/renderer/src/hooks/programmatic-scroll-marks.test.ts": 9, + "src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts": 14, + "src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts": 13, + "src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts": 39, + "src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts": 12, + "src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts": 76, + "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts": 8, + "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts": 68, + "src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts": 19, + "src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts": 108, + "src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts": 80, + "src/renderer/src/hooks/remote-workspace-target-sync.test.ts": 45, + "src/renderer/src/hooks/runtime-client-events-sync.test.ts": 66, + "src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts": 51, + "src/renderer/src/hooks/shortcut-label-cache.test.tsx": 65, + "src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts": 7, + "src/renderer/src/hooks/structured-session-completion-focus.test.ts": 1364, + "src/renderer/src/hooks/unpaired-device-auth-notification.test.ts": 6, + "src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts": 31, + "src/renderer/src/hooks/use-clipboard-text-copy-feedback.test.ts": 28, + "src/renderer/src/hooks/use-now.test.ts": 35, + "src/renderer/src/hooks/use-palette-search-evaluation-context.test.ts": 29, + "src/renderer/src/hooks/use-task-page-github-work-item-mutation-host.test.ts": 7, + "src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts": 59, + "src/renderer/src/hooks/use-window-stream-visibility.test.ts": 37, + "src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx": 57, + "src/renderer/src/hooks/useAgentDetectionTarget.test.ts": 11, + "src/renderer/src/hooks/useAppMenuPaste.test.tsx": 29, + "src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx": 13, + "src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts": 95, + "src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts": 55, + "src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts": 38, + "src/renderer/src/hooks/useAutoAckViewedAgent.test.ts": 63, + "src/renderer/src/hooks/useAutomationDispatchEvents.test.ts": 692, + "src/renderer/src/hooks/useComposerState-decisions.test.ts": 9, + "src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts": 21, + "src/renderer/src/hooks/useComposerState-host-retarget.test.ts": 7, + "src/renderer/src/hooks/useComposerState.integration.test.ts": 39, + "src/renderer/src/hooks/useDetectedAgents.test.tsx": 43, + "src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts": 6402, + "src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts": 27, + "src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx": 24, + "src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts": 21, + "src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts": 21, + "src/renderer/src/hooks/useEditorExternalWatch.test.ts": 54, + "src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 33, + "src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 47, + "src/renderer/src/hooks/useGlobalFileDrop.test.ts": 15, + "src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 90, + "src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 17, + "src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 9, + "src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts": 1128, + "src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts": 1955, + "src/renderer/src/hooks/useIpcEvents-agent-status-hook-titles.test.ts": 1349, + "src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts": 2111, + "src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts": 1997, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts": 2101, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts": 1690, + "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts": 3444, + "src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts": 1433, + "src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts": 1719, + "src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts": 1370, + "src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts": 1568, + "src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts": 1650, + "src/renderer/src/hooks/useIpcEvents-client-hosted-browser-rows.test.ts": 1604, + "src/renderer/src/hooks/useIpcEvents-close-routing-active-browser-tab.test.ts": 1787, + "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts": 2510, + "src/renderer/src/hooks/useIpcEvents-close-routing-floating-guest.test.ts": 1438, + "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts": 2721, + "src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts": 1311, + "src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts": 1102, + "src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts": 12, + "src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts": 1455, + "src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts": 1275, + "src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts": 17, + "src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts": 1943, + "src/renderer/src/hooks/useIpcEvents-silent-terminal-adoption.test.ts": 1945, + "src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts": 1453, + "src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts": 2086, + "src/renderer/src/hooks/useIpcEvents-updater-status.test.ts": 1582, + "src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts": 1559, + "src/renderer/src/hooks/useIssueMetadata.test.tsx": 27, + "src/renderer/src/hooks/useLinearProviderConnected.test.tsx": 19, + "src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx": 320, + "src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx": 207, + "src/renderer/src/hooks/useMetadataListRequest.test.tsx": 20, + "src/renderer/src/hooks/useModalReturnFocus.test.tsx": 64, + "src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx": 32, + "src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx": 55, + "src/renderer/src/hooks/useResetCountdownClock.test.ts": 23, + "src/renderer/src/hooks/useRetiredWorktreeNames.test.ts": 344, + "src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx": 85, + "src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx": 235, + "src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts": 184, + "src/renderer/src/hooks/useSidebarResize.test.ts": 9, + "src/renderer/src/hooks/useSkillFreshness.test.tsx": 65, + "src/renderer/src/hooks/useUnreadDockBadge.test.ts": 128, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts": 30, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts": 31, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.test.ts": 8, + "src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx": 46, + "src/renderer/src/hooks/viewport-size-change-listener.test.ts": 6, + "src/renderer/src/hooks/worktree-change-refresh-queue.test.ts": 14, + "src/renderer/src/hooks/worktree-head-identity-apply.test.ts": 10, + "src/renderer/src/i18n/I18nProvider.test.tsx": 36, + "src/renderer/src/i18n/integration-card-status-localization.test.ts": 7, + "src/renderer/src/i18n/intl-locale.test.ts": 121, + "src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts": 7, + "src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts": 9, + "src/renderer/src/i18n/lazy-locale.test.ts": 109, + "src/renderer/src/i18n/locale-english-regression.test.ts": 22, + "src/renderer/src/i18n/native-chat-locales.test.ts": 10, + "src/renderer/src/i18n/no-top-level-translate.test.ts": 2294, + "src/renderer/src/i18n/plugin-chrome-allowlist.test.ts": 11, + "src/renderer/src/i18n/pseudo-localization.test.ts": 8, + "src/renderer/src/i18n/relative-time-format.test.ts": 27, + "src/renderer/src/i18n/runtime-required-catalog.test.ts": 335, + "src/renderer/src/i18n/settings-status-label-localization.test.ts": 9, + "src/renderer/src/i18n/smart-workspace-jira-locales.test.ts": 8, + "src/renderer/src/i18n/technical-literal-catalog-values.test.ts": 8, + "src/renderer/src/i18n/weekday-names.test.ts": 40, + "src/renderer/src/i18n/worktree-visibility-locales.test.ts": 8, + "src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts": 45, + "src/renderer/src/lazy-modal-mount-state.test.ts": 5, + "src/renderer/src/lazy-use-ref-ratchet.test.ts": 165, + "src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts": 11, + "src/renderer/src/lib/activate-ai-vault-structured-session.test.ts": 17, + "src/renderer/src/lib/activate-tab-and-focus-pane.test.ts": 11, + "src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts": 385, + "src/renderer/src/lib/active-agent-note-send-focused-session.test.ts": 74, + "src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts": 6, + "src/renderer/src/lib/active-agent-note-send-target-detection.test.ts": 13, + "src/renderer/src/lib/active-view-persist.test.ts": 4, + "src/renderer/src/lib/activity-thread-display.test.ts": 10, + "src/renderer/src/lib/agent-background-session-launch-host.test.ts": 12, + "src/renderer/src/lib/agent-catalog-links.test.ts": 5, + "src/renderer/src/lib/agent-draft-readiness.test.ts": 8, + "src/renderer/src/lib/agent-followup-delivery.test.ts": 18659, + "src/renderer/src/lib/agent-hibernation-confirmation.test.ts": 4, + "src/renderer/src/lib/agent-hibernation-coordinator.test.ts": 163, + "src/renderer/src/lib/agent-hibernation-output-activity.test.ts": 6, + "src/renderer/src/lib/agent-hibernation-pane-age.test.ts": 9, + "src/renderer/src/lib/agent-hibernation-planner.test.ts": 30, + "src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts": 65, + "src/renderer/src/lib/agent-hibernation-visibility.test.ts": 5, + "src/renderer/src/lib/agent-launch-prompt-delivery.test.ts": 14, + "src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts": 6, + "src/renderer/src/lib/agent-launch-route-input.test.ts": 16, + "src/renderer/src/lib/agent-launch-routing-caller-census.test.ts": 552, + "src/renderer/src/lib/agent-launch-routing.test.ts": 11, + "src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts": 13, + "src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts": 48, + "src/renderer/src/lib/agent-paste-draft.test.ts": 178, + "src/renderer/src/lib/agent-picker-search.test.ts": 28, + "src/renderer/src/lib/agent-ready-wait.test.ts": 7, + "src/renderer/src/lib/agent-resume-launch-target.test.ts": 2472, + "src/renderer/src/lib/agent-row-primary-text.test.ts": 8, + "src/renderer/src/lib/agent-row-tool-preview.test.ts": 9, + "src/renderer/src/lib/agent-send-title-status.test.ts": 14, + "src/renderer/src/lib/agent-session-fork-context.test.ts": 47, + "src/renderer/src/lib/agent-session-launch-plan.test.ts": 10, + "src/renderer/src/lib/agent-skill-cli-prerequisite.test.ts": 68, + "src/renderer/src/lib/agent-skill-nav-install-status.test.ts": 5, + "src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts": 85, + "src/renderer/src/lib/agent-status-connection-ownership.test.ts": 7, + "src/renderer/src/lib/agent-status-count.test.ts": 14, + "src/renderer/src/lib/agent-status-epoch-clock.test.ts": 12, + "src/renderer/src/lib/agent-status-evidence-clock.test.ts": 6, + "src/renderer/src/lib/agent-status-terminal-title.test.ts": 11, + "src/renderer/src/lib/agent-status-worktree-attribution.test.ts": 6, + "src/renderer/src/lib/agent-status.test.ts": 31, + "src/renderer/src/lib/agent-tab-shortcuts.test.ts": 7, + "src/renderer/src/lib/agent-trust-preflight.test.ts": 6, + "src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts": 19, + "src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts": 6, + "src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts": 9, + "src/renderer/src/lib/ai-vault-resume-command.test.ts": 31, + "src/renderer/src/lib/ai-vault-resume-shell.test.ts": 5, + "src/renderer/src/lib/ai-vault-resume-target.test.ts": 15, + "src/renderer/src/lib/ai-vault-session-drag.test.ts": 15, + "src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts": 16, + "src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts": 38, + "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts": 576, + "src/renderer/src/lib/app-command-dispatch.test.ts": 6, + "src/renderer/src/lib/app-font-family.test.ts": 4, + "src/renderer/src/lib/app-menu-paste.test.ts": 49, + "src/renderer/src/lib/app-menu-selection-actions.test.ts": 4, + "src/renderer/src/lib/automation-session-observer.test.ts": 58, + "src/renderer/src/lib/automation-session-reuse.test.ts": 7, + "src/renderer/src/lib/automation-terminal-ownership.test.ts": 9, + "src/renderer/src/lib/browser-cookie-import-toast.test.ts": 18, + "src/renderer/src/lib/browser-history-match.performance.test.ts": 17, + "src/renderer/src/lib/browser-history-match.test.ts": 34, + "src/renderer/src/lib/browser-page-conversion-history.test.ts": 8, + "src/renderer/src/lib/browser-page-palette-activation.test.ts": 59, + "src/renderer/src/lib/browser-palette-page-entries.test.ts": 145, + "src/renderer/src/lib/browser-palette-search.test.ts": 28, + "src/renderer/src/lib/browser-uuid.test.ts": 5, + "src/renderer/src/lib/browser-workspace-tab-activation.test.ts": 9, + "src/renderer/src/lib/client-creation-action-policy.test.ts": 12, + "src/renderer/src/lib/cmd-j-github-url-lookup.test.ts": 8, + "src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts": 30, + "src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts": 7, + "src/renderer/src/lib/cmd-j-section-leadership.test.ts": 9, + "src/renderer/src/lib/codex-account-display-label.test.ts": 6, + "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 8, + "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 21, + "src/renderer/src/lib/codex-session-restart-route-recheck.test.ts": 13, + "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 30, + "src/renderer/src/lib/codex-session-restart.test.ts": 93, + "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 17, + "src/renderer/src/lib/codex-stale-pane-sweep.test.ts": 44, + "src/renderer/src/lib/comment-body-line-count.test.ts": 40, + "src/renderer/src/lib/comment-body-submit-state.test.ts": 31, + "src/renderer/src/lib/composer-issue-command.test.ts": 7, + "src/renderer/src/lib/composer-submit-cancellation.test.ts": 7, + "src/renderer/src/lib/connection-context.test.ts": 56, + "src/renderer/src/lib/crash-diagnostics.test.ts": 83, + "src/renderer/src/lib/create-untitled-markdown.test.ts": 13, + "src/renderer/src/lib/desktop-window-chrome.test.ts": 5, + "src/renderer/src/lib/diff-comment-compat.test.ts": 5, + "src/renderer/src/lib/diff-comments-format.test.ts": 7, + "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 11, + "src/renderer/src/lib/direct-ssh-target-scope.test.ts": 12, + "src/renderer/src/lib/doc-preview-grants.test.ts": 118, + "src/renderer/src/lib/document-theme.test.ts": 10, + "src/renderer/src/lib/editable-target.test.ts": 6, + "src/renderer/src/lib/editor-file-operation-owner.test.ts": 29, + "src/renderer/src/lib/editor-font-zoom.test.ts": 5, + "src/renderer/src/lib/ensure-hooks-confirmed.test.ts": 433, + "src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts": 162, + "src/renderer/src/lib/ensure-simulator-tab.test.ts": 5, + "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts": 7, + "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts": 16, + "src/renderer/src/lib/ephemeral-vm-workspace-target.integration.test.ts": 8, + "src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts": 13, + "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts": 8, + "src/renderer/src/lib/execute-open-editor-path-move.test.ts": 209, + "src/renderer/src/lib/external-editor-open-capability.test.ts": 5, + "src/renderer/src/lib/feature-education-telemetry.test.ts": 12, + "src/renderer/src/lib/feedback-image-attachments.test.ts": 28, + "src/renderer/src/lib/file-preview-action-visibility.test.tsx": 22, + "src/renderer/src/lib/file-preview.test.ts": 16, + "src/renderer/src/lib/file-search-result-owner.test.ts": 6, + "src/renderer/src/lib/file-search-selection.test.ts": 8, + "src/renderer/src/lib/file-type-icons.test.ts": 9, + "src/renderer/src/lib/find-query-bounds.test.ts": 4, + "src/renderer/src/lib/finished-agent-resume-resurrection.test.ts": 38, + "src/renderer/src/lib/fix-checks-agent-launch.test.ts": 262, + "src/renderer/src/lib/flatten-retained-slice.test.ts": 132, + "src/renderer/src/lib/floating-terminal.test.ts": 7, + "src/renderer/src/lib/floating-workspace-terminal-actions.test.ts": 26, + "src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts": 9, + "src/renderer/src/lib/focus-terminal-tab-surface.test.ts": 16, + "src/renderer/src/lib/folder-workspace-path-status.test.ts": 8, + "src/renderer/src/lib/foreground-terminal-tabs.test.ts": 14, + "src/renderer/src/lib/github-links.test.ts": 11, + "src/renderer/src/lib/github-pr-start-point.test.ts": 11, + "src/renderer/src/lib/github-source-runtime-context.test.ts": 8, + "src/renderer/src/lib/github-work-item-source-lookup.test.ts": 8, + "src/renderer/src/lib/github-work-item-workspace-attachment.test.ts": 10, + "src/renderer/src/lib/gitlab-links.test.ts": 14, + "src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts": 8, + "src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts": 121, + "src/renderer/src/lib/hook-command-delayed-delivery.test.ts": 11, + "src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts": 20, + "src/renderer/src/lib/http-link-destinations.test.ts": 14, + "src/renderer/src/lib/http-link-modifier-routing.test.ts": 12, + "src/renderer/src/lib/http-link-routing.test.ts": 25, + "src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts": 23, + "src/renderer/src/lib/ime-composition-keyboard-event.test.ts": 30, + "src/renderer/src/lib/ios-web-platform.test.ts": 6, + "src/renderer/src/lib/jira-source-host.test.ts": 8, + "src/renderer/src/lib/keyboard-layout/detect-option-as-alt.test.ts": 11, + "src/renderer/src/lib/keyboard-layout/input-source-id.test.ts": 4, + "src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts": 14, + "src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts": 20, + "src/renderer/src/lib/keyboard-layout/option-key-location-state.test.ts": 7, + "src/renderer/src/lib/language-detect.test.ts": 8, + "src/renderer/src/lib/large-text-control-paste.test.ts": 23, + "src/renderer/src/lib/launch-agent-background-session-remote.test.ts": 498, + "src/renderer/src/lib/launch-agent-background-session.test.ts": 360, + "src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts": 653, + "src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts": 553, + "src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts": 15, + "src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts": 544, + "src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts": 760, + "src/renderer/src/lib/launch-agent-in-new-tab.test.ts": 1687, + "src/renderer/src/lib/launch-agent-session-continuation.test.ts": 27, + "src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts": 945, + "src/renderer/src/lib/launch-ai-vault-session.test.ts": 10, + "src/renderer/src/lib/launch-structured-agent-session.test.ts": 726, + "src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts": 12, + "src/renderer/src/lib/launch-work-item-direct-agent.test.ts": 15, + "src/renderer/src/lib/launch-work-item-direct-messages.test.ts": 6, + "src/renderer/src/lib/launch-work-item-direct.test.ts": 51, + "src/renderer/src/lib/launch-worktree-background-terminals.test.ts": 305, + "src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts": 61, + "src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 10, + "src/renderer/src/lib/lazy-with-retry.right-sidebar-syntax-error.test.ts": 10, + "src/renderer/src/lib/lazy-with-retry.test.ts": 37, + "src/renderer/src/lib/left-sidebar-appearance.test.ts": 13, + "src/renderer/src/lib/linear-agent-skill-update-command.test.ts": 4, + "src/renderer/src/lib/linear-board-drag-payload.test.ts": 10, + "src/renderer/src/lib/linear-issue-context-snapshot.test.ts": 19, + "src/renderer/src/lib/linear-issue-url-lookup.test.ts": 10, + "src/renderer/src/lib/linear-issue-workspace-attachment.test.ts": 10, + "src/renderer/src/lib/linear-issue-workspace-open.test.ts": 10, + "src/renderer/src/lib/linear-linked-work-item.test.ts": 12, + "src/renderer/src/lib/linked-work-item-context.test.ts": 16, + "src/renderer/src/lib/linked-work-item-provider.test.ts": 5, + "src/renderer/src/lib/list-row-interaction.test.ts": 11, + "src/renderer/src/lib/local-path-open-guard.test.ts": 5, + "src/renderer/src/lib/local-preflight-context-cache.test.ts": 31, + "src/renderer/src/lib/local-preflight-context.test.ts": 18, + "src/renderer/src/lib/locale-text-collators.test.ts": 30, + "src/renderer/src/lib/manual-terminal-worktree-parking.test.ts": 7, + "src/renderer/src/lib/markdown-comment-blocks.test.ts": 6, + "src/renderer/src/lib/markdown-document-templates.test.ts": 9, + "src/renderer/src/lib/markdown-review-note-copy.test.ts": 5, + "src/renderer/src/lib/markdown-review-notes.test.ts": 26, + "src/renderer/src/lib/migration-unsupported-agent-entry.test.ts": 4, + "src/renderer/src/lib/mobile-terminal-tab-mount.test.ts": 7, + "src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts": 25, + "src/renderer/src/lib/monaco-diff-editor-disposal.test.ts": 13, + "src/renderer/src/lib/monaco-languages/monarch-embed-entry-recursion.test.ts": 388, + "src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts": 61, + "src/renderer/src/lib/monaco-languages/register-astro.test.ts": 32, + "src/renderer/src/lib/monaco-languages/register-jsonl.test.ts": 27, + "src/renderer/src/lib/monaco-languages/register-nim.test.ts": 22, + "src/renderer/src/lib/monaco-languages/register-svelte.test.ts": 28, + "src/renderer/src/lib/monaco-languages/register-vue.test.ts": 20, + "src/renderer/src/lib/monaco-languages/textmate-language-registration.test.ts": 10, + "src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts": 123, + "src/renderer/src/lib/monaco-peek-preview-options.test.ts": 12, + "src/renderer/src/lib/native-chat-initial-view-mode.test.ts": 13, + "src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts": 12, + "src/renderer/src/lib/native-chat-transcript-readability.test.ts": 4, + "src/renderer/src/lib/nested-repo-selected-paths.test.ts": 8, + "src/renderer/src/lib/new-workspace-composer-repo.test.ts": 7, + "src/renderer/src/lib/new-workspace-create-gates.test.ts": 4, + "src/renderer/src/lib/new-workspace-enter-guard.test.ts": 18, + "src/renderer/src/lib/new-workspace-project-options.test.ts": 21, + "src/renderer/src/lib/new-workspace-ssh-gate.test.ts": 6, + "src/renderer/src/lib/new-workspace.test.ts": 4395, + "src/renderer/src/lib/non-secure-context-crypto.repro.test.ts": 11, + "src/renderer/src/lib/notes-send-agent-targets.test.ts": 30, + "src/renderer/src/lib/onboarding-project-checklist.test.ts": 6, + "src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts": 7, + "src/renderer/src/lib/open-mobile-emulator-tab.test.ts": 17, + "src/renderer/src/lib/open-tab-occupant-agent.test.ts": 16, + "src/renderer/src/lib/orca-hook-trust.test.ts": 9, + "src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts": 278, + "src/renderer/src/lib/orchestration-setup-state.test.ts": 9, + "src/renderer/src/lib/orchestration-skill-coverage.test.ts": 20, + "src/renderer/src/lib/order-empty-query-worktrees.test.ts": 30, + "src/renderer/src/lib/palette-match/cmd-j-ranking-contract.test.ts": 15, + "src/renderer/src/lib/palette-match/match-field-allocation.test.ts": 49, + "src/renderer/src/lib/palette-match/palette-match-core.test.ts": 26, + "src/renderer/src/lib/palette-match/palette-match-performance.test.ts": 9461, + "src/renderer/src/lib/palette-match/palette-ranking.test.ts": 16, + "src/renderer/src/lib/palette-repo-resolution.test.ts": 9, + "src/renderer/src/lib/palette-type-alias-match.test.ts": 9, + "src/renderer/src/lib/pane-agent-evidence.test.ts": 23, + "src/renderer/src/lib/pane-manager/browser-mobile-driver-state.test.ts": 10, + "src/renderer/src/lib/pane-manager/browser-remote-viewer-state.test.ts": 5, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts": 22, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.test.ts": 16, + "src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts": 7, + "src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts": 10, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts": 5, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts": 23, + "src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts": 10, + "src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts": 318, + "src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts": 6, + "src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts": 23, + "src/renderer/src/lib/pane-manager/pane-divider.test.ts": 13, + "src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts": 20, + "src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts": 17, + "src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts": 16, + "src/renderer/src/lib/pane-manager/pane-fit.test.ts": 61, + "src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts": 10, + "src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts": 9, + "src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts": 52, + "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts": 26, + "src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts": 14, + "src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts": 44, + "src/renderer/src/lib/pane-manager/pane-pointer-focus.test.ts": 5, + "src/renderer/src/lib/pane-manager/pane-pty-resize-hold.test.ts": 8, + "src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts": 10, + "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts": 53, + "src/renderer/src/lib/pane-manager/pane-scroll.test.ts": 87, + "src/renderer/src/lib/pane-manager/pane-split-close.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts": 8, + "src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts": 15, + "src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.test.ts": 5, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-ack-credit.test.ts": 69, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-backlog-cap.test.ts": 110, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-disposed-writes.test.ts": 46, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-foreground-refresh.test.ts": 68, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts": 254, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-synchronized-frames.test.ts": 65, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts": 256, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts": 146, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-parity.test.ts": 1130, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-scaling.test.ts": 65, + "src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts": 26, + "src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts": 30, + "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts": 50, + "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts": 8, + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts": 38, + "src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts": 11, + "src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts": 15, + "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts": 183, + "src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts": 11, + "src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts": 41, + "src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts": 14, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-write.test.ts": 13, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts": 5, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts": 10, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts": 14, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts": 55, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts": 13, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts": 22, + "src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.test.ts": 13, + "src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts": 19, + "src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts": 19, + "src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts": 20, + "src/renderer/src/lib/pane-manager/xterm-instance-disposed.test.ts": 10, + "src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts": 5037, + "src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts": 10, + "src/renderer/src/lib/parked-terminal-host-hydration.test.ts": 12, + "src/renderer/src/lib/passive-macos-app-data-access.test.ts": 6, + "src/renderer/src/lib/paste-payload-metadata.test.ts": 11, + "src/renderer/src/lib/path-head-elision.test.ts": 5, + "src/renderer/src/lib/path.test.ts": 6, + "src/renderer/src/lib/pending-worktree-creation.test.ts": 5, + "src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts": 11, + "src/renderer/src/lib/pi-session-resume-wake.test.ts": 12, + "src/renderer/src/lib/plugin-command-execution.test.ts": 7, + "src/renderer/src/lib/plugin-command-keybindings.test.ts": 19, + "src/renderer/src/lib/pr-bot-author-overrides.test.ts": 213, + "src/renderer/src/lib/pr-comment-action-state.test.ts": 12, + "src/renderer/src/lib/pr-comment-reactions.test.ts": 6, + "src/renderer/src/lib/primary-selection-paste.test.ts": 79, + "src/renderer/src/lib/primary-selection.test.ts": 9, + "src/renderer/src/lib/project-clone-url-prefill.test.ts": 15, + "src/renderer/src/lib/project-host-clone-url.test.ts": 8, + "src/renderer/src/lib/project-host-setup-options.test.ts": 20, + "src/renderer/src/lib/project-host-workspace-target.test.ts": 17, + "src/renderer/src/lib/project-skill-runtime.test.ts": 8, + "src/renderer/src/lib/provisioned-root-create-options.test.ts": 8, + "src/renderer/src/lib/quick-workspace-agent-selection.test.ts": 7, + "src/renderer/src/lib/react-commit-cascade-install-order.test.ts": 8, + "src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx": 44, + "src/renderer/src/lib/react-commit-cascade-observer.test.ts": 150, + "src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts": 12, + "src/renderer/src/lib/react-commit-cascade-telemetry.test.ts": 108, + "src/renderer/src/lib/react-error-boundary-reporting.test.ts": 15, + "src/renderer/src/lib/react-grab-dev-gate.test.ts": 4, + "src/renderer/src/lib/react-renderer-root.test.ts": 6, + "src/renderer/src/lib/recent-workspace-tab-rows.test.ts": 19, + "src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts": 62, + "src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 36, + "src/renderer/src/lib/renderer-app-platform.test.ts": 34, + "src/renderer/src/lib/renderer-memory-profile.test.ts": 26, + "src/renderer/src/lib/repo-display-labels.test.ts": 6, + "src/renderer/src/lib/repo-runtime-owner.test.ts": 14, + "src/renderer/src/lib/repo-search.test.ts": 10, + "src/renderer/src/lib/repo-slug-cache.test.ts": 8, + "src/renderer/src/lib/repo-slug-index.test.ts": 515, + "src/renderer/src/lib/resolved-worktree-execution-host.test.ts": 7, + "src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts": 22, + "src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts": 13, + "src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts": 25, + "src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts": 65, + "src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts": 11, + "src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts": 32, + "src/renderer/src/lib/resume-sleeping-agent-session.test.ts": 126, + "src/renderer/src/lib/resume-stale-structured-agent-session.test.ts": 9, + "src/renderer/src/lib/right-sidebar-visibility.test.ts": 8, + "src/renderer/src/lib/run-quick-command-in-new-tab.test.ts": 10, + "src/renderer/src/lib/running-agent-targets.test.ts": 12, + "src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts": 9, + "src/renderer/src/lib/runtime-session-mirror-targets.test.ts": 6, + "src/renderer/src/lib/runtime-workspace-file-route.test.ts": 14, + "src/renderer/src/lib/screen-submit-shortcut.test.ts": 6, + "src/renderer/src/lib/script-textarea-rows.test.ts": 52, + "src/renderer/src/lib/scroll-cache.test.ts": 9, + "src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts": 13, + "src/renderer/src/lib/session-write-subscriber-allocation.test.ts": 21, + "src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts": 21, + "src/renderer/src/lib/session-write-subscriber.test.ts": 107, + "src/renderer/src/lib/settled-worker-wake-policy.test.ts": 25, + "src/renderer/src/lib/setup-runner.test.ts": 4, + "src/renderer/src/lib/setup-script-prompt.test.ts": 20, + "src/renderer/src/lib/sha256.test.ts": 22, + "src/renderer/src/lib/shutdown-checkpoint-guard.test.ts": 26, + "src/renderer/src/lib/sidebar-worktree-activation.test.ts": 13, + "src/renderer/src/lib/simulator-launch-coordination.test.ts": 112, + "src/renderer/src/lib/simulator-palette-search.test.ts": 29, + "src/renderer/src/lib/simulator-pane-shutdown-scheduler.test.ts": 17, + "src/renderer/src/lib/simulator-tab-palette-activation.test.ts": 32, + "src/renderer/src/lib/skill-freshness-display-status.test.ts": 16, + "src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts": 290, + "src/renderer/src/lib/smart-github-submit.test.ts": 19, + "src/renderer/src/lib/source-control-agent-action-plan.test.ts": 15, + "src/renderer/src/lib/source-control-generation-plan.test.ts": 16, + "src/renderer/src/lib/source-control-launch-agent-selection.test.ts": 12, + "src/renderer/src/lib/source-control-launch-platform.test.ts": 10, + "src/renderer/src/lib/source-control-remote-error.test.ts": 27, + "src/renderer/src/lib/sparse-preset-draft.test.ts": 21, + "src/renderer/src/lib/ssh-background-startup-delivery.test.ts": 17, + "src/renderer/src/lib/ssh-mutation-expectation.test.ts": 7, + "src/renderer/src/lib/startup-ui-hydration.test.ts": 10, + "src/renderer/src/lib/state-collection-byte-estimate.test.ts": 46, + "src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts": 361, + "src/renderer/src/lib/structured-agent-launch-settlement.test.ts": 67, + "src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts": 8, + "src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts": 12, + "src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts": 25, + "src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts": 16, + "src/renderer/src/lib/structured-agent-session-launch.test.ts": 307, + "src/renderer/src/lib/structured-agent-session-tab-activation.test.ts": 7, + "src/renderer/src/lib/tab-agent-identity-decision-table.test.ts": 438, + "src/renderer/src/lib/tab-agent-status-index.test.ts": 162, + "src/renderer/src/lib/tab-agent.test.ts": 10, + "src/renderer/src/lib/tab-has-live-pty.test.ts": 7, + "src/renderer/src/lib/tab-number-shortcuts.test.ts": 9, + "src/renderer/src/lib/terminal-contrast-correction.test.ts": 13, + "src/renderer/src/lib/terminal-file-uri-link.test.ts": 8, + "src/renderer/src/lib/terminal-input-activity-coalescing.test.ts": 12, + "src/renderer/src/lib/terminal-links-redos.test.ts": 12, + "src/renderer/src/lib/terminal-links.test.ts": 331, + "src/renderer/src/lib/terminal-pane-title-sanitization.test.ts": 7, + "src/renderer/src/lib/terminal-quick-command-project-scope.test.ts": 7, + "src/renderer/src/lib/terminal-quick-command-search.test.ts": 11, + "src/renderer/src/lib/terminal-reveal-identity.test.ts": 10, + "src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx": 16, + "src/renderer/src/lib/terminal-tab-for-pty-id.test.ts": 10, + "src/renderer/src/lib/terminal-theme.test.ts": 22, + "src/renderer/src/lib/terminal-worktree-route.test.ts": 16, + "src/renderer/src/lib/text-control-paste-ownership.test.ts": 21, + "src/renderer/src/lib/text-control-paste.test.ts": 30, + "src/renderer/src/lib/titlebar-left-chrome.test.ts": 6, + "src/renderer/src/lib/titlebar-worktree-history-controls.test.ts": 4, + "src/renderer/src/lib/tui-agent-startup.test.ts": 18, + "src/renderer/src/lib/typing-latency/diagnostic-lifecycle.test.ts": 14, + "src/renderer/src/lib/typing-latency/diagnostic-summary.test.ts": 13, + "src/renderer/src/lib/typing-latency/echo-instrumentation.test.ts": 28, + "src/renderer/src/lib/typing-latency/input-events.test.ts": 7, + "src/renderer/src/lib/typing-latency/input-source.test.ts": 8, + "src/renderer/src/lib/typing-latency/sample-window.test.ts": 10, + "src/renderer/src/lib/unread-badge-count.test.ts": 4, + "src/renderer/src/lib/update-check-click-options.test.ts": 5, + "src/renderer/src/lib/updater-beforeunload.test.ts": 8, + "src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx": 63, + "src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts": 40, + "src/renderer/src/lib/use-tab-agent-pi-identity.test.ts": 27, + "src/renderer/src/lib/use-tab-agent-process-signals.test.ts": 48, + "src/renderer/src/lib/use-tab-agent-remote-pty-selector.test.ts": 30, + "src/renderer/src/lib/use-tab-agent-retained-identity.test.ts": 36, + "src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts": 37, + "src/renderer/src/lib/use-tab-agent.test.ts": 112, + "src/renderer/src/lib/visible-overlay.test.ts": 15, + "src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts": 19, + "src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts": 10, + "src/renderer/src/lib/web-client-location.test.ts": 6, + "src/renderer/src/lib/window-label-formatter.test.ts": 13, + "src/renderer/src/lib/window-visibility-interval.test.ts": 15, + "src/renderer/src/lib/window-visibility-timeout-poller.test.ts": 10, + "src/renderer/src/lib/windows-terminal-capabilities-race.test.ts": 7, + "src/renderer/src/lib/windows-terminal-capabilities.test.ts": 64, + "src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts": 25, + "src/renderer/src/lib/work-item-link-query-bounds.test.ts": 5, + "src/renderer/src/lib/work-item-lookup-text.test.ts": 8, + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts": 257, + "src/renderer/src/lib/workspace-activation-path-gate.test.ts": 23, + "src/renderer/src/lib/workspace-activation-terminal-focus.test.ts": 270, + "src/renderer/src/lib/workspace-browser-tab-open.test.ts": 37, + "src/renderer/src/lib/workspace-composer-initial-focus.test.ts": 16, + "src/renderer/src/lib/workspace-create-error-format.test.ts": 9, + "src/renderer/src/lib/workspace-doc-address-input.test.ts": 12, + "src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts": 60, + "src/renderer/src/lib/workspace-emoji-shortcodes.test.ts": 29, + "src/renderer/src/lib/workspace-file-drag.test.ts": 7, + "src/renderer/src/lib/workspace-port-groups.test.ts": 5, + "src/renderer/src/lib/workspace-port-host-availability.test.ts": 12, + "src/renderer/src/lib/workspace-port-scan-debounce.test.ts": 12, + "src/renderer/src/lib/workspace-port-scan-publish.test.ts": 24, + "src/renderer/src/lib/workspace-session-browser-history.test.ts": 12, + "src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts": 7, + "src/renderer/src/lib/workspace-session-editor-drafts.test.ts": 9, + "src/renderer/src/lib/workspace-session-host-contention.test.ts": 18, + "src/renderer/src/lib/workspace-session-host-persistence.test.ts": 22, + "src/renderer/src/lib/workspace-session-host-split.test.ts": 14, + "src/renderer/src/lib/workspace-session-hydration-keys.test.ts": 8, + "src/renderer/src/lib/workspace-session-liveness.test.ts": 8, + "src/renderer/src/lib/workspace-session-patch.test.ts": 12, + "src/renderer/src/lib/workspace-session-persistence-gate.test.ts": 8, + "src/renderer/src/lib/workspace-session-relevant-fields.test.ts": 6, + "src/renderer/src/lib/workspace-session-staged-browser-tabs.test.ts": 5, + "src/renderer/src/lib/workspace-session.test.ts": 20, + "src/renderer/src/lib/workspace-tab-agent-metadata.test.ts": 11, + "src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts": 10, + "src/renderer/src/lib/workspace-tab-palette-activation.test.ts": 19, + "src/renderer/src/lib/workspace-tab-palette-results.test.ts": 19, + "src/renderer/src/lib/workspace-tab-palette-search.test.ts": 37, + "src/renderer/src/lib/workspace-terminal-host-authority.test.ts": 38, + "src/renderer/src/lib/worktree-activation-agent-startup.test.ts": 42, + "src/renderer/src/lib/worktree-activation-automation-filter.test.ts": 19, + "src/renderer/src/lib/worktree-activation-created-agent.test.ts": 72, + "src/renderer/src/lib/worktree-activation-default-tabs.test.ts": 33, + "src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts": 71, + "src/renderer/src/lib/worktree-activation-empty-remote.test.ts": 63, + "src/renderer/src/lib/worktree-activation-issue-command.test.ts": 21, + "src/renderer/src/lib/worktree-activation-pty-inventory.test.ts": 18, + "src/renderer/src/lib/worktree-activation-reveal.test.ts": 18, + "src/renderer/src/lib/worktree-activation-setup-script.test.ts": 67, + "src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts": 43, + "src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts": 366, + "src/renderer/src/lib/worktree-activation-web-runtime.test.ts": 198, + "src/renderer/src/lib/worktree-activity-state.test.ts": 8, + "src/renderer/src/lib/worktree-agent-activation-gate.test.ts": 36, + "src/renderer/src/lib/worktree-agent-activation-seam.test.ts": 37, + "src/renderer/src/lib/worktree-agent-structured-inventory.test.ts": 8, + "src/renderer/src/lib/worktree-attachment-label.test.ts": 6, + "src/renderer/src/lib/worktree-creation-agent-seeding.test.ts": 11, + "src/renderer/src/lib/worktree-creation-agent-seeds.test.ts": 107, + "src/renderer/src/lib/worktree-creation-chat-setup.test.ts": 23, + "src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts": 3, + "src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts": 7, + "src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts": 123, + "src/renderer/src/lib/worktree-creation-flow.test.ts": 879, + "src/renderer/src/lib/worktree-creation-structured-session.test.ts": 28, + "src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts": 9, + "src/renderer/src/lib/worktree-creation-surface.test.ts": 4, + "src/renderer/src/lib/worktree-default-display-name.test.ts": 11, + "src/renderer/src/lib/worktree-display-name-order.test.ts": 13, + "src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts": 12, + "src/renderer/src/lib/worktree-git-identity-display.test.ts": 4, + "src/renderer/src/lib/worktree-jump-navigation.test.ts": 21, + "src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts": 13, + "src/renderer/src/lib/worktree-operation-generation.test.ts": 7, + "src/renderer/src/lib/worktree-operation-route.test.ts": 20, + "src/renderer/src/lib/worktree-palette-comment-snippet.test.ts": 13, + "src/renderer/src/lib/worktree-palette-create-action.test.ts": 7, + "src/renderer/src/lib/worktree-palette-multi-keyword.test.ts": 143, + "src/renderer/src/lib/worktree-palette-review-match.test.ts": 9, + "src/renderer/src/lib/worktree-palette-runtime-owner-identity.test.ts": 14, + "src/renderer/src/lib/worktree-palette-search.test.ts": 62, + "src/renderer/src/lib/worktree-palette-task-url-match.test.ts": 25, + "src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts": 53, + "src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts": 30, + "src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts": 30, + "src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts": 411, + "src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts": 80, + "src/renderer/src/lib/worktree-runtime-owner-index.test.ts": 7, + "src/renderer/src/lib/worktree-runtime-owner.test.ts": 64, + "src/renderer/src/lib/worktree-sort-order-host-split.test.ts": 5, + "src/renderer/src/lib/worktree-sort-order-persistence.test.ts": 29, + "src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts": 15, + "src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts": 4, + "src/renderer/src/lib/worktree-status.interrupted.test.ts": 8, + "src/renderer/src/lib/worktree-status.test.ts": 16, + "src/renderer/src/lib/worktree-visit-recency.test.ts": 6, + "src/renderer/src/renderer-node-builtin-boundary.test.ts": 1808, + "src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts": 11, + "src/renderer/src/runtime/agent-session-operation-id.test.ts": 6, + "src/renderer/src/runtime/browser-client-host-identity.test.ts": 7, + "src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts": 221, + "src/renderer/src/runtime/browser-workspace-tab-close-plan.test.ts": 11, + "src/renderer/src/runtime/browser-workspace-tab-close.test.ts": 21, + "src/renderer/src/runtime/client-hosted-browser-close-intent-replay.test.ts": 11, + "src/renderer/src/runtime/client-hosted-browser-close-intents.test.ts": 16, + "src/renderer/src/runtime/client-hosted-browser-row-close.test.ts": 13, + "src/renderer/src/runtime/close-mirrored-editor-tab.test.ts": 61, + "src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts": 47, + "src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts": 13, + "src/renderer/src/runtime/github-check-details-timeout.test.ts": 14, + "src/renderer/src/runtime/gitlab-job-trace-client.test.ts": 25, + "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts": 37, + "src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx": 120, + "src/renderer/src/runtime/host-session-mirror-settle-census.test.ts": 76, + "src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx": 165, + "src/renderer/src/runtime/host-session-snapshot-authority-client-hosted.test.ts": 4, + "src/renderer/src/runtime/local-runtime-capabilities.test.ts": 10, + "src/renderer/src/runtime/local-session-tab-close-owner.test.ts": 9, + "src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts": 17, + "src/renderer/src/runtime/local-structured-session-retired-epoch-repair.test.ts": 21, + "src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts": 35, + "src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts": 28, + "src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts": 210, + "src/renderer/src/runtime/local-structured-session-tabs-sync/retired-epoch-repair.test.ts": 16, + "src/renderer/src/runtime/mirrored-agent-status-clock-skew.test.ts": 51, + "src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts": 58, + "src/renderer/src/runtime/mobile-markdown-bridge.test.ts": 31, + "src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts": 6, + "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts": 146, + "src/renderer/src/runtime/remote-agent-row-last-assistant-message.test.ts": 98, + "src/renderer/src/runtime/remote-agent-session-launch.test.ts": 11, + "src/renderer/src/runtime/remote-host-file-delete-repro.test.ts": 30, + "src/renderer/src/runtime/remote-host-file-open-repro.test.ts": 16, + "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts": 11, + "src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts": 28, + "src/renderer/src/runtime/remote-runtime-terminal-end-verdict.test.ts": 49, + "src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts": 47, + "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts": 296, + "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts": 17, + "src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts": 9, + "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts": 145, + "src/renderer/src/runtime/remote-server-install-failure-probe.test.ts": 9, + "src/renderer/src/runtime/remote-server-parity.test.ts": 28, + "src/renderer/src/runtime/remote-server-restart-wait.test.ts": 7, + "src/renderer/src/runtime/remote-server-update-coordinator.test.ts": 24, + "src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts": 14, + "src/renderer/src/runtime/remote-terminal-stream-watchdog.test.ts": 13, + "src/renderer/src/runtime/restored-client-hosted-browser-host-attach.test.ts": 16, + "src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts": 15, + "src/renderer/src/runtime/runtime-client-events.test.ts": 9, + "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts": 148, + "src/renderer/src/runtime/runtime-file-client-download.test.ts": 21, + "src/renderer/src/runtime/runtime-file-client-external-import.test.ts": 20, + "src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts": 17, + "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts": 129, + "src/renderer/src/runtime/runtime-file-client-watch.test.ts": 73, + "src/renderer/src/runtime/runtime-file-client.test.ts": 18, + "src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts": 22, + "src/renderer/src/runtime/runtime-file-search-bounds.test.ts": 7, + "src/renderer/src/runtime/runtime-git-client-api-contract.test.ts": 4, + "src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts": 13, + "src/renderer/src/runtime/runtime-git-client-merge.test.ts": 11, + "src/renderer/src/runtime/runtime-git-client.test.ts": 32, + "src/renderer/src/runtime/runtime-hooks-client.test.ts": 9, + "src/renderer/src/runtime/runtime-host-connection-state.test.ts": 7, + "src/renderer/src/runtime/runtime-jira-client.test.ts": 17, + "src/renderer/src/runtime/runtime-jira-payload-stream.test.ts": 10, + "src/renderer/src/runtime/runtime-linear-client.test.ts": 25, + "src/renderer/src/runtime/runtime-provider-accounts-client.test.ts": 37, + "src/renderer/src/runtime/runtime-provider-search-bounds.test.ts": 7, + "src/renderer/src/runtime/runtime-repo-client.test.ts": 7, + "src/renderer/src/runtime/runtime-repo-search-bounds.test.ts": 7, + "src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts": 7, + "src/renderer/src/runtime/runtime-rpc-client.test.ts": 22, + "src/renderer/src/runtime/runtime-rpc-result.test.ts": 9, + "src/renderer/src/runtime/runtime-server-directory-browser.test.ts": 9, + "src/renderer/src/runtime/runtime-skills-client.test.ts": 12, + "src/renderer/src/runtime/runtime-skills-delete-client.test.ts": 15, + "src/renderer/src/runtime/runtime-terminal-inspection.test.ts": 683, + "src/renderer/src/runtime/runtime-terminal-stream.test.ts": 286, + "src/renderer/src/runtime/runtime-worktree-selector.test.ts": 6, + "src/renderer/src/runtime/structured-agent-session-client.test.ts": 14, + "src/renderer/src/runtime/structured-agent-session-close.test.ts": 8, + "src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts": 11, + "src/renderer/src/runtime/structured-agent-session-status-feed.test.ts": 18, + "src/renderer/src/runtime/structured-conversation-tab-replacement.test.ts": 20, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection-join.test.ts": 51, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts": 20, + "src/renderer/src/runtime/sync-runtime-graph-automation-leaf.test.ts": 16, + "src/renderer/src/runtime/sync-runtime-graph-browser.test.ts": 15, + "src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts": 83, + "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts": 19, + "src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts": 19, + "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts": 29, + "src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts": 32, + "src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts": 128, + "src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts": 55, + "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts": 24, + "src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts": 696, + "src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts": 13, + "src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts": 30, + "src/renderer/src/runtime/sync-runtime-graph-workspace-publication.test.ts": 19, + "src/renderer/src/runtime/sync-runtime-graph.test.ts": 20, + "src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts": 4, + "src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts": 22, + "src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts": 78, + "src/renderer/src/runtime/use-worktree-runtime-target.test.ts": 58, + "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts": 16, + "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts": 4, + "src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts": 9, + "src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts": 81, + "src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts": 112, + "src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts": 283, + "src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts": 231, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts": 118, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts": 32, + "src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts": 320, + "src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts": 22, + "src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts": 27, + "src/renderer/src/runtime/web-runtime-session-tab-move.test.ts": 11, + "src/renderer/src/runtime/web-runtime-session-tab-props.test.ts": 116, + "src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts": 38, + "src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts": 35, + "src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts": 590, + "src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts": 16, + "src/renderer/src/runtime/web-runtime-session.test.ts": 16, + "src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts": 4, + "src/renderer/src/runtime/web-session-browser-placement.test.ts": 8, + "src/renderer/src/runtime/web-session-close-intent.test.ts": 7, + "src/renderer/src/runtime/web-session-existing-tab-index.test.ts": 7, + "src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts": 9, + "src/renderer/src/runtime/web-session-intent-owner.test.ts": 7, + "src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts": 333, + "src/renderer/src/runtime/web-session-structured-tab-focus.test.ts": 11, + "src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts": 40, + "src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts": 19, + "src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts": 29, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-page-content.test.ts": 22, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-placement.test.ts": 36, + "src/renderer/src/runtime/web-session-tabs-sync-diff-focus-steal.test.ts": 19, + "src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts": 26, + "src/renderer/src/runtime/web-session-tabs-sync-host-restart-browser-rows.test.ts": 22, + "src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts": 138, + "src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts": 21, + "src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-layout-groups.test.ts": 22, + "src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts": 54, + "src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts": 109, + "src/renderer/src/runtime/web-session-tabs-sync-restored-browser-rows.test.ts": 17, + "src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts": 36, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-adoption.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-authority.test.ts": 12, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.ts": 5, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts": 30, + "src/renderer/src/runtime/web-session-tabs-sync-tracking-teardown.test.ts": 18, + "src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx": 113, + "src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx": 170, + "src/renderer/src/runtime/web-session-tabs-sync.test.ts": 29, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts": 7, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts": 9, + "src/renderer/src/runtime/web-session-terminal-handle-events.test.ts": 6, + "src/renderer/src/runtime/web-session-terminal-orphan-absence-across-republication.test.ts": 11, + "src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts": 43, + "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts": 49, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts": 179, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts": 10, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-prior-removal.test.ts": 15, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts": 295, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts": 168, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts": 122, + "src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts": 33, + "src/renderer/src/runtime/web-session-terminal-recovery-snapshot-validation.test.ts": 155, + "src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts": 11, + "src/renderer/src/runtime/window-visibility-subscription-parking.test.ts": 26, + "src/renderer/src/runtime/worktree-create-base.test.ts": 4, + "src/renderer/src/ssh/ssh-connect-in-flight.test.ts": 21, + "src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts": 15, + "src/renderer/src/ssh/ssh-connect-verb.test.ts": 7, + "src/renderer/src/ssh/ssh-connection-recoverability.test.ts": 6, + "src/renderer/src/startup/active-workspace-ssh-targets.test.ts": 7, + "src/renderer/src/startup/ssh-startup-reconnect.test.ts": 16, + "src/renderer/src/startup/startup-ssh-connection-restore.test.ts": 13, + "src/renderer/src/store/active-terminal-chrome-selector.test.ts": 17, + "src/renderer/src/store/always-mounted-selector-scan-cost.test.ts": 116, + "src/renderer/src/store/copy-on-write-record.test.ts": 6, + "src/renderer/src/store/folder-workspaces/folder-workspace-catalog.test.ts": 14, + "src/renderer/src/store/pinned-tab-close-guard.test.ts": 13, + "src/renderer/src/store/plugin-language-packs.test.ts": 21, + "src/renderer/src/store/plugin-panels.test.ts": 13, + "src/renderer/src/store/project-host-setup-selector.test.ts": 18, + "src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts": 12, + "src/renderer/src/store/projects/project-wsl-filesystem-boundary-advisory.test.ts": 13, + "src/renderer/src/store/react-commit-cascade-write-probe.test.ts": 5, + "src/renderer/src/store/repos/safe-auto-fork-sync.test.ts": 8, + "src/renderer/src/store/right-sidebar-route.test.ts": 6, + "src/renderer/src/store/running-terminal-close-confirm.test.ts": 7, + "src/renderer/src/store/selectors.test.ts": 44, + "src/renderer/src/store/slices/active-tab-owner-worktree.test.ts": 12, + "src/renderer/src/store/slices/activity-cleared-at.test.ts": 82, + "src/renderer/src/store/slices/agent-generated-tab-title.test.ts": 141, + "src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts": 53, + "src/renderer/src/store/slices/agent-pane-authority.test.ts": 110, + "src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts": 99, + "src/renderer/src/store/slices/agent-status-batch.test.ts": 154, + "src/renderer/src/store/slices/agent-status-drop-ipc.test.ts": 705, + "src/renderer/src/store/slices/agent-status-drop.test.ts": 88, + "src/renderer/src/store/slices/agent-status-freshness-cache.test.ts": 10, + "src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts": 20, + "src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts": 7, + "src/renderer/src/store/slices/agent-status-live-map-leak.test.ts": 10291, + "src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts": 90, + "src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts": 107, + "src/renderer/src/store/slices/agent-status-pane-keyed-records.test.ts": 26, + "src/renderer/src/store/slices/agent-status-pr-refresh-handoff.test.ts": 89, + "src/renderer/src/store/slices/agent-status-provider-session.test.ts": 117, + "src/renderer/src/store/slices/agent-status-quit-capture-resumable-agents.test.ts": 64, + "src/renderer/src/store/slices/agent-status-quit-capture.test.ts": 170, + "src/renderer/src/store/slices/agent-status-reminted-pane-key.test.ts": 41, + "src/renderer/src/store/slices/agent-status-retained-leak.test.ts": 846, + "src/renderer/src/store/slices/agent-status-retention-prefix-sweep.test.ts": 85, + "src/renderer/src/store/slices/agent-status-runtime-orchestration.test.ts": 96, + "src/renderer/src/store/slices/agent-status-session-boundary-done.test.ts": 60, + "src/renderer/src/store/slices/agent-status-ssh-connection-clear.test.ts": 64, + "src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts": 128, + "src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts": 50, + "src/renderer/src/store/slices/agent-status.test.ts": 54, + "src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts": 587, + "src/renderer/src/store/slices/browser-cleanup-close.test.ts": 151, + "src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts": 191, + "src/renderer/src/store/slices/browser-page-conversion.test.ts": 125, + "src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts": 221, + "src/renderer/src/store/slices/browser-remote-tab-creation.test.ts": 42, + "src/renderer/src/store/slices/browser-session-host-selection.test.ts": 225, + "src/renderer/src/store/slices/browser-session-profiles.test.ts": 21, + "src/renderer/src/store/slices/browser-webview-cleanup.test.ts": 7, + "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts": 171, + "src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts": 18, + "src/renderer/src/store/slices/browser.test.ts": 43, + "src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts": 89, + "src/renderer/src/store/slices/cmd-j-create-actions.test.ts": 90, + "src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts": 48, + "src/renderer/src/store/slices/degraded-repo-hydration.test.ts": 81, + "src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts": 55, + "src/renderer/src/store/slices/detected-agents.test.ts": 138, + "src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts": 11, + "src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts": 9, + "src/renderer/src/store/slices/diffComments.test.ts": 122, + "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts": 62, + "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts": 39, + "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts": 147, + "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts": 11, + "src/renderer/src/store/slices/editor-branch-diff-snapshots.test.ts": 19, + "src/renderer/src/store/slices/editor-branch-line-total.test.ts": 16, + "src/renderer/src/store/slices/editor-check-details-tabs.test.ts": 27, + "src/renderer/src/store/slices/editor-close-file-cleanup.test.ts": 255, + "src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts": 31, + "src/renderer/src/store/slices/editor-hydration-scaling.test.ts": 85, + "src/renderer/src/store/slices/editor-markdown-link-activation.test.ts": 38, + "src/renderer/src/store/slices/editor-markdown-view-state.test.ts": 37, + "src/renderer/src/store/slices/editor-open-diff.test.ts": 42, + "src/renderer/src/store/slices/editor-read-only-tabs.test.ts": 16, + "src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts": 40, + "src/renderer/src/store/slices/editor-rekey-open-files.test.ts": 75, + "src/renderer/src/store/slices/editor-remote-branch-actions.test.ts": 62, + "src/renderer/src/store/slices/editor-rich-markdown-size-override.test.ts": 14, + "src/renderer/src/store/slices/editor-right-sidebar-state.test.ts": 24, + "src/renderer/src/store/slices/editor-session-duplicate-restore.test.ts": 84, + "src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts": 59, + "src/renderer/src/store/slices/editor-tab-placement.test.ts": 30, + "src/renderer/src/store/slices/editor/actions/markdown-preview-actions.test.ts": 9, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts": 512, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts": 143, + "src/renderer/src/store/slices/editor/git/git-status-reconciliation.perf.test.ts": 36, + "src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts": 144, + "src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts": 388, + "src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts": 188, + "src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts": 75, + "src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts": 5, + "src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts": 221, + "src/renderer/src/store/slices/github-checks-cache.test.ts": 46, + "src/renderer/src/store/slices/github-checks.test.ts": 9, + "src/renderer/src/store/slices/github-issue-source-indicator-suppression.test.ts": 45, + "src/renderer/src/store/slices/github-issue-state-machine.test.ts": 35, + "src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts": 35, + "src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts": 20, + "src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts": 26, + "src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts": 27, + "src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts": 33, + "src/renderer/src/store/slices/github-pr-checks-fetch.test.ts": 121, + "src/renderer/src/store/slices/github-pr-comments.test.ts": 89, + "src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts": 28, + "src/renderer/src/store/slices/github-pr-refresh-hosted-review-cache-leak.test.ts": 802, + "src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts": 218, + "src/renderer/src/store/slices/github-pr-refresh-sequences-leak.test.ts": 16, + "src/renderer/src/store/slices/github-pr-refresh-states-leak.test.ts": 4478, + "src/renderer/src/store/slices/github-project-request-coordination.test.ts": 20, + "src/renderer/src/store/slices/github-project-row-owner.test.ts": 6, + "src/renderer/src/store/slices/github-project-view-tables.test.ts": 60, + "src/renderer/src/store/slices/github-provider-request-concurrency.test.ts": 19, + "src/renderer/src/store/slices/github-refresh-sweep.test.ts": 252, + "src/renderer/src/store/slices/github-repo-lookup-index.test.ts": 4, + "src/renderer/src/store/slices/github-review-thread-actions.test.ts": 24, + "src/renderer/src/store/slices/github-work-item-cache-identity.test.ts": 13, + "src/renderer/src/store/slices/github-work-items-error-envelope.test.ts": 43, + "src/renderer/src/store/slices/github-work-items-pagination.test.ts": 29, + "src/renderer/src/store/slices/github-work-items-query-bounds.test.ts": 10, + "src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts": 218, + "src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts": 85, + "src/renderer/src/store/slices/hosted-review-cache-race.test.ts": 15, + "src/renderer/src/store/slices/hosted-review-cache.test.ts": 69, + "src/renderer/src/store/slices/hosted-review.test.ts": 20, + "src/renderer/src/store/slices/jira.test.ts": 41, + "src/renderer/src/store/slices/linear-credential-error-recovery.test.ts": 31, + "src/renderer/src/store/slices/linear-invalidation.test.ts": 23, + "src/renderer/src/store/slices/linear-issue-cache-refresh.test.ts": 17, + "src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts": 40, + "src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts": 20, + "src/renderer/src/store/slices/linear.test.ts": 13, + "src/renderer/src/store/slices/local-detected-agent-state.test.ts": 12, + "src/renderer/src/store/slices/memory.test.ts": 7, + "src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts": 84, + "src/renderer/src/store/slices/new-issue-draft.test.ts": 7, + "src/renderer/src/store/slices/new-markdown.test.ts": 23, + "src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts": 80, + "src/renderer/src/store/slices/orca-profiles.test.ts": 89, + "src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts": 8, + "src/renderer/src/store/slices/pane-foreground-agent.test.ts": 57, + "src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts": 9, + "src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts": 9, + "src/renderer/src/store/slices/preflight.test.ts": 26, + "src/renderer/src/store/slices/project-group-removal-targets.test.ts": 8, + "src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts": 50, + "src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts": 137, + "src/renderer/src/store/slices/rate-limits.test.ts": 7, + "src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts": 6, + "src/renderer/src/store/slices/recently-closed-tabs.test.ts": 211, + "src/renderer/src/store/slices/remote-server-updates.integration.test.ts": 15, + "src/renderer/src/store/slices/repo-identity-reconcile.test.ts": 12, + "src/renderer/src/store/slices/repo-owner-cache-identity.test.ts": 5, + "src/renderer/src/store/slices/repo-reorder-host-split.test.ts": 5, + "src/renderer/src/store/slices/repos-add-races.test.ts": 56, + "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts": 76, + "src/renderer/src/store/slices/repos-all-hosts-generation.test.ts": 114, + "src/renderer/src/store/slices/repos-all-hosts.test.ts": 148, + "src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts": 88, + "src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts": 117, + "src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts": 91, + "src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts": 32, + "src/renderer/src/store/slices/repos-host-identity-routing.test.ts": 191, + "src/renderer/src/store/slices/repos-manual-order-hydration.test.ts": 172, + "src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts": 71, + "src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts": 57, + "src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts": 52, + "src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts": 349, + "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts": 46, + "src/renderer/src/store/slices/repos-project-group-create-race.test.ts": 101, + "src/renderer/src/store/slices/repos-project-groups-delete.test.ts": 86, + "src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts": 73, + "src/renderer/src/store/slices/repos-project-groups.test.ts": 200, + "src/renderer/src/store/slices/repos-project-host-capability.test.ts": 93, + "src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts": 50, + "src/renderer/src/store/slices/repos-project-runtime.test.ts": 77, + "src/renderer/src/store/slices/repos-refresh-identity.test.ts": 128, + "src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts": 57, + "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts": 87, + "src/renderer/src/store/slices/repos-runtime-project-groups.test.ts": 56, + "src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts": 43, + "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts": 194, + "src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts": 52, + "src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts": 67, + "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts": 80, + "src/renderer/src/store/slices/repos-stale-fetch.test.ts": 93, + "src/renderer/src/store/slices/repos-update-serialization.test.ts": 52, + "src/renderer/src/store/slices/repos.runtime-fallback.test.ts": 73, + "src/renderer/src/store/slices/repos.test.ts": 185, + "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts": 106, + "src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts": 45, + "src/renderer/src/store/slices/runtime-environment-ssh.test.ts": 93, + "src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts": 50, + "src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts": 189, + "src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts": 94, + "src/renderer/src/store/slices/runtime-status-first-publication.test.ts": 6, + "src/renderer/src/store/slices/runtime-status-refresh-diagnostics.test.ts": 7, + "src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts": 9, + "src/renderer/src/store/slices/runtime-status-snapshot.test.ts": 8, + "src/renderer/src/store/slices/runtime-status.test.ts": 187, + "src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts": 37, + "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts": 13, + "src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts": 46, + "src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts": 211, + "src/renderer/src/store/slices/settings-search-state.test.ts": 8, + "src/renderer/src/store/slices/settings.test.ts": 338, + "src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts": 78, + "src/renderer/src/store/slices/sparse-presets.test.ts": 26, + "src/renderer/src/store/slices/ssh.test.ts": 89, + "src/renderer/src/store/slices/stale-runtime-host-rows.test.ts": 15, + "src/renderer/src/store/slices/store-active-worktree-selection.test.ts": 87, + "src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts": 94, + "src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts": 159, + "src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts": 88, + "src/renderer/src/store/slices/store-create-tab-id-hint.test.ts": 57, + "src/renderer/src/store/slices/store-session-browser-hydration.test.ts": 117, + "src/renderer/src/store/slices/store-session-cascades.test.ts": 86, + "src/renderer/src/store/slices/store-session-editor-hydration.test.ts": 111, + "src/renderer/src/store/slices/store-session-terminal-activity.test.ts": 169, + "src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts": 78, + "src/renderer/src/store/slices/store-session-workspace-hydration.test.ts": 84, + "src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts": 122, + "src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts": 147, + "src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts": 96, + "src/renderer/src/store/slices/store-sleep-runtime-convergence.test.ts": 448, + "src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts": 49, + "src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts": 141, + "src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts": 7, + "src/renderer/src/store/slices/tab-group-reference-repair.test.ts": 15, + "src/renderer/src/store/slices/tab-group-state.test.ts": 19, + "src/renderer/src/store/slices/tab-view-mode.test.ts": 86, + "src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts": 27, + "src/renderer/src/store/slices/tabs-empty-split-activation.test.ts": 29, + "src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts": 12, + "src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts": 15, + "src/renderer/src/store/slices/tabs-hydration.test.ts": 12, + "src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts": 68, + "src/renderer/src/store/slices/tabs-model-reconciliation.test.ts": 83, + "src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts": 185, + "src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts": 88, + "src/renderer/src/store/slices/tabs-session-hydration.test.ts": 67, + "src/renderer/src/store/slices/tabs-unread-and-focus.test.ts": 71, + "src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts": 423, + "src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts": 12, + "src/renderer/src/store/slices/tabs/tabs-reconciliation-batch-identity.test.ts": 49, + "src/renderer/src/store/slices/task-creation-drafts.test.ts": 6, + "src/renderer/src/store/slices/terminal-helpers.test.ts": 11, + "src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts": 51, + "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts": 63, + "src/renderer/src/store/slices/terminal-orphan-helpers.test.ts": 8, + "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts": 33, + "src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts": 92, + "src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts": 36, + "src/renderer/src/store/slices/terminal-quick-command-hosts.test.ts": 132, + "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts": 80, + "src/renderer/src/store/slices/terminal-tab-id-hydration.test.ts": 38, + "src/renderer/src/store/slices/terminal-tab-owner-index.test.ts": 6, + "src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts": 151, + "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts": 172, + "src/renderer/src/store/slices/terminal-tab-retirement.test.ts": 15, + "src/renderer/src/store/slices/terminal-tab-title-batch.test.ts": 147, + "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts": 85, + "src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts": 61, + "src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts": 87, + "src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts": 76, + "src/renderer/src/store/slices/terminals-hydration.test.ts": 145, + "src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts": 17, + "src/renderer/src/store/slices/ui-agent-send-target.test.ts": 58, + "src/renderer/src/store/slices/ui-contextual-tours.test.ts": 51, + "src/renderer/src/store/slices/ui-feature-interactions.test.ts": 31, + "src/renderer/src/store/slices/ui-hydration-view-layout.test.ts": 46, + "src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts": 11, + "src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts": 45, + "src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts": 184, + "src/renderer/src/store/slices/ui-new-workspace-draft.test.ts": 8, + "src/renderer/src/store/slices/ui-notice-dismissals.test.ts": 52, + "src/renderer/src/store/slices/ui-page-navigation.test.ts": 51, + "src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts": 6, + "src/renderer/src/store/slices/usage-web-client-fallback.test.ts": 12, + "src/renderer/src/store/slices/workspace-cleanup-browse.test.ts": 21, + "src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts": 174, + "src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts": 285, + "src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts": 71, + "src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts": 7, + "src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts": 62, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts": 5, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts": 584, + "src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts": 44, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts": 99, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts": 143, + "src/renderer/src/store/slices/workspace-document-title-refresh.test.ts": 48, + "src/renderer/src/store/slices/workspace-space.test.ts": 11, + "src/renderer/src/store/slices/worktree-by-id-index.test.ts": 6, + "src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts": 12, + "src/renderer/src/store/slices/worktree-helpers.test.ts": 6, + "src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts": 11, + "src/renderer/src/store/slices/worktree-meta-update-application.test.ts": 8, + "src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts": 21, + "src/renderer/src/store/slices/worktree-nav-history.test.ts": 12, + "src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts": 91, + "src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts": 78, + "src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts": 79, + "src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts": 14, + "src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts": 32, + "src/renderer/src/store/slices/worktrees-activity-persistence.test.ts": 66, + "src/renderer/src/store/slices/worktrees-create-base-status.test.ts": 33, + "src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts": 27, + "src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts": 42, + "src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts": 173, + "src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts": 25, + "src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts": 40, + "src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts": 25, + "src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts": 87, + "src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts": 17, + "src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts": 488, + "src/renderer/src/store/slices/worktrees-hydration-purge.test.ts": 59, + "src/renderer/src/store/slices/worktrees-identity-migration.test.ts": 292, + "src/renderer/src/store/slices/worktrees-lineage-state.test.ts": 41, + "src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts": 69, + "src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts": 43, + "src/renderer/src/store/slices/worktrees-pending-creation-state.test.ts": 70, + "src/renderer/src/store/slices/worktrees-purge-terminal-state.test.ts": 11, + "src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts": 35, + "src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts": 49, + "src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts": 60, + "src/renderer/src/store/slices/worktrees-runtime-connection-generation-fence.test.ts": 81, + "src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts": 25, + "src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts": 15, + "src/renderer/src/store/slices/worktrees-unread-state.test.ts": 19, + "src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts": 27, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts": 8, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-unavailable-reason.test.ts": 7, + "src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts": 14, + "src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts": 6, + "src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts": 7, + "src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts": 9, + "src/renderer/src/store/slices/worktrees/teardown/worktree-teardown-array-identity.test.ts": 9, + "src/renderer/src/store/store-identity-churn-probe.test.ts": 19, + "src/renderer/src/store/store-listener-census.test.ts": 7, + "src/renderer/src/store/terminals/restored-relay-session-identity.test.ts": 88, + "src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts": 68, + "src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx": 113, + "src/renderer/src/store/terminals/terminal-shutdown-guards-identity.test.ts": 5, + "src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts": 7, + "src/renderer/src/store/terminals/terminal-tab-close-map-identity.test.ts": 68, + "src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts": 86, + "src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts": 9, + "src/renderer/src/store/terminals/workspace-terminal-placeholders.test.ts": 10, + "src/renderer/src/store/worktree-diff-comments-selector.test.ts": 53, + "src/renderer/src/store/worktree-host-collision-index.test.ts": 7, + "src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts": 5, + "src/renderer/src/web/preload-api/web-gitlab-api.test.ts": 23, + "src/renderer/src/web/web-clipboard-copy-fallback.test.ts": 12, + "src/renderer/src/web/web-clipboard-copy-terminal-selection.test.ts": 12, + "src/renderer/src/web/web-file-mutation-methods.test.ts": 14, + "src/renderer/src/web/web-pairing.test.ts": 11, + "src/renderer/src/web/web-preload-api-agent-providers.test.ts": 341, + "src/renderer/src/web/web-preload-api-clipboard.test.ts": 1137, + "src/renderer/src/web/web-preload-api-composition.test.ts": 443, + "src/renderer/src/web/web-preload-api-filesystem.test.ts": 413, + "src/renderer/src/web/web-preload-api-git.test.ts": 391, + "src/renderer/src/web/web-preload-api-github.test.ts": 312, + "src/renderer/src/web/web-preload-api-gitlab.test.ts": 596, + "src/renderer/src/web/web-preload-api-keybindings.test.ts": 444, + "src/renderer/src/web/web-preload-api-runtime-calls.test.ts": 755, + "src/renderer/src/web/web-preload-api-runtime-environment.test.ts": 1195, + "src/renderer/src/web/web-preload-api-settings.test.ts": 1562, + "src/renderer/src/web/web-preload-api-ssh.test.ts": 640, + "src/renderer/src/web/web-preload-api-ui.test.ts": 901, + "src/renderer/src/web/web-preload-api-workspace-catalog.test.ts": 903, + "src/renderer/src/web/web-preload-updater-package-recovery.test.ts": 370, + "src/renderer/src/web/web-runtime-client-export-parity.test.ts": 6, + "src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts": 64, + "src/renderer/src/web/web-runtime-client-heartbeat.test.ts": 42, + "src/renderer/src/web/web-runtime-client-timeout-budget.test.ts": 45, + "src/renderer/src/web/web-runtime-client.test.ts": 351, + "src/renderer/src/web/web-runtime-connection-frame-router.test.ts": 6, + "src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts": 55, + "src/renderer/src/web/web-runtime-status-owner.test.ts": 651, + "src/renderer/src/web/web-viewport-shell.test.ts": 8, + "src/renderer/src/web/web-workspace-session.test.ts": 8, + "src/shared/add-repo-existing-workspaces-telemetry.test.ts": 9, + "src/shared/agent-cli-flag-detection.test.ts": 7, + "src/shared/agent-cli-install-dir-fallback.test.ts": 26, + "src/shared/agent-decorative-title-signature.test.ts": 11, + "src/shared/agent-detection.test.ts": 57, + "src/shared/agent-feature-install-commands.test.ts": 13, + "src/shared/agent-hook-endpoint-file.test.ts": 5, + "src/shared/agent-hook-endpoint-temp-cleanup.test.ts": 14, + "src/shared/agent-hook-listener-antigravity.test.ts": 17, + "src/shared/agent-hook-listener-claude-compatible-vendors.test.ts": 20, + "src/shared/agent-hook-listener-claude-subagents.test.ts": 21, + "src/shared/agent-hook-listener-claude-turn-state.test.ts": 19, + "src/shared/agent-hook-listener-command-code-transcript.test.ts": 114, + "src/shared/agent-hook-listener-extraction-characterization.test.ts": 23, + "src/shared/agent-hook-listener-grok.test.ts": 32, + "src/shared/agent-hook-listener-hermes-codex-droid.test.ts": 13, + "src/shared/agent-hook-listener-interactive-prompts.test.ts": 17, + "src/shared/agent-hook-listener-pi-compatible.test.ts": 15, + "src/shared/agent-hook-listener-relay-dependency.test.ts": 22, + "src/shared/agent-hook-listener-roster-retention.test.ts": 54, + "src/shared/agent-hook-listener-session-replacement.test.ts": 15, + "src/shared/agent-hook-listener-startless-child-lifecycle.test.ts": 22, + "src/shared/agent-hook-listener-transport.test.ts": 49, + "src/shared/agent-hook-listener/transcript-reader.test.ts": 93, + "src/shared/agent-hook-relay.test.ts": 9, + "src/shared/agent-hook-request-body-memory.test.ts": 53, + "src/shared/agent-hook-spool-read.test.ts": 9, + "src/shared/agent-hook-status-cache.test.ts": 5, + "src/shared/agent-hook-transport-interference.test.ts": 9, + "src/shared/agent-kind.test.ts": 9, + "src/shared/agent-launch-remote.test.ts": 7, + "src/shared/agent-model-probe-spec.test.ts": 9, + "src/shared/agent-notification-id.test.ts": 8, + "src/shared/agent-process-recognition.test.ts": 20, + "src/shared/agent-prompt-injection.test.ts": 7, + "src/shared/agent-resume-argv-drop.test.ts": 11, + "src/shared/agent-resume-launch-command.test.ts": 63, + "src/shared/agent-row-conversation-name.test.ts": 10, + "src/shared/agent-scratch-worktrees.test.ts": 7, + "src/shared/agent-session-conversation-name.test.ts": 9, + "src/shared/agent-session-definitive-refusal.test.ts": 11, + "src/shared/agent-session-journal-schemas.test.ts": 35, + "src/shared/agent-session-lease-adjudication.test.ts": 16, + "src/shared/agent-session-mutation-envelope.test.ts": 10, + "src/shared/agent-session-operation-ledger.test.ts": 9, + "src/shared/agent-session-option-catalog-grok.test.ts": 12, + "src/shared/agent-session-option-catalog.test.ts": 10, + "src/shared/agent-session-provider-handle.test.ts": 21, + "src/shared/agent-session-pty-write-admission.test.ts": 8, + "src/shared/agent-session-question-answer.test.ts": 9, + "src/shared/agent-session-resume.test.ts": 9, + "src/shared/agent-session-turn-record.test.ts": 9, + "src/shared/agent-skill-sharing-contract.test.ts": 9, + "src/shared/agent-skill-sharing-gate.test.ts": 6, + "src/shared/agent-status-observation.test.ts": 13, + "src/shared/agent-status-osc-pending-retention.test.ts": 73, + "src/shared/agent-status-osc-scan-budget.test.ts": 195, + "src/shared/agent-status-osc-split-frame-scan-budget.test.ts": 96, + "src/shared/agent-status-osc.test.ts": 16, + "src/shared/agent-status-types.test.ts": 29, + "src/shared/agent-tab-title.test.ts": 14, + "src/shared/agent-terminal-status-equivalence.test.ts": 4, + "src/shared/agent-title-agy-gemini-collision.test.ts": 23, + "src/shared/agent-title-decoration.test.ts": 9, + "src/shared/agent-title-evidence.test.ts": 43, + "src/shared/agent-title-identity-characterization.test.ts": 11, + "src/shared/agent-tui-input-clear.test.ts": 13, + "src/shared/ai-vault-resume-command.test.ts": 7, + "src/shared/ai-vault-resume-preparation.test.ts": 5, + "src/shared/ai-vault-scan-error-message.test.ts": 14, + "src/shared/ai-vault-search-query-operators.test.ts": 10, + "src/shared/ai-vault-session-depth.test.ts": 17, + "src/shared/ai-vault-session-filters.test.ts": 172, + "src/shared/ai-vault-types.test.ts": 9, + "src/shared/app-version.test.ts": 12, + "src/shared/artifact-sharing-gate.test.ts": 9, + "src/shared/automation-execution-target.test.ts": 11, + "src/shared/automation-host-filter.test.ts": 5, + "src/shared/automation-legacy-list-partition.test.ts": 8, + "src/shared/automation-list-response.test.ts": 10, + "src/shared/automation-list-scope.test.ts": 19, + "src/shared/automation-owner-key.test.ts": 10, + "src/shared/automation-owner-precondition.test.ts": 11, + "src/shared/automation-run-identity.test.ts": 5, + "src/shared/automation-run-retention.test.ts": 19, + "src/shared/automation-schedules.test.ts": 183, + "src/shared/automation-usage-summary.test.ts": 9, + "src/shared/base-ref-search-result.test.ts": 6, + "src/shared/binary-file-extensions.test.ts": 4, + "src/shared/bounded-map.test.ts": 17, + "src/shared/branch-name-from-work.test.ts": 11, + "src/shared/branch-prefix.test.ts": 12, + "src/shared/browser-client-automation-protocol.test.ts": 12, + "src/shared/browser-client-host-id-argument.test.ts": 6, + "src/shared/browser-client-host-protocol.test.ts": 241, + "src/shared/browser-client-host-reconciliation-protocol.test.ts": 22, + "src/shared/browser-client-hosting-eligibility.test.ts": 9, + "src/shared/browser-cookie-import-sources.test.ts": 5, + "src/shared/browser-grab-types.test.ts": 11, + "src/shared/browser-network-capabilities.test.ts": 10, + "src/shared/browser-network-tunnel-protocol.test.ts": 11, + "src/shared/browser-network-tunnel-stream-framing.test.ts": 1908, + "src/shared/browser-screencast-protocol.test.ts": 7, + "src/shared/browser-url.test.ts": 14, + "src/shared/cheap-process-table-snapshot.test.ts": 10, + "src/shared/check-job-log-tail-slice.test.ts": 7, + "src/shared/child-process/child-process-import-boundary.test.ts": 9, + "src/shared/child-process/close-process-registry.test.ts": 8, + "src/shared/child-process/process-tree-termination.test.ts": 16, + "src/shared/child-process/retryable-process-exit-proof.test.ts": 8, + "src/shared/child-process/run-process-termination-failure.test.ts": 12, + "src/shared/child-process/run-process.test.ts": 7159, + "src/shared/child-process/windows-cmd-shim-resolution.test.ts": 15, + "src/shared/child-process/windows-command-line.test.ts": 14, + "src/shared/child-process/windows-console-visibility.test.ts": 7, + "src/shared/claimed-agent-pty-owner.test.ts": 33, + "src/shared/claude-agent-teams-tmux-compat.test.ts": 8, + "src/shared/claude-background-task-status.test.ts": 36, + "src/shared/claude-model-list-probe.test.ts": 14, + "src/shared/claude-statusline-rate-limits.test.ts": 7, + "src/shared/claude-subagent-roster.test.ts": 17, + "src/shared/claude-subagent-row-lifecycle.test.ts": 14, + "src/shared/cli-argument-boundary.test.ts": 5, + "src/shared/cli-runtime-pairing-boundary.test.ts": 182, + "src/shared/cli-workspace-provenance.test.ts": 6, + "src/shared/client-environment-info.test.ts": 10, + "src/shared/clipboard-image.test.ts": 9, + "src/shared/clipboard-text.test.ts": 29, + "src/shared/closed-terminal-tab-tombstones.test.ts": 12, + "src/shared/cloud-service-url.test.ts": 4, + "src/shared/codex-auth-errors.test.ts": 22, + "src/shared/codex-pet-sprite-defaults.test.ts": 12, + "src/shared/codex-reset-credit-attempt-ledger.test.ts": 16, + "src/shared/codex-reset-credit-scope.test.ts": 8, + "src/shared/codex-startup-delivery.test.ts": 8, + "src/shared/codex-subagent-poll-scheduler.test.ts": 14, + "src/shared/codex-subagent-rollout-lifecycle.test.ts": 18, + "src/shared/codex-subagent-roster.test.ts": 6, + "src/shared/codex-subagent-transcript.test.ts": 18, + "src/shared/combined-diff-file-tree-width.test.ts": 6, + "src/shared/command-code-output-status.test.ts": 155, + "src/shared/command-code-turn-boundary.test.ts": 6, + "src/shared/command-token-scanner.test.ts": 11, + "src/shared/commit-message-agent-spec.test.ts": 38, + "src/shared/commit-message-generation.test.ts": 12, + "src/shared/commit-message-plan.test.ts": 21, + "src/shared/commit-message-prompt.test.ts": 28, + "src/shared/computer-awake-mode.test.ts": 7, + "src/shared/computer-use-error-recovery.test.ts": 6, + "src/shared/computer-use-key-spec.test.ts": 7, + "src/shared/constants.test.ts": 15, + "src/shared/contextual-tours.test.ts": 16, + "src/shared/crash-reporting.test.ts": 18, + "src/shared/cross-platform-path-guards.test.ts": 1239, + "src/shared/cross-platform-path.test.ts": 17, + "src/shared/daemon-adoption-telemetry.test.ts": 10, + "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 17, + "src/shared/doc-preview-file-access.test.ts": 36, + "src/shared/doc-preview-scheme.test.ts": 8, + "src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 7, + "src/shared/draft-paste-ready-scanner.test.ts": 19, + "src/shared/emoji-shortcode-catalog.lazy.test.ts": 42, + "src/shared/emulator-keyboard-frame.test.ts": 7, + "src/shared/emulator-touch-frame.test.ts": 5, + "src/shared/ephemeral-setup-terminal-worktree-id.test.ts": 6, + "src/shared/ephemeral-vm-recipe-checkout-mode.test.ts": 7, + "src/shared/ephemeral-vm-recipe-doctor.test.ts": 10, + "src/shared/ephemeral-vm-recipe-process.test.ts": 154, + "src/shared/ephemeral-vm-recipe-repo-url.test.ts": 7, + "src/shared/ephemeral-vm-recipes.test.ts": 23, + "src/shared/ephemeral-vm-runtime-feature-sorting.test.ts": 145, + "src/shared/ephemeral-vm-runtime-store-rollback.test.ts": 341, + "src/shared/ephemeral-vm-runtime-store.test.ts": 41, + "src/shared/event-loop-yield.test.ts": 28, + "src/shared/execution-host-registry.test.ts": 15, + "src/shared/execution-host.test.ts": 14, + "src/shared/export-let-function-initializer-ban.test.ts": 2085, + "src/shared/external-automation-jobs-file.test.ts": 48, + "src/shared/external-worktree-inbox.test.ts": 11, + "src/shared/feature-education-telemetry.test.ts": 5, + "src/shared/feature-interactions.test.ts": 1489, + "src/shared/feature-tips.test.ts": 7, + "src/shared/feature-wall-tour-depth.test.ts": 5, + "src/shared/file-link-location.test.ts": 7, + "src/shared/file-name-sort.test.ts": 6, + "src/shared/filesystem-directory-listing-limit.test.ts": 10, + "src/shared/fish-binary-requirement.test.ts": 14, + "src/shared/folder-workspace-execution-host.test.ts": 17, + "src/shared/folder-workspace-worktree.test.ts": 8, + "src/shared/folder-workspaces.test.ts": 6, + "src/shared/foreground-process-ancestry-parity.test.ts": 286, + "src/shared/foreground-process-selection.test.ts": 11, + "src/shared/foreground-wrapper-agent.test.ts": 7, + "src/shared/generated-code-path.test.ts": 10, + "src/shared/git-branch-cleanup.test.ts": 17, + "src/shared/git-branch-compare-head.test.ts": 6, + "src/shared/git-branch-line-total-soft-deadline.test.ts": 3529, + "src/shared/git-branch-line-total.test.ts": 39, + "src/shared/git-capability-cache.test.ts": 10, + "src/shared/git-check-ignore-stdio.test.ts": 8, + "src/shared/git-clone-failure-message.test.ts": 26, + "src/shared/git-config-snapshot-runner.test.ts": 13, + "src/shared/git-configured-branch-target.test.ts": 11, + "src/shared/git-cquoted-path.test.ts": 3, + "src/shared/git-diff-transport-budget.test.ts": 1152, + "src/shared/git-discard-path-safety.test.ts": 22, + "src/shared/git-exec-mutation.test.ts": 10, + "src/shared/git-fetch-head-capability.test.ts": 6, + "src/shared/git-fetch-head-lock-key-derivation.test.ts": 64, + "src/shared/git-fetch-head-lock.test.ts": 108, + "src/shared/git-fork-sync.test.ts": 21, + "src/shared/git-history-graph.test.ts": 10, + "src/shared/git-history-message-allocation.test.ts": 11, + "src/shared/git-history-ref-display.test.ts": 9, + "src/shared/git-history.test.ts": 18, + "src/shared/git-merge-tree-capability.test.ts": 9, + "src/shared/git-metadata-path.test.ts": 13, + "src/shared/git-push-target-validation.test.ts": 6, + "src/shared/git-remote-error.test.ts": 36, + "src/shared/git-remote-identity.test.ts": 10, + "src/shared/git-remote-url-index.test.ts": 11, + "src/shared/git-rev-list-output.test.ts": 8, + "src/shared/git-status-branch-line-total-cache.test.ts": 12, + "src/shared/git-status-conflict-entries.test.ts": 10, + "src/shared/git-status-limit.test.ts": 6, + "src/shared/git-status-line-stat-inputs.test.ts": 6, + "src/shared/git-status-line-stats-cache.test.ts": 10, + "src/shared/git-status-upstream-ref.test.ts": 6, + "src/shared/git-uncommitted-line-stats.test.ts": 54, + "src/shared/git-upstream-status.test.ts": 16, + "src/shared/git-worktree-operation-lock.test.ts": 16, + "src/shared/github/api-availability.test.ts": 6, + "src/shared/github/project-identity.test.ts": 5, + "src/shared/github/project-ref-input.test.ts": 11, + "src/shared/github/project-roadmap-timeline.test.ts": 11, + "src/shared/github/pull-request-auto-merge-availability.test.ts": 6, + "src/shared/github/pull-request-for-branch-outcome.test.ts": 7, + "src/shared/github/pull-request-merge-methods.test.ts": 8, + "src/shared/github/repository-identity-key.test.ts": 5, + "src/shared/github/work-items-query-bounds.test.ts": 9, + "src/shared/gitlab-job-log-excerpt.test.ts": 36, + "src/shared/gitlab-job-trace-check-details.test.ts": 17, + "src/shared/gitlab-pipeline-checks.test.ts": 6, + "src/shared/gitlab-projects.test.ts": 10, + "src/shared/grok-model-list-probe.test.ts": 14, + "src/shared/grok-session-paths.test.ts": 21, + "src/shared/growing-byte-buffer.test.ts": 89, + "src/shared/handled-wire-discriminant.test.ts": 4, + "src/shared/harness-injected-user-turns.test.ts": 8, + "src/shared/hermes-run-ref-retention.test.ts": 6, + "src/shared/hook-command-source-policy.test.ts": 12, + "src/shared/host-balanced-listing-scaling.test.ts": 13, + "src/shared/host-setting-overrides.test.ts": 10, + "src/shared/hosted-review-creation-providers.test.ts": 4, + "src/shared/hosted-review-github.test.ts": 8, + "src/shared/hosted-review-ready-capabilities.test.ts": 5, + "src/shared/hosted-review-refs.test.ts": 6, + "src/shared/image-data-uri.test.ts": 12, + "src/shared/image-paste-following-text.test.ts": 5, + "src/shared/in-flight-promise-dedupe.test.ts": 19, + "src/shared/issue-link-input.test.ts": 9, + "src/shared/jira-issue-url.test.ts": 8, + "src/shared/json-text-structure-limit.test.ts": 9, + "src/shared/keybindings-conflicts.test.ts": 32, + "src/shared/keybindings-default-bindings.test.ts": 32, + "src/shared/keybindings-digit-index.test.ts": 12, + "src/shared/keybindings-double-tap.test.ts": 14, + "src/shared/keybindings-keyboard-layout.test.ts": 8, + "src/shared/keybindings-parse-cache.test.ts": 43, + "src/shared/keybindings-parsing.test.ts": 12, + "src/shared/keybindings-terminal-context.test.ts": 7, + "src/shared/keybindings-unassigned-actions.test.ts": 12, + "src/shared/linear/inline-media.test.ts": 4, + "src/shared/linear/issue-attribute-filter.test.ts": 9, + "src/shared/linear/issue-view-resume-state.test.ts": 15, + "src/shared/linear/links.test.ts": 7, + "src/shared/linear/workspace-types.test.ts": 7, + "src/shared/linux-proc-port-scan-limits.test.ts": 12, + "src/shared/local-account-runtime.test.ts": 7, + "src/shared/local-build-compatibility.test.ts": 5, + "src/shared/localhost-worktree-labels.test.ts": 8, + "src/shared/loose-ref-count.test.ts": 247, + "src/shared/macos-symbolic-hotkeys.test.ts": 14, + "src/shared/managed-agent-command-token.test.ts": 9, + "src/shared/manual-repo-order.test.ts": 12, + "src/shared/map-settled-with-concurrency.test.ts": 20, + "src/shared/map-with-concurrency.test.ts": 58, + "src/shared/markdown-document-listing-limits.test.ts": 5, + "src/shared/markdown-toc-panel-width.test.ts": 7, + "src/shared/mcp-config.test.ts": 29, + "src/shared/mobile-e2ee-v2-contract.test.ts": 15, + "src/shared/mobile-e2ee-v2-framing.test.ts": 13, + "src/shared/mobile-file-directory-limit.test.ts": 11, + "src/shared/mobile-markdown-document.test.ts": 8, + "src/shared/mobile-pairing-connection-mode.test.ts": 9, + "src/shared/mobile-pairing-custom-address.test.ts": 13, + "src/shared/mobile-push-contract.test.ts": 5, + "src/shared/mobile-relay-close-codes.test.ts": 4, + "src/shared/mobile-relay-mint-failure.test.ts": 5, + "src/shared/mobile-relay-pairing-offer.test.ts": 22, + "src/shared/mobile-relay-phone-protocol.test.ts": 12, + "src/shared/model-id-label.test.ts": 4, + "src/shared/modifier-double-tap-detector.test.ts": 10, + "src/shared/native-chat-agent-profiles.test.ts": 17, + "src/shared/native-chat-agent-support.test.ts": 7, + "src/shared/native-chat-ask-fifo.test.ts": 186, + "src/shared/native-chat-ask.test.ts": 12, + "src/shared/native-chat-command-envelope.test.ts": 9, + "src/shared/native-chat-edit-normalize.test.ts": 35, + "src/shared/native-chat-href-routing.test.ts": 11, + "src/shared/native-chat-image-transcript-markers.test.ts": 19, + "src/shared/native-chat-session-option-commands.test.ts": 15, + "src/shared/native-chat-session-option-defaults.test.ts": 15, + "src/shared/native-chat-session-option-snapshot.test.ts": 15, + "src/shared/native-chat-session-option-state.test.ts": 10, + "src/shared/native-chat-slash-commands.test.ts": 31, + "src/shared/native-chat-stream-unsubscribe.test.ts": 3, + "src/shared/native-chat-streaming.test.ts": 9, + "src/shared/native-chat-subagent-summary.test.ts": 11, + "src/shared/native-chat-task-list.test.ts": 11, + "src/shared/native-chat-tool-activity.test.ts": 11, + "src/shared/native-chat-tool-attribution-allocation.test.ts": 20, + "src/shared/native-chat-tool-icon.test.ts": 19, + "src/shared/native-chat-tool-identity.test.ts": 14, + "src/shared/native-chat-tool-pair-limit.test.ts": 74, + "src/shared/native-chat-tool-summary.test.ts": 13, + "src/shared/native-chat-transcript-retention.test.ts": 5, + "src/shared/native-chat-turn-activity.test.ts": 9, + "src/shared/native-chat-turn-status.test.ts": 13, + "src/shared/native-chat-types.test.ts": 4, + "src/shared/native-chat-unverifiable-turn-status.test.ts": 8, + "src/shared/native-file-drop.test.ts": 28, + "src/shared/nested-repo-telemetry-schema.test.ts": 18, + "src/shared/nested-repo-telemetry.test.ts": 15, + "src/shared/nested-worker-depth.test.ts": 8, + "src/shared/network-proxy.test.ts": 9, + "src/shared/network/manual-address.test.ts": 13, + "src/shared/network/server-share-address.test.ts": 24, + "src/shared/new-workspace-dialog-repo.test.ts": 5, + "src/shared/new-workspace/smart-workspace-url-source-results.test.ts": 10, + "src/shared/new-workspace/work-item-lookup-text.test.ts": 8, + "src/shared/new-workspace/workspace-source.test.ts": 8, + "src/shared/new-workspace/worktree-create-retry-policy.test.ts": 7, + "src/shared/node-bounded-file-reader-sync.test.ts": 4, + "src/shared/node-bounded-file-reader.test.ts": 14, + "src/shared/node-bounded-json-stringify.test.ts": 24, + "src/shared/node-file-content-equality.test.ts": 15, + "src/shared/node-markdown-document-discovery.test.ts": 8, + "src/shared/node-pty-spawn-helper.test.ts": 7, + "src/shared/node-readable-text.test.ts": 14, + "src/shared/node-source-copy-content-equality.test.ts": 16, + "src/shared/nul-delimited-fields.test.ts": 5, + "src/shared/nvm-default-alias.test.ts": 38, + "src/shared/omp-pi-semantic-title-preservation.test.ts": 25, + "src/shared/onboarding-tour-telemetry-events.test.ts": 12, + "src/shared/open-in-applications.test.ts": 8, + "src/shared/opencode-permission-status.test.ts": 18, + "src/shared/opencode-terminal-title.test.ts": 4, + "src/shared/orca-yaml-alias-bounds.test.ts": 27, + "src/shared/orca-yaml-bounds.test.ts": 22, + "src/shared/orchestration-ask-timeout.test.ts": 5, + "src/shared/orchestration-check-output.test.ts": 7, + "src/shared/orchestration-compatibility-evidence.test.ts": 8, + "src/shared/orchestration-dispatch-refusal-contract.test.ts": 10, + "src/shared/orchestration-fleet-attention.test.ts": 6, + "src/shared/orchestration-fleet-evidence-clock.test.ts": 8, + "src/shared/orchestration-fleet-projection.test.ts": 12, + "src/shared/orchestration-rpc-contract.test.ts": 12, + "src/shared/orchestration-task-display.test.ts": 9, + "src/shared/orchestration-task-summary.test.ts": 9, + "src/shared/orchestration-timing-budgets.test.ts": 6, + "src/shared/osc-title-scan-tail-retention.test.ts": 34, + "src/shared/osc-title-scan-tail.test.ts": 4, + "src/shared/osc52-clipboard-settings.test.ts": 7, + "src/shared/own-retained-string.test.ts": 77, + "src/shared/pairing-address-auto-selection.test.ts": 5, + "src/shared/pairing-local-ui-fields.test.ts": 6, + "src/shared/pairing.test.ts": 14, + "src/shared/pane-agent-identity-adapter.test.ts": 21, + "src/shared/pane-agent-identity-inventory.test.ts": 782, + "src/shared/pane-agent-identity-resolver.test.ts": 15, + "src/shared/pane-agent-identity-surface-inventory.test.ts": 1226, + "src/shared/pane-agent-identity-title-corpus.test.ts": 17, + "src/shared/pane-agent-owner.test.ts": 8, + "src/shared/pane-key-alias.test.ts": 6, + "src/shared/physical-exit-tracker.test.ts": 11, + "src/shared/pi-agent-kind.test.ts": 4, + "src/shared/pi-overlay-ui-settings.test.ts": 7, + "src/shared/pi-state-title-marker.test.ts": 11, + "src/shared/plugins/plugin-consent-fingerprint.test.ts": 14, + "src/shared/plugins/plugin-consent-request.test.ts": 10, + "src/shared/plugins/plugin-content-pack-contributions.test.ts": 16, + "src/shared/plugins/plugin-demo-fixture.test.ts": 16, + "src/shared/plugins/plugin-hostile-fixture.test.ts": 4, + "src/shared/plugins/plugin-install-lockfile.test.ts": 10, + "src/shared/plugins/plugin-kill-list.test.ts": 47, + "src/shared/plugins/plugin-language-pack-artifact.test.ts": 134, + "src/shared/plugins/plugin-manifest.test.ts": 24, + "src/shared/plugins/plugin-marketplace.test.ts": 64, + "src/shared/plugins/plugin-panel-call-admission.test.ts": 6, + "src/shared/plugins/plugin-panel-message-budget.test.ts": 6, + "src/shared/plugins/plugin-panel-pong-frame.test.ts": 9, + "src/shared/plugins/plugin-panel-shell.test.ts": 7, + "src/shared/plugins/plugin-path-safety.test.ts": 15, + "src/shared/plugins/plugin-vm-recipe-artifact.test.ts": 18, + "src/shared/posix-wait-status.test.ts": 7, + "src/shared/powershell-native-argument.test.ts": 4, + "src/shared/pr-bot-author-overrides.test.ts": 11, + "src/shared/pr-check-severity-order.test.ts": 7, + "src/shared/pr-check-status.test.ts": 5, + "src/shared/pr-comment-audience.test.ts": 5, + "src/shared/pr-comment-groups.test.ts": 8, + "src/shared/pr-comment-time.test.ts": 7, + "src/shared/preferred-git-remote.test.ts": 6, + "src/shared/priority-semaphore.test.ts": 19, + "src/shared/process-output-field-scanner.test.ts": 6, + "src/shared/process-table-snapshot.test.ts": 72, + "src/shared/project-catalog-row-normalization.test.ts": 9, + "src/shared/project-execution-runtime.test.ts": 15, + "src/shared/project-groups.test.ts": 124, + "src/shared/project-host-setup-projection.test.ts": 30, + "src/shared/project-identity-succession.test.ts": 30, + "src/shared/promise-settlement-waiters.test.ts": 337, + "src/shared/protocol-compat.test.ts": 13, + "src/shared/pty-consumer-session.test.ts": 17, + "src/shared/pty-delivery-diagnostics.test.ts": 14, + "src/shared/pty-liveness-verdict.test.ts": 6, + "src/shared/pty-owner-backend.test.ts": 4, + "src/shared/pty-slave-line-discipline-echo.test.ts": 13, + "src/shared/pty-startup-ingress-live-query-reply.test.ts": 23, + "src/shared/pty-startup-ingress.test.ts": 60, + "src/shared/pty-startup-reply-echo-shapes.test.ts": 14, + "src/shared/published-pane-agent-identity.test.ts": 20, + "src/shared/pull-request-generation.test.ts": 11, + "src/shared/quick-open-directory-reader.test.ts": 11, + "src/shared/quick-open-expansion-paths.test.ts": 30, + "src/shared/quick-open-filter.renderer-safety.test.ts": 6, + "src/shared/quick-open-filter.test.ts": 15, + "src/shared/quick-open-git-directory-collapse.test.ts": 51, + "src/shared/quick-open-listing-limits.test.ts": 14, + "src/shared/quick-open-readdir-memory.test.ts": 38, + "src/shared/quick-open-readdir-walk.test.ts": 197, + "src/shared/quick-open-transport-budget.test.ts": 5, + "src/shared/raster-image-base64-preview.test.ts": 322, + "src/shared/raster-image-dimensions.test.ts": 17, + "src/shared/raster-image-preview-limits.test.ts": 7, + "src/shared/rate-limit-reset-format.test.ts": 9, + "src/shared/rate-limit-types.test.ts": 6, + "src/shared/react-update-depth-attribution.test.ts": 7, + "src/shared/reconnect-jitter.test.ts": 5, + "src/shared/relay-frame-buffer.test.ts": 393, + "src/shared/relay-optional-artifacts.test.ts": 5, + "src/shared/relay-version-marker.test.ts": 5, + "src/shared/release-channel.test.ts": 26, + "src/shared/remote-foreground-evidence.test.ts": 9, + "src/shared/remote-pairing-address.test.ts": 18, + "src/shared/remote-pairing-verification.test.ts": 7, + "src/shared/remote-rpc-content-budget.test.ts": 89, + "src/shared/remote-runtime-client-error-classification.test.ts": 8, + "src/shared/remote-runtime-client.test.ts": 4792, + "src/shared/remote-runtime-memory-limits.test.ts": 110, + "src/shared/remote-runtime-outbound-admission.test.ts": 903, + "src/shared/remote-runtime-request-connection-stale.test.ts": 141, + "src/shared/remote-runtime-request-connection.test.ts": 100, + "src/shared/remote-runtime-request-frames.test.ts": 9, + "src/shared/remote-runtime-request-response-router.test.ts": 12, + "src/shared/remote-runtime-request-websocket.test.ts": 32, + "src/shared/remote-runtime-shared-control-boundary.test.ts": 2634, + "src/shared/remote-runtime-shared-control-connection.test.ts": 3301, + "src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts": 14, + "src/shared/remote-runtime-shared-control-reconnect.test.ts": 9, + "src/shared/remote-runtime-shared-control-retired-request-ids.test.ts": 8, + "src/shared/remote-runtime-shared-control-socket-generation.test.ts": 4, + "src/shared/remote-runtime-shared-control-standing-intent.test.ts": 615, + "src/shared/remote-runtime-shared-control-subscription-close.test.ts": 5, + "src/shared/remote-runtime-shared-control-subscriptions.test.ts": 12, + "src/shared/remote-runtime-socket-liveness.test.ts": 9, + "src/shared/remote-runtime-subscription-frame-router.test.ts": 7, + "src/shared/remote-runtime-subscription-request.test.ts": 295, + "src/shared/remote-runtime-tailscale-hint.test.ts": 7, + "src/shared/remote-runtime-transport-error-agreement.test.ts": 9, + "src/shared/remote-workspace-session-projection.test.ts": 9, + "src/shared/renderer-restart-preparation.test.ts": 10, + "src/shared/repo-badge-color.test.ts": 5, + "src/shared/repo-icon.test.ts": 23, + "src/shared/repo-ref-maintenance.test.ts": 316, + "src/shared/repo-search-limits.test.ts": 10, + "src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts": 16, + "src/shared/repro-7732-gitlab-job-id-dropped.test.ts": 5, + "src/shared/repro-8478-opencode-native-title-icon.test.ts": 16, + "src/shared/require-tui-agent-config.test.ts": 4, + "src/shared/resolved-worktree-lineage.test.ts": 10, + "src/shared/retired-pty-incarnations.test.ts": 4, + "src/shared/review-head-tracking-ref.test.ts": 6, + "src/shared/ripgrep-process-availability.test.ts": 11, + "src/shared/runtime-client-export-parity.test.ts": 5, + "src/shared/runtime-environment-store.test.ts": 32, + "src/shared/runtime-host-status-owner.test.ts": 22, + "src/shared/runtime-listing-host-scope.test.ts": 10, + "src/shared/runtime-navigation.test.ts": 5, + "src/shared/runtime-rpc-call-queue.test.ts": 15, + "src/shared/runtime-workspace-file-owner.test.ts": 10, + "src/shared/runtime-workspace-window-availability.test.ts": 5, + "src/shared/search-match-count.test.ts": 7, + "src/shared/search-subprocess-lines.test.ts": 87, + "src/shared/secret-store.test.ts": 8, + "src/shared/secure-file-coarse-ctime.test.ts": 9, + "src/shared/secure-file-fsync-flags.test.ts": 13, + "src/shared/secure-file.test.ts": 602, + "src/shared/secure-path-hardening-cache.test.ts": 9, + "src/shared/secure-path-hardening-retry-budget.test.ts": 39, + "src/shared/serve-option-validation.test.ts": 12, + "src/shared/setup-agent-sequencing.test.ts": 6203, + "src/shared/setup-runner-command.test.ts": 16, + "src/shared/setup-script-imports.test.ts": 26, + "src/shared/setup-script-package-manager-suggestion.test.ts": 14, + "src/shared/setup-script-shebang.test.ts": 7, + "src/shared/setup-script-telemetry-events.test.ts": 10, + "src/shared/setup-script-telemetry.test.ts": 7, + "src/shared/shell-foreground-snapshot.test.ts": 118, + "src/shared/shell-process-readiness.test.ts": 20, + "src/shared/skill-bundle-install-contract.test.ts": 9, + "src/shared/skill-bundle-name.test.ts": 3, + "src/shared/skill-delete-contract.test.ts": 30, + "src/shared/skill-deletion-eligibility.test.ts": 6, + "src/shared/skill-install-contract.test.ts": 13, + "src/shared/skill-install-failure.test.ts": 11, + "src/shared/skill-metadata.test.ts": 6, + "src/shared/skill-package-manifest.test.ts": 78, + "src/shared/skill-path-containment.test.ts": 8, + "src/shared/skills-cli-agent-keys.test.ts": 10, + "src/shared/source-control-ai-action-recipes.test.ts": 11, + "src/shared/source-control-ai-action-variables.test.ts": 12, + "src/shared/source-control-ai-actions.test.ts": 11, + "src/shared/source-control-ai-policy-regression.test.ts": 12, + "src/shared/source-control-ai-recipe-save.test.ts": 15, + "src/shared/source-control-ai.test.ts": 18, + "src/shared/source-control-create-review-intent.test.ts": 12, + "src/shared/source-control-group-order.test.ts": 4, + "src/shared/source-control-primary-action-decision.test.ts": 5, + "src/shared/source-control-push-failure.test.ts": 12, + "src/shared/source-control-recovery-agent-command.test.ts": 7, + "src/shared/source-scan/source-tree-scan.test.ts": 37, + "src/shared/ssh-pending-pty-kill.test.ts": 9, + "src/shared/ssh-pty-id.test.ts": 8, + "src/shared/ssh-relay-pty-ownership-proof.test.ts": 44, + "src/shared/ssh-retained-payload-admission.test.ts": 20, + "src/shared/ssh-target-generation.test.ts": 11, + "src/shared/ssh-types.test.ts": 7, + "src/shared/stable-pane-id.test.ts": 13, + "src/shared/startup-command-submission.test.ts": 6, + "src/shared/status-bar-usage-mode.test.ts": 3, + "src/shared/string-chunk-compaction.test.ts": 19, + "src/shared/structural-value-equality.test.ts": 10, + "src/shared/structured-agent-session-coalescer.test.ts": 7, + "src/shared/structured-agent-session-composer.test.ts": 13, + "src/shared/structured-agent-session-create.test.ts": 16, + "src/shared/structured-agent-session-item-retention.test.ts": 460, + "src/shared/structured-agent-session-live-turn.test.ts": 4, + "src/shared/structured-agent-session-mutation.test.ts": 6, + "src/shared/structured-agent-session-option-picks.test.ts": 8, + "src/shared/structured-agent-session-options.test.ts": 12, + "src/shared/structured-agent-session-projection.test.ts": 14, + "src/shared/structured-agent-session-reducer.test.ts": 43, + "src/shared/structured-agent-session-turn-timing-retention.test.ts": 20, + "src/shared/structured-agent-session-turn-timing.test.ts": 15, + "src/shared/structured-native-chat-launch-route.test.ts": 8, + "src/shared/subprocess-stdin-write.test.ts": 4, + "src/shared/synthetic-agent-title.test.ts": 9, + "src/shared/tab-title-resolution.test.ts": 8, + "src/shared/tailnet-address.test.ts": 5, + "src/shared/task-providers.test.ts": 12, + "src/shared/task-query.test.ts": 24, + "src/shared/task-source-context.test.ts": 12, + "src/shared/telemetry-common-props.test.ts": 9, + "src/shared/telemetry-events-feature-education.test.ts": 12, + "src/shared/telemetry-events.test.ts": 37, + "src/shared/telemetry-feature-wall-events.test.ts": 11, + "src/shared/telemetry-orca-cli-feature-tip.test.ts": 6, + "src/shared/terminal-bell-detector.test.ts": 7, + "src/shared/terminal-color-scheme-protocol.test.ts": 7, + "src/shared/terminal-composer-draft.test.ts": 13, + "src/shared/terminal-custom-themes.test.ts": 10, + "src/shared/terminal-escape-introducer.test.ts": 16, + "src/shared/terminal-exit-cause.test.ts": 8, + "src/shared/terminal-file-url-target.test.ts": 3, + "src/shared/terminal-fonts.test.ts": 7, + "src/shared/terminal-github-pr-link-detector.test.ts": 14, + "src/shared/terminal-input.test.ts": 40, + "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts": 19, + "src/shared/terminal-line-height-settings.test.ts": 4, + "src/shared/terminal-mode-2031-final-state.test.ts": 10, + "src/shared/terminal-mode-reset-profiles.test.ts": 13, + "src/shared/terminal-osc-color-reply.test.ts": 7, + "src/shared/terminal-output-side-effects.test.ts": 14, + "src/shared/terminal-partial-escape-tail-ground-scan.test.ts": 12, + "src/shared/terminal-partial-escape-tail.fuzz.test.ts": 986, + "src/shared/terminal-partial-escape-tail.test.ts": 13, + "src/shared/terminal-query-reply.test.ts": 23, + "src/shared/terminal-quick-commands.test.ts": 10, + "src/shared/terminal-reply-query-scan.test.ts": 5, + "src/shared/terminal-scrollback-policy.test.ts": 11, + "src/shared/terminal-startup-cwd.test.ts": 11, + "src/shared/terminal-stream-protocol.test.ts": 10, + "src/shared/terminal-title-agent-type.test.ts": 27, + "src/shared/terminal-title-classification-corpus.test.ts": 34, + "src/shared/terminal-title-classification-memo.test.ts": 20, + "src/shared/terminal-view-attributes.test.ts": 15, + "src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts": 9, + "src/shared/terminal-zero-dimensions-diagnostic.test.ts": 5, + "src/shared/test-code-path.test.ts": 10, + "src/shared/text-search.test.ts": 53, + "src/shared/timer-delay.test.ts": 9, + "src/shared/tui-agent-config.test.ts": 9, + "src/shared/tui-agent-permissions.test.ts": 10, + "src/shared/tui-agent-selection.test.ts": 10, + "src/shared/tui-agent-startup-copilot-resume.test.ts": 5, + "src/shared/tui-agent-startup-hermes.test.ts": 26, + "src/shared/tui-agent-startup-session-options.test.ts": 13, + "src/shared/tui-agent-startup-shell.test.ts": 23, + "src/shared/tui-agent-startup.test.ts": 29, + "src/shared/ui-language.test.ts": 8, + "src/shared/ui-locale.test.ts": 14, + "src/shared/ui-zoom-level.test.ts": 7, + "src/shared/updated-at-order.test.ts": 82, + "src/shared/updater-windows-signature-check.test.ts": 6, + "src/shared/usage-percentage-display-change-notice.test.ts": 6, + "src/shared/usage-percentage-display.test.ts": 8, + "src/shared/utf8-byte-limits.test.ts": 70, + "src/shared/vscode-remote-ssh-launcher.test.ts": 7, + "src/shared/window-shortcut-policy-agent-dashboard.test.ts": 7, + "src/shared/window-shortcut-policy.test.ts": 25, + "src/shared/windows-cmd-runner-delayed-launch.test.ts": 5, + "src/shared/windows-command-line-budget.test.ts": 1102, + "src/shared/windows-console-input.test.ts": 12, + "src/shared/windows-environment-expansion.test.ts": 48, + "src/shared/windows-interactive-login-spawn.test.ts": 13, + "src/shared/windows-lane-tree-removal-boundary.test.ts": 19, + "src/shared/windows-long-path-git-args.test.ts": 4, + "src/shared/windows-security-descriptor.test.ts": 13, + "src/shared/windows-terminal-shell.test.ts": 9, + "src/shared/windows-transient-lock-removal.test.ts": 1176, + "src/shared/worker-terminal-host-scope.test.ts": 15, + "src/shared/worker-transcript-text-scaling.test.ts": 7, + "src/shared/workspace-cleanup-applied-filters.test.ts": 15, + "src/shared/workspace-cleanup-browse-state-persistence.test.ts": 18, + "src/shared/workspace-cleanup-ui-state.test.ts": 7, + "src/shared/workspace-cleanup.test.ts": 10, + "src/shared/workspace-doc-history.test.ts": 34, + "src/shared/workspace-linked-item-equality.test.ts": 7, + "src/shared/workspace-linked-item-source-context.test.ts": 8, + "src/shared/workspace-name.test.ts": 25, + "src/shared/workspace-session-browser-history.test.ts": 25, + "src/shared/workspace-session-partition-owner.test.ts": 4, + "src/shared/workspace-session-salvage-equivalence.test.ts": 95, + "src/shared/workspace-session-salvage.test.ts": 1522, + "src/shared/workspace-session-schema-field-coverage.test.ts": 15, + "src/shared/workspace-session-schema.sleeping-agent.test.ts": 22, + "src/shared/workspace-session-schema.test.ts": 23, + "src/shared/workspace-session-tab-focus-schema.test.ts": 19, + "src/shared/workspace-session-terminal-buffers.test.ts": 28, + "src/shared/workspace-session-terminal-schema.test.ts": 12, + "src/shared/workspace-session-terminal-tab-close.test.ts": 43, + "src/shared/workspace-session-validation-work.test.ts": 108, + "src/shared/workspace-space-compaction.test.ts": 15, + "src/shared/workspace-space-entry-traversal.test.ts": 1132, + "src/shared/workspace-space-scan-budget.test.ts": 32, + "src/shared/workspace-statuses.test.ts": 24, + "src/shared/worktree-execution-host-resolution.test.ts": 15, + "src/shared/worktree-name-suggestion.test.ts": 29, + "src/shared/worktree/base-ref.test.ts": 11, + "src/shared/worktree/card-properties.test.ts": 6, + "src/shared/worktree/create-preparation.test.ts": 6, + "src/shared/worktree/github-pr-suppression.test.ts": 8, + "src/shared/worktree/host-context-labels.test.ts": 8, + "src/shared/worktree/host-qualified-identity.test.ts": 8, + "src/shared/worktree/id.test.ts": 13, + "src/shared/worktree/identity.test.ts": 7, + "src/shared/worktree/ownership-configured-base-visibility.test.ts": 22, + "src/shared/worktree/ownership-worktree-base-path.test.ts": 8, + "src/shared/worktree/ownership.test.ts": 284, + "src/shared/worktree/removal-fence-error.test.ts": 7, + "src/shared/worktree/removal-force-classification.test.ts": 5, + "src/shared/worktree/retired-name-cache.test.ts": 15, + "src/shared/worktree/retired-name-registry.test.ts": 185, + "src/shared/worktree/sort-order-update.test.ts": 7, + "src/shared/worktree/submodule-removal.test.ts": 7, + "src/shared/worktree/visibility-sources.test.ts": 18, + "src/shared/ws-outbound-backpressure-queue.test.ts": 31, + "src/shared/wsl-exec-mode-separator.test.ts": 2451, + "src/shared/wsl-login-shell-command.test.ts": 66, + "src/shared/wsl-paths.test.ts": 18, + "src/shared/zod-salvage-absence.test.ts": 21, + "tests/e2e/alternate-screen-fixture-script.unit.test.ts": 8, + "tests/e2e/completed-worker-retirement-resume.unit.test.ts": 128, + "tests/e2e/global-teardown.unit.test.ts": 55, + "tests/e2e/helpers/alt-screen-frame.unit.test.ts": 40, + "tests/e2e/helpers/client-hosted-browser-fixture.unit.test.ts": 13, + "tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts": 11, + "tests/e2e/helpers/electron-home-isolation.unit.test.ts": 7, + "tests/e2e/helpers/electron-launch-args.unit.test.ts": 9, + "tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts": 1209, + "tests/e2e/helpers/electron-process-shutdown.unit.test.ts": 11, + "tests/e2e/helpers/fake-agent-command-override.unit.test.ts": 3, + "tests/e2e/helpers/fake-agent-paste-end-scanner.unit.test.ts": 16, + "tests/e2e/helpers/git-status-retry-barrier.unit.test.ts": 7, + "tests/e2e/helpers/golden-source-control.unit.test.ts": 11, + "tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 18, + "tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts": 5, + "tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 12, + "tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 10, + "tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts": 138, + "tests/e2e/helpers/remote-terminal-source-range-contract-fixture.unit.test.ts": 7, + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts": 57, + "tests/e2e/helpers/streaming-terminal-cleanup.unit.test.ts": 10, + "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts": 518, + "tests/e2e/host-guest-paint-retention-remote-viewer.unit.test.ts": 278, + "tests/e2e/orca-restart-navigation.unit.test.ts": 9, + "tests/e2e/orchestration-run-pagination.unit.test.ts": 9, + "tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts": 935, + "tests/e2e/relay-region-compatibility.unit.test.ts": 56, + "tests/e2e/relay-region-correction.unit.test.ts": 5935, + "tests/e2e/remote-agent-completion-authority.unit.test.ts": 20, + "tests/e2e/remote-terminal-tab-retirement.unit.test.ts": 53, + "tests/e2e/restored-terminal-input-readiness.unit.test.ts": 17, + "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts": 460, + "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts": 33, + "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts": 82, + "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts": 9, + "tests/e2e/structured-native-chat-routing-authority.unit.test.ts": 67, + "tests/e2e/terminal-foreground-confirmation.unit.test.ts": 19, + "tests/e2e/terminal-probe-input-sequence.unit.test.ts": 9, + "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts": 11, + "tests/e2e/terminal-split-activation-latency-report.unit.test.ts": 13, + "tests/tools/relay-bench/phone-e2ee-desktop-parity.test.mjs": 48, + "tests/tools/relay-bench/region-probe-replay.test.mjs": 26, + "tests/tools/relay-bench/relay-bench-invocation.test.mjs": 16, + "tests/tools/relay-bench/relay-bench-state-file.test.mjs": 13, + "tests/tools/relay-bench/relay-phone-connect-bench.test.mjs": 27, + "tests/tools/win-update-e2e/app-driver.test.mjs": 23, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs": 8, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs": 40, + "tests/tools/windows-pty-native-capability-smoke/run.test.mjs": 11 + } + }, + "e2e": { + "runId": "34652504501", + "jobIds": [ + "103437895060", + "103437895072", + "103437895076", + "103437895077", + "103437895093", + "103437895101", + "103437895106", + "103437895107", + "103437895124", + "103437895125", + "103437895133", + "103437895146", + "103437895183", + "103437895216" + ], + "overheadMs": 0, + "timings": { + "tests/e2e/active-view-restart-restore.spec.ts": 22600, + "tests/e2e/activity-agent-pane-isolation.spec.ts": 102400, + "tests/e2e/add-project-default-checkout.spec.ts": 41600, + "tests/e2e/agent-dashboard-status-burst.spec.ts": 16900, + "tests/e2e/agent-descendant-process-kill.spec.ts": 14800, + "tests/e2e/agent-session-live-force-exit-resume.spec.ts": 31800, + "tests/e2e/agent-session-log-tail-stability.spec.ts": 78000, + "tests/e2e/agent-session-quit-resume.spec.ts": 27800, + "tests/e2e/ai-vault-session-delete.spec.ts": 29800, + "tests/e2e/app-menu-paste-ownership.spec.ts": 31900, + "tests/e2e/artificial-opencode-terminal-load.spec.ts": 230300, + "tests/e2e/automation-hidden-terminal-first-mount.spec.ts": 15100, + "tests/e2e/automation-prompt-disclosure.spec.ts": 27000, + "tests/e2e/automation-runs-dashboard.spec.ts": 18900, + "tests/e2e/browser-address-bar-narrow-toolbar.spec.ts": 17100, + "tests/e2e/browser-embedded-owner-routing.spec.ts": 20900, + "tests/e2e/browser-fedcm-fallback.spec.ts": 15100, + "tests/e2e/browser-guest-attachment-validation.spec.ts": 16900, + "tests/e2e/browser-guest-crash-recovery.spec.ts": 123200, + "tests/e2e/browser-loading-surface.spec.ts": 49800, + "tests/e2e/browser-local-https-certificate-trust.spec.ts": 19500, + "tests/e2e/browser-reload-feedback.spec.ts": 21000, + "tests/e2e/browser-split-shortcuts.spec.ts": 75000, + "tests/e2e/browser-tab.spec.ts": 175700, + "tests/e2e/chinese-ime-chat-input-repro.spec.ts": 49200, + "tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts": 101900, + "tests/e2e/combined-diff-scroll-restore.spec.ts": 51900, + "tests/e2e/completed-worker-retirement-resume.spec.ts": 40400, + "tests/e2e/daemon-generation-legacy-close-safety.spec.ts": 5300, + "tests/e2e/daemon-lifecycle-retirement.spec.ts": 222, + "tests/e2e/daemon-live-session-preservation.spec.ts": 20700, + "tests/e2e/daemon-slow-health-check-preservation.spec.ts": 23700, + "tests/e2e/daemon-slow-init-pty-gate.spec.ts": 20700, + "tests/e2e/default-branch-visibility.spec.ts": 14800, + "tests/e2e/dictation-indicator.spec.ts": 14900, + "tests/e2e/diff-note-delete.spec.ts": 18700, + "tests/e2e/diff-note-edit.spec.ts": 20900, + "tests/e2e/diff-note-layout.spec.ts": 24900, + "tests/e2e/droid-notification.spec.ts": 65000, + "tests/e2e/editable-context-paste-ownership.spec.ts": 15700, + "tests/e2e/editor-tab-selection-restore.spec.ts": 28900, + "tests/e2e/electron-home-isolation.spec.ts": 12800, + "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts": 26800, + "tests/e2e/ephemeral-vm-provisioned-root.spec.ts": 60000, + "tests/e2e/feature-wall.spec.ts": 101800, + "tests/e2e/file-explorer-watch-refresh.spec.ts": 17700, + "tests/e2e/file-open.spec.ts": 60000, + "tests/e2e/finished-agent-ghost-resume.spec.ts": 19100, + "tests/e2e/floating-mobile-emulator-tab.spec.ts": 23000, + "tests/e2e/floating-tab-rename.spec.ts": 56200, + "tests/e2e/folder-setup-shallow-priority.spec.ts": 50100, + "tests/e2e/folder-setup.spec.ts": 56200, + "tests/e2e/git-history-tooltip-wrap.spec.ts": 22900, + "tests/e2e/git-no-upstream-polling-churn.spec.ts": 22600, + "tests/e2e/github-cli-stall-repro.spec.ts": 18200, + "tests/e2e/github-created-issue-start-prefill.spec.ts": 43100, + "tests/e2e/github-url-smart-input-transition.spec.ts": 58200, + "tests/e2e/golden-agent-tui-launch.spec.ts": 27300, + "tests/e2e/golden-core-flows.spec.ts": 91100, + "tests/e2e/golden-file-open-edit-save.spec.ts": 25000, + "tests/e2e/golden-fresh-profile-terminal.spec.ts": 24400, + "tests/e2e/golden-posix-fresh-startup.spec.ts": 4900, + "tests/e2e/golden-posix-profile-index-fsync.spec.ts": 4900, + "tests/e2e/golden-quit-relaunch-session.spec.ts": 29700, + "tests/e2e/golden-shell-after-agent-exit.spec.ts": 28300, + "tests/e2e/golden-shell-command.spec.ts": 15400, + "tests/e2e/golden-source-control-commit.spec.ts": 19100, + "tests/e2e/golden-source-control-open-diff.spec.ts": 20900, + "tests/e2e/golden-tab-bar-agent-launch.spec.ts": 54100, + "tests/e2e/golden-terminal-file-link.spec.ts": 43300, + "tests/e2e/golden-worktree-create-switch.spec.ts": 29100, + "tests/e2e/grok-hook-session-cleanup.spec.ts": 7700, + "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts": 58000, + "tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts": 25300, + "tests/e2e/headless-serve-desktop-activation.spec.ts": 16700, + "tests/e2e/headless-serve-focused-terminal-create.spec.ts": 13200, + "tests/e2e/host-parked-pane-remote-viewer.spec.ts": 144000, + "tests/e2e/issue-12656-terminal-link-tooltip.spec.ts": 21000, + "tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts": 104400, + "tests/e2e/landing-preflight-runtime-routing.spec.ts": 11000, + "tests/e2e/large-diff-freeze-repro.spec.ts": 35800, + "tests/e2e/linear-filter-chip-labels.spec.ts": 29800, + "tests/e2e/linear-issue-view-persistence.spec.ts": 198000, + "tests/e2e/linear-url-workspace-entry.spec.ts": 73500, + "tests/e2e/live-background-terminal-mount-authority.spec.ts": 33300, + "tests/e2e/local-cli-terminal-retention.spec.ts": 18300, + "tests/e2e/local-worktree-visibility-runtime-active.spec.ts": 14700, + "tests/e2e/macos-press-and-hold-startup.spec.ts": 12700, + "tests/e2e/manual-worktree-order-persistence.spec.ts": 36100, + "tests/e2e/markdown-add-review-note-shortcut.spec.ts": 68300, + "tests/e2e/markdown-explorer-find-focus.spec.ts": 16300, + "tests/e2e/markdown-nested-toggle.spec.ts": 50700, + "tests/e2e/markdown-ordered-list-exit.spec.ts": 53200, + "tests/e2e/markdown-prose-reflow.spec.ts": 62000, + "tests/e2e/markdown-table-row-backspace.spec.ts": 24600, + "tests/e2e/mobile-banner.spec.ts": 89300, + "tests/e2e/multi-client-navigation-isolation.spec.ts": 99900, + "tests/e2e/native-chat-ask-user-question-card.spec.ts": 23600, + "tests/e2e/native-chat-first-flush-race.spec.ts": 22700, + "tests/e2e/native-chat-history-prepend-anchor.spec.ts": 29400, + "tests/e2e/new-workspace-create-more.spec.ts": 36300, + "tests/e2e/new-workspace-cross-project-dialog.spec.ts": 17100, + "tests/e2e/new-workspace-linked-item-project-switch.spec.ts": 45200, + "tests/e2e/notification-settings.spec.ts": 23500, + "tests/e2e/onboarding.spec.ts": 276800, + "tests/e2e/orchestration-idle-mail-delivery.spec.ts": 220800, + "tests/e2e/orchestration-idle-mail-restore.spec.ts": 27000, + "tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts": 22200, + "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts": 50700, + "tests/e2e/orchestration-low-level-dispatch-release.spec.ts": 15000, + "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts": 18100, + "tests/e2e/orchestration-worker-terminal-visibility.spec.ts": 53200, + "tests/e2e/orchestration-worker-transcript-providers.spec.ts": 54700, + "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts": 21600, + "tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts": 42600, + "tests/e2e/paired-client-hosted-browser-cookie-survival.spec.ts": 20100, + "tests/e2e/paired-client-hosted-browser-double-restart.spec.ts": 45800, + "tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts": 48600, + "tests/e2e/paired-client-hosted-browser-host-strip.spec.ts": 23300, + "tests/e2e/paired-client-hosted-browser-quit-survival.spec.ts": 145700, + "tests/e2e/paired-client-hosted-browser-restart-survival.spec.ts": 20700, + "tests/e2e/paired-client-hosted-browser-title-hold.spec.ts": 24300, + "tests/e2e/paired-client-hosted-browser.spec.ts": 53000, + "tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts": 53300, + "tests/e2e/paired-external-worktree-discovery.spec.ts": 19300, + "tests/e2e/paired-instant-browser-tab.spec.ts": 191700, + "tests/e2e/paired-preview-address-bar-convergence.spec.ts": 162000, + "tests/e2e/paired-quick-open-large-tree.spec.ts": 26300, + "tests/e2e/paired-remote-browser-ghost-subscriber-rejoin.spec.ts": 22600, + "tests/e2e/paired-remote-browser-link-open-routing.spec.ts": 78000, + "tests/e2e/paired-remote-browser-stream-reconnect.spec.ts": 57200, + "tests/e2e/paired-remote-html-preview-local-render.spec.ts": 262700, + "tests/e2e/paired-remote-pane-layout-retry.spec.ts": 28200, + "tests/e2e/paired-remote-split-pane-host-retired-ghost.spec.ts": 20000, + "tests/e2e/paired-remote-terminal-browser-link.spec.ts": 78000, + "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts": 35600, + "tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts": 27100, + "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts": 39700, + "tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts": 28300, + "tests/e2e/paired-skill-installation.spec.ts": 26500, + "tests/e2e/paired-split-pane-browser-placement.spec.ts": 85900, + "tests/e2e/paired-startup-exec-readiness.spec.ts": 6000, + "tests/e2e/paired-web-add-project-unavailable-host.spec.ts": 13200, + "tests/e2e/persisted-session-production-upgrade.spec.ts": 26700, + "tests/e2e/pet-status-segment-layout.spec.ts": 25100, + "tests/e2e/pi-ui-prompt-status.spec.ts": 21100, + "tests/e2e/plugin-demo.spec.ts": 26600, + "tests/e2e/plugin-marketplace-content.spec.ts": 39600, + "tests/e2e/plugin-panel-containment.spec.ts": 50300, + "tests/e2e/plugin-startup-budget.spec.ts": 20400, + "tests/e2e/pr-comments-sidebar-cards.spec.ts": 144300, + "tests/e2e/pr11346-selected-runtime-add.spec.ts": 33800, + "tests/e2e/project-group-creation-visibility.spec.ts": 132000, + "tests/e2e/project-group-manual-sort.spec.ts": 100300, + "tests/e2e/pty-snapshot-capability-main-stall.spec.ts": 16100.000000000002, + "tests/e2e/quick-open-file-paths.spec.ts": 22800, + "tests/e2e/remote-session-bulk-open-freeze-repro.spec.ts": 51500, + "tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts": 43600, + "tests/e2e/renderer-crash-recovery-terminal-input.spec.ts": 34500, + "tests/e2e/repo-icon-emoji-picker.spec.ts": 22900, + "tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts": 29900, + "tests/e2e/resource-manager-unbound-session-safety.spec.ts": 18700, + "tests/e2e/resource-usage-warm-reattach.spec.ts": 18700, + "tests/e2e/restart-restore-terminal-input.spec.ts": 77800, + "tests/e2e/rich-markdown-inline-image.spec.ts": 55200, + "tests/e2e/rich-markdown-link-bubble-stacking.spec.ts": 30100, + "tests/e2e/right-sidebar-windows-titlebar.spec.ts": 14600, + "tests/e2e/runtime-host-status-recovery.spec.ts": 156000, + "tests/e2e/settings-agent-awake.spec.ts": 33400, + "tests/e2e/settings-display-name-ime.spec.ts": 18100, + "tests/e2e/settings-search-responsiveness.spec.ts": 15600, + "tests/e2e/settings-search-shortcuts-pane.spec.ts": 15800, + "tests/e2e/settings-skill-detection.spec.ts": 21800, + "tests/e2e/settled-worker-tab-survives-restart.spec.ts": 62400, + "tests/e2e/setup-guide-sidebar.spec.ts": 6800, + "tests/e2e/setup-script-import.spec.ts": 57600, + "tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts": 19100, + "tests/e2e/sidebar-agent-row-identity.spec.ts": 16700, + "tests/e2e/slept-workspace-remount-wake.spec.ts": 31000, + "tests/e2e/source-control-commit-draft-persistence.spec.ts": 16400, + "tests/e2e/source-control-commit-message-ai.spec.ts": 50900, + "tests/e2e/source-control-create-pr-intent-notice-layout.spec.ts": 16900, + "tests/e2e/source-control-create-pr-intent-switch.spec.ts": 41000, + "tests/e2e/source-control-create-pr.spec.ts": 37500, + "tests/e2e/source-control-discard-confirmation.spec.ts": 25900, + "tests/e2e/source-control-large-file-count.spec.ts": 54700, + "tests/e2e/source-control-pr-generation-switch.spec.ts": 146600, + "tests/e2e/source-control-pr-linked-issue-ai.spec.ts": 34200, + "tests/e2e/ssh-config-host-import.spec.ts": 204600, + "tests/e2e/ssh-config-host-picker.spec.ts": 206600, + "tests/e2e/ssh-host-form-modal.spec.ts": 94700, + "tests/e2e/status-bar-caffeinate.spec.ts": 17900, + "tests/e2e/status-bar-session-count-management-kill.spec.ts": 26000, + "tests/e2e/tab-close-navigation.spec.ts": 32100, + "tests/e2e/tab-create-entry-file-paths.spec.ts": 23600, + "tests/e2e/tab-rename-paste-ownership.spec.ts": 15600, + "tests/e2e/tab-rename.spec.ts": 149900, + "tests/e2e/tab-sidebar-closed-overlap.spec.ts": 28600, + "tests/e2e/tabs.spec.ts": 164500, + "tests/e2e/tasks-page.spec.ts": 90400, + "tests/e2e/terminal-attention.spec.ts": 69700, + "tests/e2e/terminal-cjk-ime-committed-text.spec.ts": 122400, + "tests/e2e/terminal-codex-hidden-startup-background.spec.ts": 17500, + "tests/e2e/terminal-codex-home.spec.ts": 14600, + "tests/e2e/terminal-cold-activation-deferral.spec.ts": 22200, + "tests/e2e/terminal-column-desync-repro.spec.ts": 149400, + "tests/e2e/terminal-context-menu-session-id.spec.ts": 20800, + "tests/e2e/terminal-cursor-inactive-style.spec.ts": 15700, + "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts": 23000, + "tests/e2e/terminal-foreground-redraw-freeze.spec.ts": 14800, + "tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts": 107100, + "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts": 76200, + "tests/e2e/terminal-hidden-tui-visual-restore.spec.ts": 70600, + "tests/e2e/terminal-hidden-view-parking.spec.ts": 199400, + "tests/e2e/terminal-history-size-typing-latency.spec.ts": 21200, + "tests/e2e/terminal-ime-exact-byte.spec.ts": 60400, + "tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts": 309900, + "tests/e2e/terminal-korean-composing-chord-order.spec.ts": 16400, + "tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts": 29800, + "tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts": 45100, + "tests/e2e/terminal-korean-preedit-visibility.spec.ts": 62200, + "tests/e2e/terminal-large-paste-responsiveness.spec.ts": 15400, + "tests/e2e/terminal-link-click-ownership.spec.ts": 69100, + "tests/e2e/terminal-link-hover-after-worktree-return.spec.ts": 46600, + "tests/e2e/terminal-long-table-scroll-restore.spec.ts": 74600, + "tests/e2e/terminal-macos-system-key-remap.spec.ts": 97000, + "tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts": 21600, + "tests/e2e/terminal-osc-color-queries.spec.ts": 14900, + "tests/e2e/terminal-osc8-cold-park-restore.spec.ts": 19000, + "tests/e2e/terminal-output-scheduler.spec.ts": 54700, + "tests/e2e/terminal-pane-binding-lifecycle.spec.ts": 46800, + "tests/e2e/terminal-pane-close-layout-consistency.spec.ts": 128900, + "tests/e2e/terminal-pane-content-retention.spec.ts": 45100, + "tests/e2e/terminal-pane-layout-resize.spec.ts": 47500, + "tests/e2e/terminal-pane-split-identity.spec.ts": 81000, + "tests/e2e/terminal-pane-title-editing.spec.ts": 189100, + "tests/e2e/terminal-pane-title-focus-handoff.spec.ts": 158000, + "tests/e2e/terminal-pane-title-strip-placement.spec.ts": 62400, + "tests/e2e/terminal-parked-cli-split.spec.ts": 28900, + "tests/e2e/terminal-parked-close-retirement.spec.ts": 16300, + "tests/e2e/terminal-parked-memory.spec.ts": 80100, + "tests/e2e/terminal-paste-ownership.spec.ts": 66100, + "tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts": 19400, + "tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts": 16700, + "tests/e2e/terminal-push-delivery-loss-recovery.spec.ts": 16500, + "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts": 42100, + "tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts": 35800, + "tests/e2e/terminal-reattach-mouse-mode-leak.spec.ts": 22800, + "tests/e2e/terminal-reattach-tui-mouse-mode.spec.ts": 44200, + "tests/e2e/terminal-restart-persistence.spec.ts": 109600, + "tests/e2e/terminal-reveal-paused-render-repro.spec.ts": 31100, + "tests/e2e/terminal-reveal-stale-pty-resize-repro.spec.ts": 49000, + "tests/e2e/terminal-scroll-intent-follow.spec.ts": 73200, + "tests/e2e/terminal-send-agent-prompt-submit.spec.ts": 51400, + "tests/e2e/terminal-shortcuts.spec.ts": 91700, + "tests/e2e/terminal-sleep-wake-restore.spec.ts": 16800, + "tests/e2e/terminal-split-pane-paste-ownership.spec.ts": 30700, + "tests/e2e/terminal-streaming-refocus-viewport.spec.ts": 15800, + "tests/e2e/terminal-stuck-occlusion-recovery.spec.ts": 15200, + "tests/e2e/terminal-tab-close-restart-persistence.spec.ts": 22800, + "tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts": 57100, + "tests/e2e/terminal-tab-close-running-confirm.spec.ts": 27500, + "tests/e2e/terminal-tab-switch-sigwinch-restore.spec.ts": 30400, + "tests/e2e/terminal-tab-switch-visual-restore.spec.ts": 90300, + "tests/e2e/terminal-tui-wheel-drain.spec.ts": 528000, + "tests/e2e/terminal-tui-wheel-reports.spec.ts": 34400, + "tests/e2e/terminal-typing-latency.spec.ts": 15200, + "tests/e2e/terminal-wedged-write-pipeline-recovery.spec.ts": 71900, + "tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts": 15900, + "tests/e2e/update-install-renderer-checkpoint-recovery.spec.ts": 14900, + "tests/e2e/usage-overview.spec.ts": 33800, + "tests/e2e/voice-microphone-selection.spec.ts": 34000, + "tests/e2e/windows-terminal-env-icons.spec.ts": 15400, + "tests/e2e/workspace-back-forward-navigation.spec.ts": 108500, + "tests/e2e/workspace-board-lane-virtualization.spec.ts": 107700, + "tests/e2e/workspace-emoji-picker.spec.ts": 32900, + "tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts": 21100, + "tests/e2e/workspace-space-git-status.spec.ts": 16100.000000000002, + "tests/e2e/worktree-active-delete-scroll-position.spec.ts": 59200, + "tests/e2e/worktree-card-downward-drag.spec.ts": 60000, + "tests/e2e/worktree-delete-shortcut.spec.ts": 20600, + "tests/e2e/worktree-jump-palette-filter.spec.ts": 96200, + "tests/e2e/worktree-lifecycle.spec.ts": 44000, + "tests/e2e/worktree-lineage-agent-expansion.spec.ts": 21800, + "tests/e2e/worktree-lineage.spec.ts": 96700, + "tests/e2e/worktree-recent-sort.spec.ts": 28400, + "tests/e2e/worktree-scroll-to-current.spec.ts": 62400, + "tests/e2e/worktree-smart-sort.spec.ts": 14100, + "tests/e2e/worktree-switch-first-paint.spec.ts": 52200, + "tests/e2e/worktree-switch-responsiveness.spec.ts": 15100, + "tests/e2e/worktree.spec.ts": 118100 + } + } +} diff --git a/config/scripts/ci-shard-timings.md b/config/scripts/ci-shard-timings.md new file mode 100644 index 00000000000..f6e846cfd6d --- /dev/null +++ b/config/scripts/ci-shard-timings.md @@ -0,0 +1,93 @@ +# Timing-based CI shards + +The eight unit shards and fourteen general E2E shards use longest-processing-time +assignment of whole files to the currently lightest shard. Ties use file path and +then shard index, independent of filesystem enumeration and locale. Unknown, +zero, or invalid durations use the baseline's positive median (1 second when no +positive evidence exists). Deleted files never enter discovery. Unit weights add +526ms per file for measured transform/setup/import/environment overhead. + +Unit assignment runs inside Vitest's sequencer after discovery and CLI exclusions; +Vitest's default sort, workers and isolation remain intact. It is enabled only by +`ORCA_BALANCE_UNIT_SHARDS=1`; ordinary local runs and explicit file filters retain +their existing behavior. E2E uses Playwright's native `--list` and `--test-list`, +retaining project filters, skipped tests and complete serial groups within files. +The workflow verifies selected test IDs against full discovery before executing. +Dedicated SSH, native IME, WSL and first-paint lanes are unchanged. + +## Evidence and limits + +`ci-shard-timings.json` records run IDs and every contributing job ID: + +- Unit run **34675583768**, Node 24, all eight successful shards: 8,484 completed + file durations. The summed transform/setup/import/environment durations divided + by measured file count give a rounded-up **526ms** per-file overhead allowance. + The original shard weighted loads were **764–849 worker-seconds**, versus + **792–792** after balancing the identical measured files. File counts change + from **1,056–1,065** to **1,060–1,061**. +- General E2E run **34652504501**, all fourteen shard logs: 291 files with completed + headless test durations, including failures. Headful benchmark reruns are not + counted. Original completed test loads were **540–1,727 seconds**, versus + **1,083–1,093** after whole-file balancing on the same measured files. The longest + measured file is **528 seconds**, below the balanced shard load. +- Current checkout discovery at validation contained **8,553 unit files** after the + workflow's exact exclusions and **733 headless E2E tests in 340 files**. New and + unmeasured files remain selected. Projected current loads were about **797 + worker-seconds** per unit shard (1,068–1,070 files) and **1,190–1,200 seconds** per + E2E shard (22–25 files). + +These are scheduling projections, not measured post-change wall-clock gains. +Unit durations overlap across workers and the overhead allowance is an average, +not a per-file import profile. E2E evidence includes failed shards and can omit +unfinished tests; unknowns receive a deterministic estimate. Historical timings +age as specs change. Full CI runs on the existing runner classes are required to +measure elapsed-time and occupancy improvements, including discovery overhead. +No retries, assertions, coverage exclusions, runner classes or shard counts changed. + +## Reproduction and refresh + +Every shard uploads an artifact named with its shard, Node version where relevant, +and run attempt. `assignment.json` contains the checked-out source SHA, run ID, +attempt, baseline SHA-256, algorithm, fallback, all shard files and chosen shard. +E2E also retains both discovery reports and `selected.txt`. Artifacts live for +14 days. A rerun of the same source uses the same checked-in baseline rather than +mutable timing caches; a GitHub job rerun therefore keeps its assignment. + +For E2E reproduction, check out the recorded source and pass the saved list to the +existing command: `pnpm run test:e2e --test-list=/path/to/selected.txt` with the same +CI environment/build inputs. For unit reproduction, use the unchanged workflow +command and exclusions with `ORCA_BALANCE_UNIT_SHARDS=1` and the recorded +`--shard=INDEX/8`. Direct test-file reruns remain supported. + +To refresh the baseline, download `log-JOB_ID.txt` files into one directory from +exactly one eight-shard unit run and one fourteen-shard general E2E run. Use the +job IDs from the Actions jobs API and fetch each with +`gh api repos/stablyai/orca/actions/jobs/JOB_ID/logs`. Do not include dedicated +lanes or multiple attempts. Then run: + +```sh +node config/scripts/ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN_ID E2E_RUN_ID config/scripts/ci-shard-timings.json +``` + +The initial source logs are in `/tmp/orca-ci-shard-logs`; two were reused from +`/tmp/orca-ci-audit`, and the remaining twenty were fetched read-only. Reimporting +those logs reproduced the checked-in JSON byte-for-byte. Review file-count and +load projections before adopting a new baseline; no network access is needed to +plan or run shards. + +## Validation + +- 74 focused tests passed across the two new test files and existing PR + parallelism, E2E gate and release E2E dispatch contracts. +- The pinned Playwright CLI selected the real 733-test suite across all fourteen + saved test lists with exact-once identity coverage and no missing tests. +- A temporary native Playwright fixture checks fourteen shards, serial groups, + skipped cases, headful filtering and mismatch rejection without launching UI. +- Real Vitest discovery with all workflow exclusions yielded 8,553 files; the + sequencer's eight assignments covered each exactly once. An actual opt-in + Vitest shard executed successfully and persisted its manifest. +- Focused TypeScript checking of `config/vitest.config.ts` and imported modules, + oxlint, formatting and baseline reimport checks passed. + +All local tests used `ORCA_BACKGROUND_LAUNCH=1` in background tool sessions. No app +windows or full E2E test bodies were launched. diff --git a/config/scripts/ci-unit-sequencer.mjs b/config/scripts/ci-unit-sequencer.mjs new file mode 100644 index 00000000000..cd55b3343b2 --- /dev/null +++ b/config/scripts/ci-unit-sequencer.mjs @@ -0,0 +1,19 @@ +import { relative } from 'node:path' +import { BaseSequencer } from 'vitest/node' +import { balanceFiles, readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' + +export default class TimingSequencer extends BaseSequencer { + async shard(specs) { + const { index, count } = this.ctx.config.shard + const key = (spec) => relative(this.ctx.config.root, spec.moduleId).replaceAll('\\', '/') + const baseline = readTimingBaseline('unit') + const assignment = balanceFiles(specs.map(key), count, baseline.timings, baseline.overheadMs) + writeAssignment(process.env.ORCA_SHARD_MANIFEST ?? 'ci-shards/unit-assignment.json', { + ...assignment, + baselineSha256: baseline.baselineSha256, + selectedShard: index + }) + const selected = new Set(assignment.shards[index - 1].files) + return specs.filter((spec) => selected.has(key(spec))) + } +} diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index 97b6adeb694..fc605bac5bd 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -178,10 +178,15 @@ describe('computer-use e2e workflow', () => { 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs' ) expect(runs).toContain('pnpm verify:computer-native') + expect( + runs.find((run) => run.includes('config/scripts/build-native-for-platform.test.mjs')) + ).toContain('--config config/vitest.config.ts') expect(runs.join('\n')).not.toContain('test:e2e:computer') expect(workflow.jobs.mac).toBeUndefined() expect(workflow.on.pull_request.paths).toEqual( expect.arrayContaining([ + 'config/scripts/build-native-for-platform.mjs', + 'config/scripts/build-native-for-platform.test.mjs', 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs', 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3f055ce222d..b7d5f16400b 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' @@ -433,3 +434,49 @@ describe('electron-builder config', () => { }) }) }) + +describe('arch-aware packaging guard', () => { + // electron-builder Arch enum: ia32=0, x64=1, armv7l=2, arm64=3. + const HOST_ARCH = process.arch === 'arm64' ? 3 : 1 + const OTHER_ARCH = process.arch === 'arm64' ? 1 : 3 + const OTHER_ARCH_NAME = process.arch === 'arm64' ? 'x64' : 'arm64' + const SHERPA_PLATFORM = process.platform === 'win32' ? 'win' : process.platform + const otherSherpa = `sherpa-onnx-${SHERPA_PLATFORM}-${OTHER_ARCH_NAME}` + const packHost = (arch) => + electronBuilderConfig.beforePack({ electronPlatformName: process.platform, arch }) + + it('allows packaging the host platform and architecture', () => { + expect(() => packHost(HOST_ARCH)).not.toThrow() + }) + + it('requires the other architecture natives to be installed', () => { + const otherSherpaInstalled = existsSync( + join(REPO_ROOT, 'node_modules', otherSherpa, 'package.json') + ) + const otherSherpaExpected = Object.hasOwn( + require('../../package.json').optionalDependencies, + otherSherpa + ) + if (otherSherpaExpected && !otherSherpaInstalled) { + expect(() => packHost(OTHER_ARCH)).toThrow(otherSherpa) + expect(() => packHost(OTHER_ARCH)).toThrow('pnpm install:release') + expect(() => packHost(HOST_ARCH)).not.toThrow() + } else { + expect(() => packHost(OTHER_ARCH)).not.toThrow() + } + }) + + it('requires installed Windows addons for Windows packaging', () => { + const windowsAddon = electronBuilderConfig.win.extraResources.some( + (resource) => resource.to === join('node_modules', '@vscode', 'windows-process-tree') + ) + const packWindows = () => + electronBuilderConfig.beforePack({ electronPlatformName: 'win32', arch: 1 }) + if (process.platform === 'win32' || windowsAddon) { + expect(packWindows).not.toThrow() + } else { + expect(packWindows).toThrow('@vscode/windows-process-tree') + expect(packWindows).toThrow('Windows packaging requires a Windows host') + } + }) +}) diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index 45145572b80..6eb34aff896 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -1,9 +1,10 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { cp, mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { delimiter, dirname, join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' +import { removeTree } from '../../src/shared/windows-transient-lock-removal.ts' const require = createRequire(import.meta.url) const projectRoot = resolve(import.meta.dirname, '..', '..') @@ -21,6 +22,13 @@ const { verifyPackagedMainRuntimeDeps } = require('../packaged-runtime-node-modules.cjs') +// Why this and not process.platform: @vscode/windows-process-tree is the only os: win32 npm +// addon left, so its presence is what decides whether the win32 plan resolves. +// @orca/windows-registry is a workspace link present on every host, so it proves nothing. +const windowsAddonsInstalled = existsSync( + join(projectRoot, 'node_modules', '@vscode', 'windows-process-tree', 'package.json') +) + describe('packaged runtime resources', () => { it('verifies packaged main runtime deps from Windows-style asar entries', async () => { const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-')) @@ -40,7 +48,7 @@ describe('packaged runtime resources', () => { expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -75,7 +83,7 @@ describe('packaged runtime resources', () => { }) expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -93,7 +101,7 @@ describe('packaged runtime resources', () => { /managed-agent-hook-controls\.js was not found/ ) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -127,7 +135,7 @@ describe('packaged runtime resources', () => { await mkdir(join(resourcesDir, 'node_modules', 'jsonc-parser'), { recursive: true }) expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -147,7 +155,7 @@ describe('packaged runtime resources', () => { expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -169,7 +177,7 @@ describe('packaged runtime resources', () => { expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -207,7 +215,7 @@ describe('packaged runtime resources', () => { 'Unsupported packaged runtime architecture: 4' ) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -243,7 +251,7 @@ describe('packaged runtime resources', () => { `console payload ${arch}` ) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } } }) @@ -264,7 +272,9 @@ describe('packaged runtime resources', () => { }) it('includes the Claude agent SDK in every desktop package plan', () => { - for (const platform of ['darwin', 'linux', 'win32']) { + for (const platform of windowsAddonsInstalled + ? ['darwin', 'linux', 'win32'] + : ['darwin', 'linux']) { const packagedTargets = createPackagedRuntimeNodeModuleResources(platform).map( (resource) => resource.to ) @@ -293,7 +303,7 @@ describe('packaged runtime resources', () => { 'Unsupported packaged runtime architecture: universal' ) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -315,7 +325,7 @@ describe('packaged runtime resources', () => { 'watcher-linux-x64-glibc' ]) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -333,7 +343,7 @@ describe('packaged runtime resources', () => { await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs']) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -353,7 +363,7 @@ describe('packaged runtime resources', () => { 'sherpa-onnx.node' ]) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -369,7 +379,7 @@ describe('packaged runtime resources', () => { await expect(readdir(packageDir)).resolves.toEqual(['index.cjs']) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -383,7 +393,7 @@ describe('packaged runtime resources', () => { }) ).rejects.toThrow(/Missing packaged resources directory/) } finally { - await rm(root, { recursive: true, force: true }) + await removeTree(root) } }) @@ -448,7 +458,7 @@ describe('packaged runtime resources', () => { await expect(stat(wrongArchPackage)).rejects.toMatchObject({ code: 'ENOENT' }) } finally { process.env.PATH = previousPath - await rm(root, { recursive: true, force: true }) + await removeTree(root) } } ) @@ -509,7 +519,7 @@ describe('packaged runtime resources', () => { ).resolves.toContain('"version": "9.9.9"') await expect(readFile(join(resourcesDir, 'package-type'), 'utf8')).resolves.toBe('AppImage') } finally { - await rm(root, { recursive: true, force: true }) + await removeTree(root) } } ) @@ -564,11 +574,13 @@ describe('lazily required packages reach Resources/node_modules', () => { const covered = (platform) => destinations[platform].has(`node_modules/${packageName}`) || destinations[platform].has(`node_modules/${specifier}`) - // Windows carries the full closure, so an uncovered specifier is uncovered everywhere. - expect( - covered('win'), - `${source} lazily requires '${specifier}', but nothing copies it to Resources/node_modules` - ).toBe(true) + // The Windows CI lane checks the full closure with its native addons installed. + if (windowsAddonsInstalled) { + expect( + covered('win'), + `${source} lazily requires '${specifier}', but nothing copies it to Resources/node_modules` + ).toBe(true) + } if (covered('mac') && covered('linux')) { continue } @@ -598,7 +610,7 @@ describe('lazily required packages reach Resources/node_modules', () => { const dataset = require(probe)('emojibase-data/en/shortcodes/emojibase.json') expect(Object.keys(dataset).length).toBeGreaterThan(1000) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) }) diff --git a/config/scripts/electron-runtime-floor.test.ts b/config/scripts/electron-runtime-floor.test.ts new file mode 100644 index 00000000000..77a1d4741e0 --- /dev/null +++ b/config/scripts/electron-runtime-floor.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Why a floor and not just a pin: Electron 43.5.0/43.6.0 set and unset `GDK_GL` + * around `gtk_init()` while FontConfig warmed up on a pool thread, and below + * glibc 2.41 that frees `environ` under a concurrent `getenv()` — a launch-time + * use-after-free on every Ubuntu we support (stablyai/orca#20081). 43.7.0 stops + * freeing the published `environ`. A downgrade past it re-ships that crash, and + * nothing else in the tree would notice. + */ +const MINIMUM_ELECTRON_VERSION = '43.7.0' + +function parseVersion(specifier: string): [number, number, number] { + const match = /(\d+)\.(\d+)\.(\d+)/.exec(specifier) + if (!match) { + throw new Error(`unparseable Electron version: ${specifier}`) + } + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +function meetsRuntimeFloor(specifier: string): boolean { + const version = parseVersion(specifier) + const floor = parseVersion(MINIMUM_ELECTRON_VERSION) + for (const [index, part] of version.entries()) { + if (part !== floor[index]) { + return part > floor[index] + } + } + return true +} + +describe('electron runtime floor', () => { + it.each([ + ['42.9.0', false], + ['43.6.0', false], + ['43.7.0', true], + ['43.7.1', true], + ['43.8.0', true], + ['44.0.0', true] + ])('reads %s as meeting the floor: %s', (specifier, expected) => { + expect(meetsRuntimeFloor(specifier)).toBe(expected) + }) + + it('pins Electron at or above the glibc environ-race fix', () => { + const packageJson = JSON.parse( + readFileSync(join(__dirname, '../../package.json'), 'utf-8') + ) as { devDependencies: Record } + const specifier = packageJson.devDependencies.electron + + expect( + meetsRuntimeFloor(specifier), + `electron ${specifier} is below the ${MINIMUM_ELECTRON_VERSION} runtime floor` + ).toBe(true) + }) +}) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index a7ac04a1a09..285b58f2ac7 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -116,9 +116,9 @@ describe('Electron Vite output contract', () => { expect(external('node:fs', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) - expect(external('psl', undefined, false)).toBe(false) + expect(external('tldts', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) - expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl') + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') }) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 10e8426c2a5..6278a1e0b41 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -22,7 +22,7 @@ const runtime = readRuntimeArg() const NATIVE_MODULES = [ 'node-pty', ...(process.platform === 'win32' - ? ['windows-native-registry', '@vscode/windows-process-tree'] + ? ['@orca/windows-registry', '@vscode/windows-process-tree'] : []) ] const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] @@ -275,7 +275,7 @@ function loadNativeModule(moduleName) { } return } - if (moduleName === 'windows-native-registry') { + if (moduleName === '@orca/windows-registry') { const registry = require(moduleName) // Why: the package defers loading its .node addon until the first registry call. registry.getRegistryKey(registry.HK.CU, 'Environment') diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 1e7d888d2e2..47fe435edcc 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -87,7 +87,7 @@ describe('ensure-native-runtime', () => { const log = readFileSync(logPath, 'utf8') expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2) expect(log).toContain(join('node_modules', 'node-pty')) - expect(log).toContain(join('node_modules', 'windows-native-registry')) + expect(log).toContain(join('node_modules', '@orca', 'windows-registry')) } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -299,11 +299,11 @@ function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) { if (process.platform !== 'win32') { return } - const registryDir = join(projectDir, 'node_modules', 'windows-native-registry') + const registryDir = join(projectDir, 'node_modules', '@orca', 'windows-registry') mkdirSync(registryDir, { recursive: true }) writeFileSync( join(registryDir, 'package.json'), - '{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n' + '{"name":"@orca/windows-registry","version":"1.0.0","main":"index.js"}\n' ) const markerGate = requiresMarker ? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }` diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs index acf31aae6a1..9810f952e77 100644 --- a/config/scripts/generate-rpc-params-catalog.mjs +++ b/config/scripts/generate-rpc-params-catalog.mjs @@ -51,7 +51,14 @@ function indexableModules() { const source = readFileSync(path.join(RPC_DIR, file), 'utf8') for (const [, specifier] of source.matchAll(/from\s+'(\.[^']+)'/g)) { const resolved = `${path.resolve(path.dirname(path.join(RPC_DIR, file)), specifier)}.ts` - if (resolved.startsWith(`${SHARED_DIR}${path.sep}`) && existsSync(resolved)) { + // Never re-add the generator's own output: a module under RPC_DIR may import the + // catalog for a type-only contract, and bundling a stale catalog makes regeneration + // crash in exactly the state that requires regenerating. + if ( + resolved !== OUTPUT_PATH && + resolved.startsWith(`${SHARED_DIR}${path.sep}`) && + existsSync(resolved) + ) { modules.add(resolved) } } @@ -197,9 +204,10 @@ ${uncataloged.map((name) => ` '${name}'`).join(',\n')} export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD -// Why: z.output is the post-parse shape the handler receives. z.input is not a -// send-side type here — requiredString is z.unknown().transform(...), so its input -// admits any value and loses optional/default semantics. +// Why: z.output is the post-parse shape the handler receives, which is not what a +// client may send — a .default() field reads as required. z.input is not the answer +// either: requiredString is z.unknown().transform(...), so its input admits any value. +// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map. export type RpcParams = (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index 226ea74c4fb..a84923e762d 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -6,8 +6,7 @@ import path from 'node:path' import process from 'node:process' import { isDeepStrictEqual } from 'node:util' -// Why: the three artifacts version independently — bumping one shape must not -// rewrite the others or bypass the registry's schema-gated append-only guard. +// Version artifacts independently to preserve the registry's schema-gated append-only guard. const CURRENT_MANIFEST_SCHEMA_VERSION = 2 const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1 const RELEASE_MAPPING_SCHEMA_VERSION = 1 @@ -41,18 +40,19 @@ function normalizeText(bytes) { return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') } -function classifyFile(bytes) { +function normalizedTextOrNull(bytes) { if (bytes.includes(0)) { - return 'binary' + return null } try { - normalizeText(bytes) - return 'text' + return normalizeText(bytes) } catch { - return 'binary' + return null } } +const classifyFile = (bytes) => (normalizedTextOrNull(bytes) === null ? 'binary' : 'text') + function assertSafeRelativePath(relativePath) { if ( path.isAbsolute(relativePath) || @@ -64,17 +64,17 @@ function assertSafeRelativePath(relativePath) { } function describeFile(manifestPath, bytes, executable) { - const classification = classifyFile(bytes) + const normalized = normalizedTextOrNull(bytes) const exactSha256 = sha256(bytes) - const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null + const textNormalizedSha256 = normalized === null ? null : sha256(normalized) return { path: manifestPath, size: bytes.length, executable, - classification, + classification: normalized === null ? 'binary' : 'text', exactSha256, textNormalizedSha256, - identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256, + identitySha256: normalized !== null && !executable ? textNormalizedSha256 : exactSha256, gitBlobSha: gitObjectSha('blob', bytes).toString('hex') } } diff --git a/config/scripts/headless-serve-shutdown-matrix.test.mjs b/config/scripts/headless-serve-shutdown-matrix.test.mjs new file mode 100644 index 00000000000..7231cc21e4f --- /dev/null +++ b/config/scripts/headless-serve-shutdown-matrix.test.mjs @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnSync } = vi.hoisted(() => ({ spawnSync: vi.fn() })) +vi.mock('node:child_process', () => ({ spawnSync })) + +let directory +let artifact +let originalArgv +let originalExitCode +const commands = () => spawnSync.mock.calls.map(([, args]) => args) +const signalRuns = () => commands().filter((args) => ['INT', 'TERM'].includes(args.at(-1))) +const succeeded = { status: 0, stdout: '', stderr: '' } + +async function run(...options) { + process.argv = ['node', 'runner', '--appimage', artifact, ...options] + await import('./run-headless-serve-shutdown-docker.mjs') +} + +beforeEach(() => { + vi.resetModules() + spawnSync.mockReset().mockReturnValue(succeeded) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + directory = mkdtempSync(join(tmpdir(), 'orca-shutdown-matrix-')) + artifact = join(directory, 'original.AppImage') + writeFileSync(artifact, 'original package bytes') + originalArgv = process.argv + originalExitCode = process.exitCode +}) + +afterEach(() => { + process.argv = originalArgv + process.exitCode = originalExitCode + rmSync(directory, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('packaged shutdown matrix', () => { + it('shares extraction but isolates every entrypoint and signal', async () => { + await run('--all-entrypoints') + expect(commands().filter((args) => args[0] === 'build')).toHaveLength(1) + const startup = commands().filter((args) => + args.includes('/usr/local/bin/run-appimage-desktop-startup-case') + ) + const extraction = commands().filter((args) => + args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract')) + ) + expect(startup).toHaveLength(1) + expect(extraction).toHaveLength(1) + expect(commands().indexOf(startup[0])).toBeLessThan(commands().indexOf(extraction[0])) + expect(signalRuns()).toHaveLength(6) + const names = new Set() + for (const [index, args] of signalRuns().entries()) { + const entrypoint = ['app', 'launcher', 'appimage'][Math.floor(index / 2)] + expect(args).toContain(`ORCA_TEST_ENTRYPOINT=${entrypoint}`) + expect(args).toContain( + `ORCA_SIGNAL_TARGET=${entrypoint === 'appimage' ? 'serving-electron' : 'app'}` + ) + expect(args).toContain( + `ORCA_INT_DELIVERY=${entrypoint === 'appimage' ? 'pid' : 'foreground-process-group'}` + ) + expect(args.at(-1)).toBe(index % 2 === 0 ? 'INT' : 'TERM') + expect(args).toContain(`${artifact}:/input/orca.AppImage:ro`) + expect(args.some((arg) => arg.endsWith(':/artifacts:ro'))).toBe(true) + expect(args).toContain('--rm') + names.add(args[args.indexOf('--name') + 1]) + } + expect(names.size).toBe(6) + const evidence = console.log.mock.calls + .map(([line]) => line) + .filter((line) => line.startsWith('{')) + .map(JSON.parse) + expect(evidence).toHaveLength(3) + expect( + evidence.every( + (entry) => + entry.sha256 === createHash('sha256').update('original package bytes').digest('hex') + ) + ).toBe(true) + expect( + commands() + .slice(-2) + .map((args) => args.slice(0, 2)) + ).toEqual([ + ['volume', 'rm'], + ['image', 'rm'] + ]) + }) + + it('attributes failures and still attempts later cases before cleanup', async () => { + spawnSync.mockImplementation((_, args) => + args.at(-1) === 'INT' ? { ...succeeded, status: 7 } : succeeded + ) + await expect(run('--all-entrypoints')).rejects.toThrow( + 'app:INT:7, launcher:INT:7, appimage:INT:7' + ) + expect(signalRuns()).toHaveLength(6) + expect(commands().at(-2).slice(0, 2)).toEqual(['volume', 'rm']) + }) + + it('cleans setup resources without running cases after failed extraction', async () => { + spawnSync.mockImplementation((_, args) => + args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract')) + ? { ...succeeded, status: 9 } + : succeeded + ) + await expect(run('--all-entrypoints')).rejects.toThrow('docker run failed') + expect(signalRuns()).toHaveLength(0) + expect( + commands() + .slice(-2) + .map((args) => args.slice(0, 2)) + ).toEqual([ + ['volume', 'rm'], + ['image', 'rm'] + ]) + }) + + it('preserves individual launcher overlay invocations', async () => { + await run('--entrypoint', 'launcher', '--launcher-exec-overlay') + expect(signalRuns()).toHaveLength(2) + expect(signalRuns().every((args) => args.includes('ORCA_TEST_ENTRYPOINT=launcher'))).toBe(true) + expect( + commands().some((args) => + args.some((arg) => arg.includes("sed -i 's/^ELECTRON_RUN_AS_NODE=1")) + ) + ).toBe(true) + }) + + it('rejects ambiguous matrix overrides before invoking Docker', async () => { + await expect(run('--all-entrypoints', '--entrypoint', 'launcher')).rejects.toThrow( + 'cannot be combined' + ) + expect(spawnSync).not.toHaveBeenCalled() + }) +}) diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index 90a3f73c77d..3b71a2e774e 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -56,12 +56,6 @@ describe('headless serve shutdown PR gate', () => { const packageStep = steps.find((step) => step.name === 'Package unpacked app') const markerStep = steps.find((step) => step.name === 'Verify root-package marker payloads') const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown') - const launcherShutdownStep = steps.find( - (step) => step.name === 'Verify extracted launcher serve signal shutdown' - ) - const appImageShutdownStep = steps.find( - (step) => step.name === 'Verify AppImage CLI registration and serve signal shutdown' - ) expect(workflow.jobs.package['timeout-minutes']).toBe(90) expect(packageStep.run).toContain('--linux AppImage deb rpm --x64 --publish never') @@ -69,19 +63,13 @@ describe('headless serve shutdown PR gate', () => { expect(markerStep.run).toContain('rpm2cpio') expect(steps.indexOf(markerStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(shutdownStep.run).toBe( - 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage' + 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --all-entrypoints' ) - expect(launcherShutdownStep.run).toContain( - 'node config/scripts/run-headless-serve-shutdown-docker.mjs' - ) - expect(launcherShutdownStep.run).toContain('--entrypoint launcher') - expect(appImageShutdownStep.run).toContain('--entrypoint appimage') - expect(appImageShutdownStep.run).toContain('--signal-target serving-electron') - expect(appImageShutdownStep.run).toContain('--int-delivery pid') expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(markerStep)) - expect(steps.indexOf(launcherShutdownStep)).toBeGreaterThan(steps.indexOf(shutdownStep)) - expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep)) + expect( + steps.filter((step) => step.run?.includes('run-headless-serve-shutdown-docker.mjs')) + ).toHaveLength(1) }) it('keeps readiness polling finite and leak-free', () => { diff --git a/config/scripts/hermes-run-correlation-benchmark.mjs b/config/scripts/hermes-run-correlation-benchmark.mjs new file mode 100644 index 00000000000..364774577cf --- /dev/null +++ b/config/scripts/hermes-run-correlation-benchmark.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, basename } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const fixture = await mkdtemp(join(tmpdir(), 'orca-hermes-correlation-')) +const baselineDirectory = process.argv[2] +const key = (seconds) => + new Date(Date.UTC(2026, 0, 1) + seconds * 1000) + .toISOString() + .replace(/[-:]/g, '') + .replace('T', '_') + .slice(0, 15) +try { + for (const host of ['native', 'relay']) { + const entry = + host === 'native' + ? 'src/main/automations/hermes-cron-run-content.ts' + : 'src/relay/hermes-run-correlation.ts' + const readers = [] + for (const mode of baselineDirectory ? ['baseline', 'current'] : ['current']) { + const bundle = join(fixture, `${host}-${mode}.cjs`) + await build({ + entryPoints: [join(root, entry)], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + plugins: + mode === 'baseline' + ? [ + { + name: 'baseline-correlation', + setup(builder) { + builder.onLoad( + { filter: /hermes-(cron-run-content|run-correlation)\.ts$/ }, + async (args) => ({ + contents: await readFile( + join(baselineDirectory, basename(args.path)), + 'utf8' + ), + loader: 'ts' + }) + ) + } + } + ] + : [] + }) + readers.push({ mode, ...createRequire(import.meta.url)(bundle) }) + } + if (readers.length === 2) { + let seed = 92817 + const random = (max) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max + } + const pool = [ + null, + '', + 'invalid', + '20260101_000000', + '20260101_000200', + '20260102_000000', + '20260103_000000', + '20260101_240000' + ] + for (let trial = 0; trial < 200; trial++) { + const sessions = Array.from({ length: random(70) }, (_, i) => ({ + kind: 'session', + id: `session-${i}`, + job_id: 'job', + run_at: null, + run_key: pool[random(pool.length)], + output_content: `session ${i}` + })) + const outputs = Array.from({ length: random(70) }, (_, i) => ({ + kind: 'output', + id: `output-${i}`, + job_id: 'job', + run_at: null, + run_key: pool[random(pool.length)], + output_path: 'unused', + output_content: `output ${i}` + })) + for (const method of [ + 'mergeHermesOutputAndSessionRunRefs', + 'mergeHermesOutputAndSessionRuns' + ]) { + assert.deepEqual( + readers[1][method](outputs, sessions), + readers[0][method](outputs, sessions) + ) + } + } + console.log(JSON.stringify({ host, randomizedParityCases: 400 })) + } + for (const runs of [100, 1000, 5000]) { + const sessions = Array.from({ length: runs }, (_, i) => ({ + kind: 'session', + id: `session-${i}`, + job_id: 'job', + run_at: null, + run_key: key(i * 3600) + })).toReversed() + const outputs = Array.from({ length: runs }, (_, i) => ({ + kind: 'output', + id: `output-${i}`, + job_id: 'job', + run_at: null, + run_key: key(i * 3600 + 120), + output_path: 'unused' + })) + let expected + for (const reader of [...readers, ...readers.toReversed()]) { + const start = performance.now() + const result = reader.mergeHermesOutputAndSessionRunRefs(outputs, sessions) + const durationMs = performance.now() - start + assert.equal(result.length, runs) + result.forEach((row, i) => assert.equal(row.session.id, `session-${i}`)) + if (expected) { + assert.deepEqual(result, expected) + } + expected = result + console.log(JSON.stringify({ host, mode: reader.mode, runs, durationMs })) + } + } + } +} finally { + await rm(fixture, { recursive: true, force: true }) +} diff --git a/config/scripts/journal-replay-retention-benchmark.mjs b/config/scripts/journal-replay-retention-benchmark.mjs new file mode 100644 index 00000000000..3991a886ab5 --- /dev/null +++ b/config/scripts/journal-replay-retention-benchmark.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +// Pass a directory containing journal-open.ts and journal-row-table.ts from the base commit. +const baselineDir = process.argv[2] +assert.ok( + baselineDir, + 'Usage: node --expose-gc journal-replay-retention-benchmark.mjs BASELINE_DIR' +) +assert.ok(global.gc, 'Run with --expose-gc to measure live backing memory during replay') +const root = fileURLToPath(new URL('../..', import.meta.url)) +const fixture = await mkdtemp(join(tmpdir(), 'orca-journal-replay-bench-')) +try { + const implementations = {} + for (const arm of ['baseline', 'current']) { + const outfile = join(fixture, `${arm}.cjs`) + await build({ + stdin: { + contents: + "export {openAgentSessionJournal} from './src/main/native-chat/agent-session-journal/journal-store-factory'; export {loadJournal} from './src/main/native-chat/agent-session-journal/journal-open'; export {journalDatabaseFile} from './src/main/native-chat/agent-session-journal/journal-paths';", + resolveDir: root + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + plugins: [ + { + name: 'replay-memory-probe', + setup(plugin) { + plugin.onLoad( + { filter: /journal-(?:open|row-table|reducer)\.ts$/ }, + async ({ path }) => { + const leaf = basename(path) + let source = await readFile( + arm === 'baseline' && leaf !== 'journal-reducer.ts' + ? join(baselineDir, leaf) + : path, + 'utf8' + ) + if (leaf === 'journal-reducer.ts') { + const marker = + 'export function applyJournalRow(state: JournalReducerState, row: JournalRow): void {' + assert.ok(source.includes(marker)) + source = source.replace( + marker, + `${marker}\nglobalThis.__replayMemoryProbe?.(row.seq);` + ) + } + return { contents: source, loader: 'ts', resolveDir: dirname(path) } + } + ) + } + } + ] + }) + implementations[arm] = createRequire(import.meta.url)(outfile) + } + const identity = { + sessionId: 'benchmark', + workspaceId: 'fixture', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread' } + } + const journalDir = join(fixture, 'session') + const journal = await implementations.current.openAgentSessionJournal({ identity, journalDir }) + const item = { provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 } + const text = 'x'.repeat(32768) + for (let revision = 0; revision < 2000; revision++) { + await journal.appendItem( + item, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `${text}${revision}` }] + }, + { fence: 1 } + ) + } + await journal.close() + for (const arm of ['baseline', 'current', 'current', 'baseline']) { + global.gc() + const start = performance.now() + let loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + const ms = performance.now() - start + assert.equal(loaded.state.items.size, 1) + assert.equal([...loaded.state.items.values()][0].revision, 2000) + loaded = null + global.gc() + const initialHeap = process.memoryUsage().heapUsed + let peakLiveHeap = initialHeap + globalThis.__replayMemoryProbe = (sequence) => { + if (sequence !== 1 && sequence % 256 !== 0) { + return + } + global.gc() + peakLiveHeap = Math.max(peakLiveHeap, process.memoryUsage().heapUsed) + } + loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + delete globalThis.__replayMemoryProbe + assert.equal(loaded.state.items.size, 1) + loaded = null + console.log( + JSON.stringify({ + arm, + ms, + databaseBytes: (await stat(implementations[arm].journalDatabaseFile(journalDir))).size, + peakLiveHeapDelta: peakLiveHeap - initialHeap + }) + ) + } +} finally { + delete globalThis.__replayMemoryProbe + await rm(fixture, { recursive: true, force: true }) +} diff --git a/config/scripts/legacy-alt-screen-scan-benchmark.mjs b/config/scripts/legacy-alt-screen-scan-benchmark.mjs new file mode 100644 index 00000000000..29c9621fbda --- /dev/null +++ b/config/scripts/legacy-alt-screen-scan-benchmark.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const modulePath = 'src/main/daemon/terminal-history-legacy-scrollback-restore.ts' +const baselineSource = readFileSync(0, 'utf8') +assert.ok( + baselineSource.includes('function truncateAltScreen'), + 'Pipe the baseline module on stdin' +) +const arms = {} +for (const [name, source] of [ + ['baseline', baselineSource], + ['indexed', readFileSync(modulePath, 'utf8')] +]) { + const result = await build({ + stdin: { + contents: `export { truncateAltScreen } from './${modulePath}'`, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + plugins: [ + { + name: 'private-export', + setup(api) { + api.onLoad({ filter: /terminal-history-legacy-scrollback-restore\.ts$/ }, () => ({ + contents: `${source}\nexport { truncateAltScreen }`, + loader: 'ts', + resolveDir: dirname(resolve(modulePath)) + })) + } + } + ] + }) + arms[name] = ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).truncateAltScreen +} + +const on = '\x1b[?1049h' +const off = '\x1b[?1049l' +let differentialCases = 0 +function verify(input) { + assert.equal(arms.indexed(input), arms.baseline(input)) + differentialCases++ +} +const tokens = [on, off, '\x1b[?1049', 'h', 'l', 'x'] +function enumerate(prefix, depth) { + verify(prefix) + if (depth === 0) { + return + } + for (const token of tokens) { + enumerate(prefix + token, depth - 1) + } +} +enumerate('', 6) + +let seed = 90211 +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return Math.floor((seed / 0x100000000) * max) +} +const fragments = [...tokens, '\r\n', '\x1b[?1047h', '\x1b[0m', 'é中😀', '\ud800', '\x00'] +for (let trial = 0; trial < 5000; trial++) { + verify(Array.from({ length: random(300) }, () => fragments[random(fragments.length)]).join('')) +} +console.log( + JSON.stringify({ + differentialCases, + node: process.version, + platform: process.platform, + arch: process.arch + }) +) + +const workloads = [ + ['empty', ''], + ['plain 4KiB', 'x'.repeat(4096)], + ['plain 16MiB', 'x'.repeat(16 * 1024 * 1024)], + ['8 balanced', `${'x'.repeat(256)}${on}TUI${off}`.repeat(8)], + ['1024 balanced', (on + 'x'.repeat(4096) + off + 'x'.repeat(4096)).repeat(1024)], + ['1024 off', ('x'.repeat(8192) + off).repeat(1024)], + ['1024 nested on', ('x'.repeat(8192) + on).repeat(1024)], + [ + '1024 nested closed', + ('x'.repeat(4096) + on).repeat(1024) + ('x'.repeat(4096) + off).repeat(1024) + ], + ['4096 off near 16MiB limit', ('x'.repeat(4088) + off).repeat(4096)] +] +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[3] + sorted[4]) / 2 +} +for (const [name, input] of workloads) { + const expected = arms.baseline(input) + assert.equal(arms.indexed(input), expected) + const samples = { baseline: [], indexed: [] } + const repeats = input.length < 8192 ? 10000 : 1 + for (const arm of Object.values(arms)) { + for (let warmup = 0; warmup < Math.min(100, repeats); warmup++) { + assert.equal(arm(input), expected) + } + } + for (const pair of buildCounterbalancedSchedule(8, 'baseline', 'indexed')) { + for (const name of pair) { + const start = performance.now() + let result + for (let repeat = 0; repeat < repeats; repeat++) { + result = arms[name](input) + } + samples[name].push((performance.now() - start) / repeats) + assert.equal(result, expected) + } + } + console.log( + JSON.stringify({ + name, + bytes: Buffer.byteLength(input), + medianMs: Object.fromEntries( + Object.entries(samples).map(([name, values]) => [name, median(values)]) + ) + }) + ) +} diff --git a/config/scripts/locale-brand-prefilter-benchmark.mjs b/config/scripts/locale-brand-prefilter-benchmark.mjs new file mode 100644 index 00000000000..c77e9f52ecd --- /dev/null +++ b/config/scripts/locale-brand-prefilter-benchmark.mjs @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' + +// Pipe the baseline policy on stdin. Catalog repair mutates only fresh in-memory copies. +const policyPath = path.resolve('config/scripts/locale-translation-policy.mjs') +const verifierPath = path.resolve('config/scripts/verify-localization-catalog.mjs') +const sources = [readFileSync(0, 'utf8'), readFileSync(policyPath, 'utf8')] +assert(sources.every((source) => source.includes('function includesPreservedLatinTerm('))) +const modules = await Promise.all( + sources.map(async (source) => { + const result = await build({ + entryPoints: [verifierPath], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'actual-locale-policy', + setup(builder) { + builder.onResolve({ filter: /^\.\// }, (args) => { + const resolved = path.resolve(args.resolveDir, args.path) + return resolved === policyPath + ? { path: resolved } + : { path: pathToFileURL(resolved).href, external: true } + }) + builder.onResolve({ filter: /^typescript-api$/ }, () => ({ + path: import.meta.resolve('typescript-api'), + external: true + })) + builder.onLoad({ filter: /locale-translation-policy\.mjs$/ }, () => ({ + contents: `${source}\nexport { includesPreservedLatinTerm };`, + resolveDir: path.dirname(policyPath) + })) + builder.onLoad({ filter: /verify-localization-catalog\.mjs$/ }, () => ({ + contents: `${readFileSync(verifierPath, 'utf8')}\nexport * from './locale-translation-policy.mjs';`, + resolveDir: path.dirname(verifierPath) + })) + } + } + ] + }) + const code = `${result.outputFiles[0].text}\n//# sourceURL=locale-brand-prefilter-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`) + }) +) + +const brands = [ + ...new Set(Object.values(modules[0].BRAND_MISTRANSLATIONS).flatMap(Object.keys)), + '', + '_', + 'a_b', + 'a.b', + '[term]', + '界', + '\ud800' +] +const boundaries = ['', ' ', 'X', '_', '2', '.', '-', '\n', '\0', 'é', '界', '😀', '\ud800'] +let comparisons = 0 +for (const term of brands) { + for (const prefix of boundaries) { + for (const suffix of boundaries) { + for (const value of [ + `${prefix}${term}${suffix}`, + `X${term}X ${prefix}${term}${suffix}`, + `${prefix}${term.toLowerCase()}${suffix}`, + `${prefix}${suffix}` + ]) { + assert.equal( + modules[1].includesPreservedLatinTerm(value, term), + modules[0].includesPreservedLatinTerm(value, term) + ) + comparisons += 1 + } + } + } +} +console.log(`${comparisons} literal/boundary differential cases match`) + +let repairCases = 0 +for (const [locale, translations] of Object.entries(modules[0].BRAND_MISTRANSLATIONS)) { + for (const [brand, wrongForms] of Object.entries(translations)) { + for (const wrong of wrongForms) { + for (const prefix of boundaries) { + for (const key of [ + 'fixture.brand', + 'fixture.search.brand', + 'auto.lib.agent.catalog.test' + ]) { + const input = { + key, + enValue: `${prefix}${brand}${prefix} fixture {{agent}}`, + localeValue: `${wrong} ${prefix}${brand}${prefix} ${wrong} {{agent}}`, + locale + } + assert.equal( + modules[1].repairTranslatedValue(input), + modules[0].repairTranslatedValue(input) + ) + repairCases += 1 + } + } + } + } +} +console.log(`${repairCases} full policy repair cases match`) + +function measured(run) { + const start = performance.now() + const value = run() + return { elapsed: performance.now() - start, value } +} + +function benchmark(name, prepare) { + const expected = prepare(modules[0])() + assert.deepEqual(prepare(modules[1])(), expected) + for (const module of modules) { + const until = performance.now() + 150 + do { + assert.deepEqual(prepare(module)(), expected) + } while (performance.now() < until) + } + /** @type {number[][]} */ + const times = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + const result = measured(prepare(modules[index])) + times[index].push(result.elapsed) + assert.deepEqual(result.value, expected) + } + } + const median = times.map((values) => { + const sorted = values.toSorted((a, b) => a - b) + return (sorted[3] + sorted[4]) / 2 + }) + console.log(JSON.stringify({ name, median, times })) +} + +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + unit: 'ms' + }) +) +const localesDir = path.resolve('src/renderer/src/i18n/locales') +const en = JSON.parse(readFileSync(path.join(localesDir, 'en.json'), 'utf8')) +const enEntries = new Map(modules[0].collectStringLeaves(en).map(({ key, value }) => [key, value])) +for (const locale of ['zh', 'ja', 'ko', 'es', 'fr']) { + const catalog = JSON.parse(readFileSync(path.join(localesDir, `${locale}.json`), 'utf8')) + const localeEntries = new Map( + modules[0].collectStringLeaves(catalog).map(({ key, value }) => [key, value]) + ) + const inputs = [...enEntries].flatMap(([key, enValue]) => { + const localeValue = localeEntries.get(key) + return typeof localeValue === 'string' ? [{ key, enValue, localeValue, locale }] : [] + }) + assert.deepEqual( + inputs.map(modules[1].repairTranslatedValue), + inputs.map(modules[0].repairTranslatedValue) + ) + const expressionCounts = modules.map((module) => { + const original = globalThis.RegExp + let count = 0 + globalThis.RegExp = new Proxy(original, { + construct(target, args) { + if (typeof args[0] === 'string' && args[0].startsWith('(^|[^A-Za-z_])')) { + count += 1 + } + return Reflect.construct(target, args) + } + }) + try { + inputs.forEach(module.repairTranslatedValue) + } finally { + globalThis.RegExp = original + } + return count + }) + console.log(JSON.stringify({ locale, leaves: inputs.length, expressionCounts })) + benchmark(`${locale}: repairCatalog`, (module) => { + const copy = structuredClone(catalog) + return () => ({ count: module.repairCatalog(en, copy, locale), catalog: copy }) + }) + benchmark(`${locale}: collectGenericTermRegressions`, (module) => { + return () => module.collectGenericTermRegressions(enEntries, localeEntries, locale) + }) +} + +for (const [name, enValue, localeValue] of [ + ['absent brands', 'Choose an endpoint.', 'Elegir un destino.'], + ['matching brand', 'Use Gemini.', 'Usar Géminis.'], + ['embedded only', 'Use _Gemini_.', 'Usar Géminis.'], + ['all brands', brands.join(' '), brands.join(' ')] +]) { + const input = { key: 'fixture.brand', enValue, localeValue, locale: 'es' } + benchmark(`10k strings: ${name}`, (module) => () => { + let result + for (let i = 0; i < 10000; i++) { + result = module.repairTranslatedValue(input) + } + return result + }) +} + +const cache = new Map([ + ['Use Gemini.', 'Usar Géminis.'], + ['Choose an endpoint.', 'Elegir un destino.'], + ['Use _Gemini_.', 'Usar Géminis.'], + ['Use GitHub Copilot.', 'Usar Copiloto de GitHub.'] +]) +const caches = modules.map((module) => { + const copy = new Map(cache) + return { count: module.repairCacheMap(copy, 'es'), entries: [...copy] } +}) +assert.deepEqual(caches[1], caches[0]) +console.log('Actual catalog outputs, regression reports, repair counts and cache mutation match') diff --git a/config/scripts/locale-brand-prefilter.test.mjs b/config/scripts/locale-brand-prefilter.test.mjs new file mode 100644 index 00000000000..0ea14ffea3b --- /dev/null +++ b/config/scripts/locale-brand-prefilter.test.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { repairTranslatedValue } from './locale-translation-policy.mjs' + +describe('locale brand matching', () => { + it('does not construct boundary expressions for absent brands', () => { + let boundaryExpressions = 0 + vi.stubGlobal( + 'RegExp', + new Proxy(RegExp, { + construct(target, args) { + if (typeof args[0] === 'string' && args[0].startsWith('(^|[^A-Za-z_])')) { + boundaryExpressions += 1 + } + return Reflect.construct(target, args) + } + }) + ) + try { + for (const locale of ['zh', 'ja', 'ko', 'es']) { + expect( + repairTranslatedValue({ + key: 'fixture.endpoint', + enValue: 'Choose an endpoint.', + localeValue: 'fixture translation', + locale + }) + ).toBe('fixture translation') + } + } finally { + vi.unstubAllGlobals() + } + expect(boundaryExpressions).toBe(0) + }) + + it.each([ + ['Use Gemini.', 'Usar Géminis.', 'Usar Gemini.'], + ['Use GeminiX.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use XGemini.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use _Gemini_.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use Gemini_2.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use 2Gemini3.', 'Usar Géminis.', 'Usar Gemini.'], + ['Use (Gemini).', 'Usar Géminis.', 'Usar Gemini.'], + ['Use éGemini界.', 'Usar Géminis.', 'Usar Gemini.'], + ['Use gemini.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use GeminiX and Gemini.', 'Usar Géminis.', 'Usar Gemini.'], + ['Use Gemini.', 'Gemini y Géminis.', 'Gemini y Géminis.'], + ['Use Gemini.', '_Gemini_ y Géminis.', '_Gemini_ y Gemini.'], + ['Use GitHub Copilot.', 'Usar Copiloto de GitHub.', 'Usar GitHub Copilot.'], + ['Use XGitHub CopilotY.', 'Usar Copiloto de GitHub.', 'Usar GitHub Copilot.'] + ])('preserves literal and boundary matching for %j / %j', (enValue, localeValue, expected) => { + expect( + repairTranslatedValue({ key: 'fixture.brand', enValue, localeValue, locale: 'es' }) + ).toBe(expected) + }) +}) diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index cec2ebf63ad..af88805b8c4 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -281,8 +281,11 @@ function escapeRegExp(value) { } function includesPreservedLatinTerm(value, term) { + if (!value.includes(term)) { + return false + } if (!/^[A-Za-z_]+$/.test(term)) { - return value.includes(term) + return true } return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value) } diff --git a/config/scripts/markdown-blank-run-scan-benchmark.mjs b/config/scripts/markdown-blank-run-scan-benchmark.mjs new file mode 100644 index 00000000000..2caaef90851 --- /dev/null +++ b/config/scripts/markdown-blank-run-scan-benchmark.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// Adverse-input audit: compares the previous scanner with the production line-bounded implementation. +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +const entry = fileURLToPath( + new URL( + '../../src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts', + import.meta.url + ) +) +const source = await readFile(entry, 'utf8') +const current = `${String.raw`/\s*(?:`}\`\`\`|~~~)/y` +const replacement = `${String.raw`/[^\S\n]*(?:`}\`\`\`|~~~)/y` +assert.ok( + source.includes(replacement), + 'Production fence regex changed; re-review benchmark candidate' +) +async function load(candidate) { + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: candidate + ? [ + { + name: 'line-bounded-candidate', + setup(plugin) { + plugin.onLoad({ filter: /monaco-markdown-doc-link-decorations\.ts$/ }, () => ({ + contents: source.replace(replacement, current), + loader: 'ts', + resolveDir: fileURLToPath( + new URL('../../src/renderer/src/components/editor/', import.meta.url) + ) + })) + } + } + ] + : [] + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).getMarkdownDocLinkDecorationRanges +} +const baseline = await load(true) +const candidate = await load(false) +const corpus = [ + '', + '\n```\n[[hidden.md]]\n```\n[[shown.md]]', + ' \t\r\n~~~\r\n[[hidden.md]]\r\n~~~\r\n[[shown.md]]', + '\u00a0\u2028```\n[[hidden.md]]\n```\n[[shown.md]]', + '`code` [[shown.md]]' +] +for (const content of corpus) { + assert.deepEqual(candidate(content), baseline(content)) +} +const scenarios = [ + ['ordinary-100k-lines', 'ordinary prose\n'.repeat(100_000)], + ['blank-10k-lines', '\n'.repeat(10_000)], + ['blank-30k-lines', '\n'.repeat(30_000)], + ['blank-100k-lines', '\n'.repeat(100_000)], + ['indented-blank-10k-lines', `${' '.repeat(80)}\n`.repeat(10_000)] +] +for (const [name, content] of scenarios) { + const samples = { baseline: [], candidate: [] } + const scanners = { baseline, candidate } + let expected + for (const arms of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) { + for (const arm of arms) { + const started = performance.now() + const ranges = scanners[arm](content) + samples[arm].push(performance.now() - started) + expected ??= ranges + assert.deepEqual(ranges, expected) + } + } + console.log( + JSON.stringify({ + name, + bytes: Buffer.byteLength(content), + samples, + baseline: summarizeBenchmarkSamples(samples.baseline), + candidate: summarizeBenchmarkSamples(samples.candidate) + }) + ) +} diff --git a/config/scripts/markdown-note-line-scan-benchmark.mjs b/config/scripts/markdown-note-line-scan-benchmark.mjs new file mode 100644 index 00000000000..2b6565278d9 --- /dev/null +++ b/config/scripts/markdown-note-line-scan-benchmark.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const sourcePath = 'src/renderer/src/lib/markdown-review-notes.ts' +const baseline = process.argv[2] ?? '20ab9950654' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(sourcePath)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const before = await load( + execFileSync('git', ['show', `${baseline}:${sourcePath}`], { encoding: 'utf8' }) +) +const after = await load(readFileSync(sourcePath, 'utf8')) +const results = [] +for (const [name, lineCount, width, count, iterations] of [ + ['small', 20, 40, 1, 1000], + ['long-lines', 1000, 1000, 20, 3], + ['many-lines', 20000, 80, 20, 3], + ['early-note', 20000, 80, 1, 1000] +]) { + const content = Array.from({ length: lineCount }, (_, i) => `${i}: ${'x'.repeat(width)}`).join( + '\r\n' + ) + const notes = Array.from({ length: count }, (_, i) => ({ + id: `${i}`, + worktreeId: 'bench', + filePath: 'README.md', + source: 'markdown', + lineNumber: name === 'early-note' ? 2 : lineCount - i, + body: 'Clarify this line', + createdAt: i, + side: 'modified' + })) + assert.equal( + after.formatMarkdownReviewNotes(notes, content), + before.formatMarkdownReviewNotes(notes, content) + ) + const arms = { before, after } + const samples = { before: [], after: [] } + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm].formatMarkdownReviewNotes(notes, content) + } + return (performance.now() - start) / iterations + } + for (let i = 0; i < 6; i++) { + run('before') + run('after') + } + for (const pair of buildCounterbalancedSchedule(12, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (xs) => xs.sort((a, b) => a - b)[Math.floor(xs.length / 2)] + results.push({ + name, + lineCount, + width, + count, + beforeMs: median(samples.before), + afterMs: median(samples.after) + }) +} +console.log( + JSON.stringify({ node: process.version, platform: process.platform, baseline, results }, null, 2) +) diff --git a/config/scripts/mobile-backspace-benchmark.mjs b/config/scripts/mobile-backspace-benchmark.mjs new file mode 100644 index 00000000000..f1839089776 --- /dev/null +++ b/config/scripts/mobile-backspace-benchmark.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +// git show :mobile/src/terminal/terminal-live-text-commit.ts | node config/scripts/mobile-backspace-benchmark.mjs +const target = resolve('mobile/src/terminal/terminal-live-text-commit.ts') +async function load(source) { + const result = await build({ + stdin: { contents: source, loader: 'ts', resolveDir: dirname(target) }, + bundle: true, + write: false, + platform: 'node', + format: 'esm' + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).getTerminalLiveAccessoryLocalEditText +} +const baseline = readFileSync(0, 'utf8') +assert.ok( + baseline.includes('function getTerminalLiveAccessoryLocalEditText'), + 'Pipe baseline source into stdin' +) +const implementations = { + before: await load(baseline), + after: await load(readFileSync(target, 'utf8')) +} +const tokens = [ + '', + 'a', + '\u0000', + '\r', + '\n', + '한', + '\u0301', + '\u200d', + '🙂', + '\ud800', + '\udbff', + '\udc00', + '\udfff' +] +let cases = 0 +for (const first of tokens) { + for (const second of tokens) { + for (const third of tokens) { + for (const localEdit of ['backspace', 'delete']) { + const input = { fieldText: first + second + third, localEdit } + assert.equal( + implementations.after(input), + implementations.before(input), + JSON.stringify(input) + ) + cases += 1 + } + } + } +} +const results = [] +for (const inputBytes of [32, 4096, 65_536, 262_144]) { + for (const glyph of ['a', '🙂']) { + const fieldText = glyph.repeat(inputBytes / Buffer.byteLength(glyph)) + const input = { localEdit: 'backspace', fieldText } + const expected = implementations.before(input) + assert.equal(implementations.after(input), expected) + const iterations = Math.max(10, Math.floor(1_000_000 / inputBytes)) + for (let warmup = 0; warmup < 100; warmup += 1) { + implementations.before(input) + implementations.after(input) + } + /** @type {{ before: number[], after: number[] }} */ + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + let actual + const started = performance.now() + for (let repeat = 0; repeat < iterations; repeat += 1) { + actual = implementations[arm](input) + } + samples[arm].push(performance.now() - started) + assert.equal(actual, expected) + } + } + const means = Object.fromEntries( + Object.entries(samples).map(([arm, values]) => [ + arm, + (values.reduce((sum, ms) => sum + ms, 0) * 1000) / values.length / iterations + ]) + ) + results.push({ + inputBytes, + glyph, + iterations, + meanMicrosecondsPerCall: means, + before: summarizeBenchmarkSamples(samples.before), + after: summarizeBenchmarkSamples(samples.after) + }) + } +} +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases: cases, results }, + null, + 2 + ) +) diff --git a/config/scripts/mobile-diagnostics-prefix-benchmark.mjs b/config/scripts/mobile-diagnostics-prefix-benchmark.mjs new file mode 100644 index 00000000000..c7beec3eba1 --- /dev/null +++ b/config/scripts/mobile-diagnostics-prefix-benchmark.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pipe baseline report then submission modules on stdin, in that order. No device/network I/O. +const baseline = readFileSync(0, 'utf8') +const divider = '\nconst CONNECTION_DIAGNOSTICS_ENDPOINT = ' +assert.equal(baseline.split(divider).length, 2) +const split = baseline.indexOf(divider) + 1 +const files = ['report', 'submission'].map((name) => + path.resolve(`mobile/src/diagnostics/connection-diagnostics-${name}.ts`) +) +const sources = [ + [baseline.slice(0, split), baseline.slice(split)], + files.map((file) => readFileSync(file, 'utf8')) +] +const modules = await Promise.all( + sources.map(async (contents) => { + const result = await build({ + stdin: { + contents: files.map((file) => `export * from ${JSON.stringify(file)};`).join('\n'), + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'actual-mobile-diagnostics', + setup(builder) { + builder.onLoad( + { filter: /connection-diagnostics-(report|submission)\.ts$/ }, + (args) => ({ + contents: contents[files.indexOf(args.path)], + loader: 'ts', + resolveDir: path.dirname(args.path) + }) + ) + builder.onResolve({ filter: /^@react-native-async-storage\/async-storage$/ }, () => ({ + path: 'forbidden-device-storage', + namespace: 'fixture' + })) + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ + contents: `function forbidden() { throw new Error('Device storage is forbidden'); } + export default { getItem: forbidden, setItem: forbidden };` + })) + } + } + ] + }) + const code = `${result.outputFiles[0].text}\n//# sourceURL=mobile-diagnostics-prefix-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`) + }) +) + +const base = { + hostName: 'fixture', + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + reconnectAttempts: 2, + lastConnectedAt: null, + platform: 'android', + appVersion: 'fixture', + nowMs: 1700000000000 +} +let seed = 0x20d1a6 +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return (seed >>> 8) % max +} +const tokens = ['a', 'é', '界', '😀', '\ud800', '\udc00', '\n', '\r\n', '\0', 'e\u0301'] +const limits = [ + Number.NEGATIVE_INFINITY, + -1, + 0, + 1, + 2, + 3, + 4, + 15, + 16, + 17, + 100, + 511, + 2048, + 65536, + Number.POSITIVE_INFINITY, + Number.NaN, + 2.5 +] +for (let trace = 0; trace < 3000; trace++) { + const lines = Array.from({ length: 1 + random(12) }, () => + Array.from({ length: 1 + random(5) }, () => + tokens[random(tokens.length)].repeat(random(100)) + ).join('') + ) + if (trace % 2) { + lines.splice(random(lines.length), 0, 'Recent connection history (fixture):') + } + const report = lines.join('\n') + const limit = limits[random(limits.length)] + assert.equal( + modules[1].boundConnectionDiagnosticsReport(report, limit), + modules[0].boundConnectionDiagnosticsReport(report, limit) + ) +} +console.log('3,000 report-bound differentials match, including nonfinite/fractional limits') + +for (let trace = 0; trace < 600; trace++) { + const entries = Object.freeze( + Array.from({ length: random(12) }, (_, index) => + Object.freeze({ + id: String(index), + ts: base.nowMs + index, + level: ['info', 'error', 'warn'][random(3)], + message: ['Authenticated', 'relay director resolve failed (503)', 'fixture'][random(3)], + detail: `${tokens[random(tokens.length)].repeat(random(4000))} token=fixture-secret`, + code: ['client-session-started', 'liveness-timeout', undefined][random(3)], + path: ['relay', 'lan', 'tailscale'][random(3)] + }) + ) + ) + const args = Object.freeze({ + ...base, + hostName: 'fixture token=host-fixture-secret', + endpoint: trace % 2 ? base.endpoint : 'invalid?token=endpoint-fixture-secret', + desktopAppVersion: trace % 2 ? '1.2.3' : '\ninvalid', + state: ['connected', 'reconnecting', 'connecting'][random(3)], + activePath: ['relay', 'lan', 'tailscale'][random(3)], + pendingPath: trace % 3 ? null : 'relay', + entries + }) + const reports = modules.map((module) => module.buildConnectionDiagnosticsReport(args)) + assert.equal(reports[1], reports[0]) + assert(!reports[1].includes('fixture-secret')) + const limit = limits[random(limits.length)] + assert.equal( + modules[1].boundConnectionDiagnosticsReport(reports[1], limit), + modules[0].boundConnectionDiagnosticsReport(reports[0], limit) + ) +} +console.log('600 frozen report-build + bound journeys preserve redaction, diagnosis and exact text') + +function runSample(run, repeats) { + let value + const start = performance.now() + for (let index = 0; index < repeats; index++) { + value = run() + } + return { value, elapsed: (performance.now() - start) / repeats } +} +function benchmark(name, arms) { + const expected = arms[0]() + assert.equal(arms[1](), expected) + for (const arm of arms) { + const until = performance.now() + 200 + do { + assert.equal(arm(), expected) + } while (performance.now() < until) + } + const repeats = Math.max(3, Math.min(10000, Math.ceil(40 / runSample(arms[0], 1).elapsed))) + /** @type {number[][]} */ + const times = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + const result = runSample(arms[index], repeats) + assert.equal(result.value, expected) + times[index].push(result.elapsed) + } + } + const median = times.map((values) => { + const sorted = values.toSorted((a, b) => a - b) + return (sorted[3] + sorted[4]) / 2 + }) + console.log(JSON.stringify({ name, repeats, median, times })) +} +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + unit: 'ms' + }) +) +for (const [events, length, token] of [ + [0, 0, 'a'], + [20, 80, 'a'], + [200, 80, 'a'], + [200, 1000, 'a'], + [200, 4000, 'a'], + [200, 2000, '😀'] +]) { + const args = { + ...base, + entries: Array.from({ length: events }, (_, i) => ({ + id: String(i), + ts: base.nowMs + i, + level: 'error', + message: `fixture-${i} ${token.repeat(length)}` + })) + } + const reports = modules.map((module) => module.buildConnectionDiagnosticsReport(args)) + assert.equal(reports[1], reports[0]) + const label = `${events} events / ${length} ${token}` + benchmark( + `${label}: build`, + modules.map((module) => () => module.buildConnectionDiagnosticsReport(args)) + ) + benchmark( + `${label}: bound`, + modules.map((module) => () => module.boundConnectionDiagnosticsReport(reports[0])) + ) +} + +for (const token of ['a', '😀', '\ud800']) { + const report = token.repeat(100000) + const results = await Promise.all( + modules.map(async (module) => { + let request + const result = await module.submitConnectionDiagnostics( + { report, platform: 'android', appVersion: 'fixture' }, + async (url, options) => { + assert.equal(options.signal.aborted, false) + request = { url, method: options.method, headers: options.headers, body: options.body } + return { ok: true } + } + ) + return { result, request } + }) + ) + assert.deepEqual(results[1], results[0]) +} +console.log('Three fake-fetch submission journeys preserve complete request bytes and results') diff --git a/config/scripts/mobile-file-ranking-benchmark.mjs b/config/scripts/mobile-file-ranking-benchmark.mjs index 68ac5b9d977..27bf99f8ecd 100644 --- a/config/scripts/mobile-file-ranking-benchmark.mjs +++ b/config/scripts/mobile-file-ranking-benchmark.mjs @@ -1,53 +1,141 @@ import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' -import { stripTypeScriptTypes } from 'node:module' import { performance } from 'node:perf_hooks' +import { transform } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' const baseline = process.argv[2] if (!baseline) { - throw new Error('Usage: node config/scripts/mobile-file-ranking-benchmark.mjs ') + throw new Error( + 'Usage: node config/scripts/mobile-file-ranking-benchmark.mjs ' + ) } +// git show :mobile/src/session/mobile-native-chat-autocomplete.ts | node config/scripts/mobile-file-ranking-benchmark.mjs --autocomplete-stdin +const autocompleteSource = baseline === '--autocomplete-stdin' ? readFileSync(0, 'utf8') : null async function load(source) { - const js = stripTypeScriptTypes(source, { mode: 'transform' }) - return await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`) -} -function measure(fn, paths, query) { - for (let warmup = 0; warmup < 10; warmup++) { - fn(paths, query, 16) - } - const samples = [] - for (let i = 0; i < 9; i++) { - const start = performance.now() - fn(paths, query, 16) - samples.push(performance.now() - start) - } - return samples.sort((a, b) => a - b)[4] + const { code } = await transform(source, { loader: 'ts', format: 'esm' }) + return await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`) } const results = [] +let differentialCases = 0 for (const [file, name] of [ ['src/main/runtime/runtime-mobile-file-path-search.ts', 'rankRuntimeMobileFilePaths'], - ['mobile/src/session/mobile-native-chat-autocomplete.ts', 'rankSuggestions'] + ['mobile/src/session/mobile-native-chat-autocomplete.ts', 'rankSuggestions'], + ['mobile/src/session/mobile-native-chat-autocomplete.ts', 'rankSlashCommandSuggestions'] ]) { + if (autocompleteSource !== null && name === 'rankRuntimeMobileFilePaths') { + continue + } const before = ( - await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })) + await load( + autocompleteSource ?? + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) + ) )[name] const after = (await load(readFileSync(file, 'utf8')))[name] - for (const count of [100, 100000]) { - const paths = Array.from( - { length: count }, - (_, i) => `src/components/workspace/group-${i % 100}/file-${i}.tsx` + const slash = name === 'rankSlashCommandSuggestions' + const toCandidates = (names) => + slash ? names.map((name, index) => ({ name, description: `Command ${index}` })) : names + if (name !== 'rankRuntimeMobileFilePaths') { + let seed = 42 + const random = (max) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max + } + const tokens = ['', 'app', 'src/', 'APP', 'zapp', '🙂', '한', '\ud800', '\u0130', ' '] + const limits = [ + undefined, + 0, + -0, + -1, + -0.5, + -Infinity, + Number.NaN, + 0.5, + 1.5, + 2.5, + 8, + 16, + Infinity + ] + for (let index = 0; index < 3000; index += 1) { + const candidates = toCandidates( + Array.from( + { length: random(100) }, + () => tokens[random(tokens.length)] + tokens[random(tokens.length)] + ) + ) + const query = tokens[random(tokens.length)] + const limit = limits[random(limits.length)] + assert.deepEqual(after(candidates, query, limit), before(candidates, query, limit)) + differentialCases += 1 + } + } + for (const count of slash ? [16, 100, 1000] : [16, 100, 10_000, 50_000, 100_000]) { + const names = Array.from({ length: count }, (_, index) => + slash + ? `team-review-${index}` + : `src/components/workspace/group-${index % 100}/file-${index}.tsx` ) - for (const query of ['file-9', 'missing', 'workspace']) { - assert.deepEqual(after(paths, query, 16), before(paths, query, 16)) + const limit = slash ? 12 : 16 + const substringQuery = slash ? 'review' : 'workspace' + const workloads = [ + { name: 'empty-query', names, query: '' }, + { name: 'substring', names, query: substringQuery }, + { name: 'no-match', names, query: 'missing' }, + { name: 'early-prefix', names, query: slash ? 'team' : 'file' }, + { + name: 'late-prefix', + names: [...names, ...Array.from({ length: 4 }, (_, index) => `${substringQuery}-${index}`)], + query: substringQuery + } + ] + for (const workload of workloads) { + const candidates = toCandidates(workload.names) + const expected = before(candidates, workload.query, limit) + assert.deepEqual(after(candidates, workload.query, limit), expected) + const implementations = { before, after } + const iterations = Math.max(10, Math.floor(100_000 / count)) + for (let warmup = 0; warmup < 100; warmup += 1) { + before(candidates, workload.query, limit) + after(candidates, workload.query, limit) + } + /** @type {{ before: number[], after: number[] }} */ + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + let actual + const start = performance.now() + for (let repeat = 0; repeat < iterations; repeat += 1) { + actual = implementations[arm](candidates, workload.query, limit) + } + samples[arm].push(performance.now() - start) + assert.deepEqual(actual, expected) + } + } results.push({ function: name, - paths: count, - query, - beforeMs: measure(before, paths, query), - afterMs: measure(after, paths, query) + candidates: candidates.length, + workload: workload.name, + iterations, + meanMicrosecondsPerCall: Object.fromEntries( + Object.entries(samples).map(([arm, values]) => [ + arm, + (values.reduce((sum, ms) => sum + ms, 0) * 1000) / values.length / iterations + ]) + ), + before: summarizeBenchmarkSamples(samples.before), + after: summarizeBenchmarkSamples(samples.after) }) } } } -console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases, results }, + null, + 2 + ) +) diff --git a/config/scripts/mobile-history-scope-paths-benchmark.mjs b/config/scripts/mobile-history-scope-paths-benchmark.mjs new file mode 100644 index 00000000000..dda318223f2 --- /dev/null +++ b/config/scripts/mobile-history-scope-paths-benchmark.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error( + 'Usage: node config/scripts/mobile-history-scope-paths-benchmark.mjs ' + ) +} +const file = 'mobile/src/agent-history/agent-history-scope-paths.ts' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {} + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).deriveMobileAiVaultScopePaths +} +const arms = { + before: await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })), + after: await load(readFileSync(file, 'utf8')) +} +const iterations = 200 +const results = [] +for (const [count, unique] of [ + [1, 1], + [16, 16], + [64, 64], + [1000, 32] +]) { + for (const root of ['/home/ada/café/project', 'C:\\Users\\ada\\café\\project']) { + const rows = Array.from({ length: count }, (_, index) => ({ + worktreeId: `w-${index}`, + repoId: 'repo', + path: `${root}/workspace-${index % unique}` + })) + const expected = arms.before('project', rows[0], rows) + assert.deepEqual(arms.after('project', rows[0], rows), expected) + const samples = { before: [], after: [] } + function run(arm) { + let length = 0 + const start = performance.now() + for (let i = 0; i < iterations; i++) { + length += arms[arm]('project', rows[0], rows).length + } + const elapsed = performance.now() - start + assert.equal(length, iterations * expected.length) + return elapsed / iterations + } + for (const arm of ['before', 'after']) { + run(arm) + } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + count, + unique, + root, + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) + } +} +console.log( + JSON.stringify( + { baseline, node: process.version, platform: process.platform, iterations, results }, + null, + 2 + ) +) diff --git a/config/scripts/mobile-linear-group-sorted-benchmark.mjs b/config/scripts/mobile-linear-group-sorted-benchmark.mjs new file mode 100644 index 00000000000..529515a99ae --- /dev/null +++ b/config/scripts/mobile-linear-group-sorted-benchmark.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error( + 'Usage: node config/scripts/mobile-linear-group-sorted-benchmark.mjs ' + ) +} +async function load(file, contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + plugins: [ + { + name: 'theme-only', + setup(bundler) { + bundler.onResolve({ filter: /mobile-tasks-dependencies$/ }, () => ({ + path: resolve('mobile/src/theme/mobile-theme.ts') + })) + } + } + ] + }) + return await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const file = 'mobile/src/tasks/mobile-tasks-reviewer-linear.ts' +const original = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8', windowsHide: true }) +) +const current = await load(file, readFileSync(file, 'utf8')) +const sorterRef = process.argv[3] +const sorter = sorterRef + ? await load( + file, + execFileSync('git', ['show', `${sorterRef}:${file}`], { encoding: 'utf8', windowsHide: true }) + ) + : original +const results = [] +for (const count of [25, 200, 1000]) { + const items = Array.from({ length: count }, (_, i) => ({ + id: `item-${i}`, + identifier: `ENG-${(i * 37) % count}`, + updatedAt: new Date(1700000000000 - i * 100000).toISOString(), + priority: i % 5, + state: { name: `state-${i % 4}`, color: 'red' }, + team: { id: `team-${i % 3}`, name: 'Team' }, + assignee: null + })) + for (const order of ['identifier', 'updated', 'priority']) { + const run = (arm) => { + const sorted = sorter.sortLinearIssues + ? sorter.sortLinearIssues(items, order) + : [...items].sort((a, b) => sorter.compareLinearIssues(a, b, order)) + return arm === 'before' + ? [ + sorter.groupLinearIssues(sorted, 'none', order), + sorter.groupLinearIssues(sorted, 'status', order) + ] + : [ + current.groupSortedLinearIssues(sorted, 'none'), + current.groupSortedLinearIssues(sorted, 'status') + ] + } + assert.deepEqual(run('after'), run('before')) + for (let i = 0; i < 10; i++) { + run('before') + run('after') + } + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + global.gc?.() + const start = performance.now() + for (let i = 0; i < 10; i++) { + run(arm) + } + samples[arm].push((performance.now() - start) / 10) + } + } + results.push({ count, order, samples }) + } +} +console.log(JSON.stringify({ baseline, sorterRef, node: process.version, results }, null, 2)) diff --git a/config/scripts/mobile-linear-sort-benchmark.mjs b/config/scripts/mobile-linear-sort-benchmark.mjs new file mode 100644 index 00000000000..1a2a2d29ad6 --- /dev/null +++ b/config/scripts/mobile-linear-sort-benchmark.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error('Usage: node config/scripts/mobile-linear-sort-benchmark.mjs ') +} +async function load(file, contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + plugins: [ + { + name: 'theme-only', + setup(bundler) { + bundler.onResolve({ filter: /mobile-tasks-dependencies$/ }, () => ({ + path: resolve('mobile/src/theme/mobile-theme.ts') + })) + } + } + ] + }) + return await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const file = 'mobile/src/tasks/mobile-tasks-reviewer-linear.ts' +const before = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) +) +const after = await load(file, readFileSync(file, 'utf8')) +const results = [] +for (const count of [0, 1, 25, 200, 1000]) { + const items = Array.from({ length: count }, (_, index) => ({ + id: `item-${index}`, + identifier: `ENG-${(index * 37) % Math.max(1, count)}`, + updatedAt: new Date(1700000000000 - index * 100000).toISOString(), + priority: index % 5 + })) + for (const sort of ['updated', 'identifier', 'priority']) { + const arms = { + before: () => [...items].sort((a, b) => before.compareLinearIssues(a, b, sort)), + after: () => after.sortLinearIssues(items, sort) + } + assert.deepEqual(arms.after(), arms.before()) + const iterations = count < 100 ? 100 : 10 + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm]() + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + const dateParses = {} + const nativeParse = Date.parse + for (const arm of ['before', 'after']) { + let calls = 0 + Date.parse = (value) => { + calls++ + return nativeParse(value) + } + try { + arms[arm]() + } finally { + Date.parse = nativeParse + } + dateParses[arm] = calls + } + results.push({ + count, + sort, + dateParses, + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) + } +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/mobile-log-revisions-benchmark.mjs b/config/scripts/mobile-log-revisions-benchmark.mjs new file mode 100644 index 00000000000..aa42094fee6 --- /dev/null +++ b/config/scripts/mobile-log-revisions-benchmark.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] ?? '20ab9950654' +const file = 'mobile/src/transport/connection-log-buffer.ts' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + tsconfigRaw: {} + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const before = await load( + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) +) +const after = await load(readFileSync(file, 'utf8')) +const drain = () => new Promise((resolve) => setImmediate(resolve)) +async function run(module, count, startup) { + let calls = 0 + let bytes = 0 + let stored = '' + const store = module.createConnectionLogStore(200, { + load: async () => [], + save: async (_host, snapshot) => { + stored = JSON.stringify(snapshot) + calls++ + bytes += Buffer.byteLength(stored) + } + }) + if (!startup) { + await store.hydrate('a') + await drain() + calls = 0 + bytes = 0 + } + const start = performance.now() + for (let i = 0; i < count; i++) { + store.append('a', { id: `${i}`, ts: i, level: 'info', message: `connection event ${i}` }) + } + await drain() + return { ms: performance.now() - start, calls, bytes, stored } +} +const results = [] +for (const count of [1, 25, 200, 1000]) { + for (const startup of [false, true]) { + const arms = { before, after } + const initialBefore = await run(before, count, startup) + const initialAfter = await run(after, count, startup) + assert.equal(initialAfter.stored, initialBefore.stored) + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push((await run(arms[arm], count, startup)).ms) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + count, + startup, + before: { + calls: initialBefore.calls, + bytes: initialBefore.bytes, + ms: median(samples.before) + }, + after: { calls: initialAfter.calls, bytes: initialAfter.bytes, ms: median(samples.after) } + }) + } +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/mobile-source-control-collation-benchmark.mjs b/config/scripts/mobile-source-control-collation-benchmark.mjs new file mode 100644 index 00000000000..e48d7dbfb0f --- /dev/null +++ b/config/scripts/mobile-source-control-collation-benchmark.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error( + 'Usage: node config/scripts/mobile-source-control-collation-benchmark.mjs ' + ) +} +async function load(file, contents, name) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {} + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + )[name] +} +// Match git's path order, including its numeric-looking names, instead of inflating sort work with a shuffle. +const paths = execFileSync('git', ['ls-files', '-z'], { maxBuffer: 16 * 1024 * 1024 }) + .toString() + .split('\0') + .filter(Boolean) +const results = [] +for (const [file, name] of [ + ['mobile/src/source-control/mobile-git-status.ts', 'buildMobileSourceControlSections'], + ['mobile/src/source-control/mobile-branch-compare.ts', 'buildMobileBranchCompareSection'], + ['mobile/src/session/mobile-diff-review-queue.ts', 'buildMobileDiffReviewQueue'] +]) { + const arms = { + before: await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }), + name + ), + after: await load(file, readFileSync(file, 'utf8'), name) + } + for (const count of [0, 1, 17, 63, 1000]) { + const step = Math.max(1, Math.floor(paths.length / Math.max(1, count))) + const entries = Array.from({ length: count }, (_, index) => ({ + path: paths[index * step], + area: 'unstaged', + status: 'modified', + ...(index % 37 === 0 ? { conflictStatus: 'unresolved' } : {}) + })) + const input = + name === 'buildMobileDiffReviewQueue' + ? { + worktreeId: 'workspace', + statusEntries: entries, + branchEntries: [], + comments: [], + reviewState: { version: 1, files: {} } + } + : entries + assert.deepEqual(arms.after(input), arms.before(input)) + const iterations = count < 100 ? 100 : 10 + function run(arm) { + const start = performance.now() + for (let index = 0; index < iterations; index++) { + arms[arm](input) + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + name, + count, + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) + } +} +console.log( + JSON.stringify( + { + baseline, + node: process.version, + platform: process.platform, + locale: new Intl.Collator().resolvedOptions().locale, + results + }, + null, + 2 + ) +) diff --git a/config/scripts/mobile-task-sort-benchmark.mjs b/config/scripts/mobile-task-sort-benchmark.mjs new file mode 100644 index 00000000000..b7d6cb469a3 --- /dev/null +++ b/config/scripts/mobile-task-sort-benchmark.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error('Usage: node config/scripts/mobile-task-sort-benchmark.mjs ') +} +async function load(file, contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + plugins: [ + { + name: 'theme-only', + setup(bundler) { + bundler.onResolve({ filter: /mobile-tasks-dependencies$/ }, () => ({ + path: resolve('mobile/src/theme/mobile-theme.ts') + })) + } + } + ] + }) + return await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const file = 'mobile/src/tasks/mobile-tasks-repository-presentation.ts' +const before = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) +) +const after = await load(file, readFileSync(file, 'utf8')) +const repos = new Map() +const results = [] +for (const count of [0, 1, 25, 1000]) { + const items = Array.from({ length: count }, (_, index) => ({ + key: `item-${index}`, + provider: 'github', + title: 'task', + subtitle: '', + status: 'open', + updatedAt: new Date(1700000000000 - index * 100000).toISOString(), + source: { repoId: `repo-${index % 25}`, repoName: `Repository ${index % 25}` } + })) + for (const sort of ['updated', 'repository']) { + const arms = { + before: () => + [...items].sort( + sort === 'repository' + ? (a, b) => before.compareTasksByRepository(a, b, repos) + : before.compareTasksByUpdated + ), + after: () => after.sortMobileTaskItems(items, sort, repos) + } + assert.deepEqual(arms.after(), arms.before()) + const iterations = count < 100 ? 100 : 10 + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm]() + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + const dateParses = {} + const nativeParse = Date.parse + for (const arm of ['before', 'after']) { + let calls = 0 + Date.parse = (value) => { + calls++ + return nativeParse(value) + } + try { + arms[arm]() + } finally { + Date.parse = nativeParse + } + dateParses[arm] = calls + } + results.push({ + count, + sort, + dateParses, + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) + } +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/native-provider-line-gate-benchmark.mjs b/config/scripts/native-provider-line-gate-benchmark.mjs new file mode 100644 index 00000000000..75194f2c3ac --- /dev/null +++ b/config/scripts/native-provider-line-gate-benchmark.mjs @@ -0,0 +1,320 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pipe the baseline client module on stdin. Native process/filesystem operations are forbidden here. +const entry = path.resolve('src/main/computer/macos-native-provider-client.ts') +const sources = [readFileSync(0, 'utf8'), readFileSync(entry, 'utf8')] +assert(sources.every((source) => source.includes('export class MacOSNativeProviderClient'))) +async function load(source) { + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'native-client-receive-fixture', + setup(builder) { + builder.onLoad({ filter: /macos-native-provider-client\.ts$/ }, () => ({ + contents: `${source}\nexport { NativeProviderLineBuffer, consumeNativeProviderLines } from './macos-native-provider-transport';`, + loader: 'ts', + resolveDir: path.dirname(entry) + })) + builder.onResolve({ filter: /^node:(fs|child_process)$/ }, (args) => ({ + path: args.path, + namespace: 'forbidden-native-operation' + })) + builder.onLoad({ filter: /.*/, namespace: 'forbidden-native-operation' }, (args) => ({ + contents: `function forbidden() { throw new Error('Native operations are forbidden in this benchmark'); } + export { forbidden as ${ + args.path === 'node:fs' + ? 'chmodSync, forbidden as mkdtempSync, forbidden as rmSync, forbidden as writeFileSync, forbidden as existsSync' + : 'spawn' + } };`, + loader: 'js' + })) + } + } + ] + }) + const bundled = `${result.outputFiles[0].text}\n//# sourceURL=native-provider-line-gate-benchmark-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(bundled).toString('base64')}`) +} +const modules = await Promise.all(sources.map(load)) + +class FixtureSocket { + destroyed = false + writes = [] + write(line) { + this.writes.push(line) + } + end() { + this.destroyed = true + } + destroy() { + this.destroyed = true + } +} + +function clientFixture(module) { + const client = new module.MacOSNativeProviderClient() + const socket = new FixtureSocket() + client.socket = socket + return { client, socket, stale: new FixtureSocket(), events: [] } +} + +function state(fixture) { + const { client, socket, events } = fixture + return { + buffered: + typeof client.socketBuffer === 'string' ? client.socketBuffer : client.socketBuffer.pending, + pending: [...client.pending.keys()], + active: client.socket === socket, + generation: client.socketStartGeneration, + destroyed: socket.destroyed, + writes: socket.writes, + events + } +} + +function register(fixture, id, throwCallback) { + fixture.client.pending.set(id, { + timer: undefined, + resolve(value) { + fixture.events.push(['resolve', id, value]) + if (throwCallback) { + throw new Error('fixture callback failure') + } + }, + reject(error) { + fixture.events.push(['reject', id, error.code, error.message]) + if (throwCallback) { + throw new Error('fixture callback failure') + } + } + }) +} + +let seed = 0x18c0ffee +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return (seed >>> 8) % max +} +for (let trace = 0; trace < 2000; trace++) { + const fixtures = modules.map(clientFixture) + let remainder = '' + for (let step = 0; step < 40; step++) { + const op = random(20) + const id = random(8) + const throwCallback = random(12) === 0 + if (!remainder) { + remainder = [ + `${JSON.stringify({ id, ok: true, result: { text: 'Unicode 界😀', value: step } })}\n`, + `${JSON.stringify({ id, ok: false, error: { code: 'fixture', message: 'failed' } })}\r\n`, + `${JSON.stringify({ id, ok: false })}\n`, + ' \t\r\n', + 'invalid json\n', + 'null\n', + '\ud83d\udc00\n' + ][random(7)] + } + const length = random(remainder.length + 1) + const chunk = remainder.slice(0, length) + if (op > 5) { + remainder = remainder.slice(length) + } + for (const fixture of fixtures) { + const { client, socket } = fixture + try { + if (op === 0) { + client.shutdown() + } else if (op === 1) { + client.handleSocketClose(socket) + } else if (op === 2) { + client.handleTransportError(socket, new Error('fixture transport error')) + } else if (op === 3) { + client.invalidateActiveSocketAfterWriteFailure(socket, new Error('fixture write error')) + } else if (op === 4) { + fixture.stale = socket + fixture.socket = new FixtureSocket() + client.socket = fixture.socket + } else if (op === 5) { + register(fixture, id, throwCallback) + } else { + client.handleSocketData(op === 6 ? fixture.stale : socket, chunk) + } + } catch (error) { + fixture.events.push(['throw', error.name, error.message]) + } + } + assert.deepEqual(state(fixtures[1]), state(fixtures[0])) + } +} +console.log('2,000 actual client receive/lifecycle traces / 80,000 commands match') + +for (let trace = 0; trace < 1000; trace++) { + const fixtures = modules.map(clientFixture) + const failThird = random(2) === 0 + for (const fixture of fixtures) { + for (let id = 1; id <= 4; id++) { + register(fixture, id, id === 3 && failThird) + } + } + const input = [ + JSON.stringify({ id: 1, ok: true, result: { text: `界😀 ${trace}` } }), + JSON.stringify({ id: 2, ok: false, error: { code: 'fixture', message: 'failed' } }), + JSON.stringify({ id: 3, ok: true, result: trace }), + JSON.stringify({ id: 4, ok: true, result: 'final reply' }), + '' + ].join('\n') + let offset = 0 + while (offset < input.length) { + const length = 1 + random(80) + const chunk = input.slice(offset, offset + length) + offset += length + for (const fixture of fixtures) { + try { + fixture.client.handleSocketData(fixture.socket, chunk) + } catch (error) { + fixture.events.push(['throw', error.name, error.message]) + } + } + assert.deepEqual(state(fixtures[1]), state(fixtures[0])) + } + for (const fixture of fixtures) { + fixture.client.handleSocketData(fixture.socket, '') + assert.equal(fixture.client.pending.size, 0) + assert.deepEqual(fixture.events.at(-1), ['resolve', 4, 'final reply']) + } + assert.deepEqual(state(fixtures[1]), state(fixtures[0])) +} +console.log('1,000 fragmented multi-reply client journeys / 4,000 request settlements match') + +class BaselineBuffer { + pending = '' + push(chunk, onLine) { + this.pending += chunk + this.pending = modules[0].consumeNativeProviderLines(this.pending, onLine) + } + clear() { + this.pending = '' + } +} +for (let trace = 0; trace < 3000; trace++) { + const buffers = [new BaselineBuffer(), new modules[1].NativeProviderLineBuffer()] + const events = [[], []] + for (let step = 0; step < 30; step++) { + const clear = random(25) === 0 + const fail = random(10) === 0 + const chunk = ['abc', '\n', '\r\n', '\ud83d', '\udc00', '\n\n', '界', '', 'ok\nfault\npartial'][ + random(9) + ] + buffers.forEach((buffer, index) => { + if (clear) { + buffer.clear() + } + try { + buffer.push(chunk, (line) => { + events[index].push(line) + if (fail) { + throw new Error('fixture callback failure') + } + }) + } catch (error) { + events[index].push({ error: error.message }) + } + }) + assert.deepEqual(events[1], events[0]) + assert.equal(buffers[1].pending, buffers[0].pending) + } +} +console.log('3,000 actual line-buffer traces / 90,000 feeds match') + +function receiveArm(module) { + const fixture = clientFixture(module) + return (chunks) => { + let result + fixture.client.pending.set(1, { + timer: undefined, + resolve: (value) => { + result = value + }, + reject: (error) => { + throw error + } + }) + for (const chunk of chunks) { + fixture.client.handleSocketData(fixture.socket, chunk) + } + return result + } +} +function sample(arm, input, repeats) { + const start = performance.now() + let result + for (let i = 0; i < repeats; i++) { + result = arm(input) + } + return { elapsed: (performance.now() - start) / repeats, result } +} + +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + unit: 'ms', + pairs: 8 + }) +) +for (const [size, chunkBytes] of [ + [64, 65536], + [120000, 65536], + [1200000, 65536], + [1200000, 4096], + [4800000, 65536], + [1200000, Number.POSITIVE_INFINITY] +]) { + const expected = { screenshot: { data: 'A'.repeat(size) }, text: 'fixture' } + const input = `${JSON.stringify({ id: 1, ok: true, result: expected })}\n` + const chunks = [] + for (let offset = 0; offset < input.length; offset += chunkBytes) { + chunks.push(input.slice(offset, offset + chunkBytes)) + } + const arms = modules.map(receiveArm) + for (const arm of arms) { + assert.deepEqual(arm(chunks), expected) + const until = performance.now() + 150 + while (performance.now() < until) { + sample(arm, chunks, 1) + } + } + const repeats = Math.max(3, Math.min(100000, Math.ceil(50 / sample(arms[0], chunks, 1).elapsed))) + /** @type {number[][]} */ + const times = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + const result = sample(arms[index], chunks, repeats) + assert.deepEqual(result.result, expected) + times[index].push(result.elapsed) + } + } + const median = times.map((values) => { + values.sort((a, b) => a - b) + return (values[3] + values[4]) / 2 + }) + console.log( + JSON.stringify({ + size, + chunkBytes: Number.isFinite(chunkBytes) ? chunkBytes : 'whole frame', + chunks: chunks.length, + repeats, + median, + times + }) + ) +} diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index aa34e043268..31d9b7a8d57 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' @@ -11,47 +11,49 @@ const { createPackagedRuntimeNodeModuleResources } = require('../packaged-runtim const readProject = (file) => readFileSync(join(projectDir, file), 'utf8') const packageJson = JSON.parse(readProject('package.json')) const pnpmWorkspace = parse(readProject('pnpm-workspace.yaml')) +// Why not process.platform: the win32 plan resolves wherever its os-gated npm addon is +// installed; @orca/windows-registry is a workspace link and present everywhere. +const windowsAddonsInstalled = existsSync( + join(projectDir, 'node_modules', '@vscode', 'windows-process-tree', 'package.json') +) describe('Electron runtime package contract', () => { + const packageTargets = { + win32: windowsAddonsInstalled ? createPackagedRuntimeNodeModuleResources('win32') : [], + darwin: createPackagedRuntimeNodeModuleResources('darwin'), + linux: createPackagedRuntimeNodeModuleResources('linux') + } + it('keeps root postinstall as the single Electron binary install owner', () => { expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs') expect(pnpmWorkspace.allowBuilds).not.toHaveProperty('electron') }) it('keeps the native Windows registry addon optional and platform-gated', () => { - const rebuildScript = readFileSync( - join(projectDir, 'config/scripts/rebuild-native-deps.mjs'), - 'utf8' - ) - const ensureScript = readFileSync( - join(projectDir, 'config/scripts/ensure-native-runtime.mjs'), - 'utf8' - ) - expect(packageJson.optionalDependencies['windows-native-registry']).toBe('3.2.2') - // Why: pnpm installs optional target architectures on every host; the root - // Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it. - expect(pnpmWorkspace.allowBuilds['windows-native-registry']).toBe(false) + const rebuildScript = readProject('config/scripts/rebuild-native-deps.mjs') + const ensureScript = readProject('config/scripts/ensure-native-runtime.mjs') + expect(packageJson.optionalDependencies['@orca/windows-registry']).toBe('workspace:*') + // Why: allowBuilds stops pnpm running node-gyp at install time -- the root + // Windows-only rebuild owns this addon so it is built against the right runtime ABI. + expect(pnpmWorkspace.allowBuilds['@orca/windows-registry']).toBe(false) // Why assert the guard and the member separately: the list now carries more // than one addon, so pinning the whole literal only tested its formatting. expect(rebuildScript).toContain("rebuildPlatform === 'win32'") - expect(rebuildScript).toContain("'windows-native-registry'") + expect(rebuildScript).toContain("'@orca/windows-registry'") expect(ensureScript).toContain("process.platform === 'win32'") - expect(ensureScript).toContain("'windows-native-registry'") - const packageTargets = { - win32: createPackagedRuntimeNodeModuleResources('win32'), - darwin: createPackagedRuntimeNodeModuleResources('darwin'), - linux: createPackagedRuntimeNodeModuleResources('linux') + expect(ensureScript).toContain("'@orca/windows-registry'") + if (windowsAddonsInstalled) { + expect(packageTargets.win32).toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', '@orca', 'windows-registry') }), + expect.objectContaining({ to: join('node_modules', 'node-addon-api') }) + ]) + ) } - expect(packageTargets.win32).toEqual( - expect.arrayContaining([ - expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }), - expect.objectContaining({ to: join('node_modules', 'node-addon-api') }) - ]) - ) for (const platform of ['darwin', 'linux']) { expect(packageTargets[platform]).not.toEqual( expect.arrayContaining([ - expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }) + expect.objectContaining({ to: join('node_modules', '@orca', 'windows-registry') }) ]) ) } @@ -67,8 +69,8 @@ describe('Electron runtime package contract', () => { 'utf8' ) expect(packageJson.optionalDependencies['@vscode/windows-process-tree']).toBe('0.8.0') - // Why: same rule as the registry addon -- pnpm installs optional deps on - // every host, so macOS/Linux must never run node-gyp for a Windows addon. + // Why: same rule as the registry addon -- allowBuilds stops pnpm running node-gyp at + // install time so the Windows-only rebuild owns it with the right runtime ABI. expect(pnpmWorkspace.allowBuilds['@vscode/windows-process-tree']).toBe(false) expect(rebuildScript).toContain("'@vscode/windows-process-tree'") expect(ensureScript).toContain("'@vscode/windows-process-tree'") @@ -79,16 +81,13 @@ describe('Electron runtime package contract', () => { expect(pnpmWorkspace.patchedDependencies['@vscode/windows-process-tree@0.8.0']).toBe( 'config/patches/@vscode__windows-process-tree@0.8.0.patch' ) - const packageTargets = { - win32: createPackagedRuntimeNodeModuleResources('win32'), - darwin: createPackagedRuntimeNodeModuleResources('darwin'), - linux: createPackagedRuntimeNodeModuleResources('linux') + if (windowsAddonsInstalled) { + expect(packageTargets.win32).toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') }) + ]) + ) } - expect(packageTargets.win32).toEqual( - expect.arrayContaining([ - expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') }) - ]) - ) for (const platform of ['darwin', 'linux']) { expect(packageTargets[platform]).not.toEqual( expect.arrayContaining([ diff --git a/config/scripts/plugin-command-bindings-benchmark.mjs b/config/scripts/plugin-command-bindings-benchmark.mjs new file mode 100644 index 00000000000..58b3dad8cab --- /dev/null +++ b/config/scripts/plugin-command-bindings-benchmark.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error('Usage: node config/scripts/plugin-command-bindings-benchmark.mjs ') +} +async function load(file, contents, name) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + banner: { + js: "import { createRequire as benchmarkRequire } from 'node:module'; import { resolve as benchmarkPath } from 'node:path'; const require = benchmarkRequire(benchmarkPath('package.json'));" + } + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + )[name] +} +const file = 'src/main/plugins/plugin-command-registry.ts' +const before = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }), + 'PluginCommandRegistry' +) +const after = await load(file, readFileSync(file, 'utf8'), 'PluginCommandRegistry') +const results = [] +for (const count of [1, 16, 64, 256]) { + const plugins = [ + { + pluginKey: 'sample.commands', + manifest: { + contributes: { + commands: Array.from({ length: count }, (_, index) => ({ + id: `command-${index}`, + title: `Command ${index}`, + action: 'view.tasks' + })), + keybindings: Array.from({ length: Math.min(count, 104) }, (_, index) => ({ + command: `command-${index}`, + key: `Mod+${Math.floor(index / 26) & 1 ? 'Alt+' : ''}${Math.floor(index / 26) & 2 ? 'Shift+' : ''}${String.fromCharCode(65 + (index % 26))}` + })) + } + } + } + ] + const arms = { before: new before(), after: new after() } + for (const platform of ['darwin', 'linux', 'win32']) { + for (const arm of Object.values(arms)) { + arm.reconcile(plugins, () => true, {}, platform) + } + const snapshot = (registry) => ({ + active: registry.list(), + previews: plugins.map((plugin) => registry.preview(plugin.pluginKey)), + errors: plugins.map((plugin) => registry.error(plugin.pluginKey)) + }) + assert.deepEqual(snapshot(arms.after), snapshot(arms.before)) + } + const iterations = 100 + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm].reconcile(plugins, () => true, {}, 'linux') + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + commands: count, + bindings: Math.min(count, 104), + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/plugin-message-budget-benchmark.mjs b/config/scripts/plugin-message-budget-benchmark.mjs new file mode 100644 index 00000000000..605eee85ea6 --- /dev/null +++ b/config/scripts/plugin-message-budget-benchmark.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] ?? '20ab9950654' +const file = 'src/shared/plugins/plugin-panel-message-budget.ts' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const before = await load( + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) +) +const after = await load(readFileSync(file, 'utf8')) +const results = [] +for (const count of [5, 1000, 100000]) { + const entries = Array.from({ length: count }, (_, i) => [`key-${i}`, `value-${i}`]) + for (const [kind, value] of [ + ['array', entries.map(([, value]) => value)], + ['map', new Map(entries)], + ['set', new Set(entries.map(([, value]) => value))], + ['object', Object.fromEntries(entries)] + ]) { + const input = structuredClone(value) + for (const cap of [0, 1, 64, 1024, 65536, Infinity]) { + assert.equal( + after.structuredCloneMessageBytes(input, cap), + before.structuredCloneMessageBytes(input, cap) + ) + } + const arms = { before, after } + const iterations = count < 100 ? 1000 : 10 + const run = (arm) => { + global.gc?.() + const start = performance.now() + const cpuStart = process.cpuUsage() + for (let i = 0; i < iterations; i++) { + arms[arm].structuredCloneMessageBytes(input) + } + const cpu = process.cpuUsage(cpuStart) + return { + ms: (performance.now() - start) / iterations, + cpuMs: (cpu.user + cpu.system) / 1000 / iterations + } + } + for (let i = 0; i < 3; i++) { + run('before') + run('after') + } + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + count, + kind, + beforeMs: median(samples.before.map((sample) => sample.ms)), + beforeCpuMs: median(samples.before.map((sample) => sample.cpuMs)), + afterMs: median(samples.after.map((sample) => sample.ms)), + afterCpuMs: median(samples.after.map((sample) => sample.cpuMs)), + samples + }) + } +} +console.log( + JSON.stringify( + { + baseline, + node: process.version, + platform: process.platform, + forcedGc: Boolean(global.gc), + results + }, + null, + 2 + ) +) diff --git a/config/scripts/plugin-shortcut-conflicts-benchmark.mjs b/config/scripts/plugin-shortcut-conflicts-benchmark.mjs new file mode 100644 index 00000000000..c7dd5d3c60e --- /dev/null +++ b/config/scripts/plugin-shortcut-conflicts-benchmark.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error( + 'Usage: node config/scripts/plugin-shortcut-conflicts-benchmark.mjs ' + ) +} +async function load(file, contents, name) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + banner: { + js: "import { createRequire as benchmarkRequire } from 'node:module'; import { resolve as benchmarkPath } from 'node:path'; const require = benchmarkRequire(benchmarkPath('package.json'));" + } + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + )[name] +} +const file = 'src/main/plugins/plugin-command-registry.ts' +const before = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }), + 'PluginCommandRegistry' +) +const after = await load(file, readFileSync(file, 'utf8'), 'PluginCommandRegistry') +const results = [] +for (const count of [1, 8, 32, 128]) { + const plugins = Array.from({ length: count }, (_, index) => ({ + pluginKey: `sample.plugin-${index}`, + manifest: { + contributes: { + commands: [ + { + id: 'open', + title: 'Open', + action: 'view.tasks', + context: index % 2 ? 'worktree' : 'global' + } + ], + keybindings: [{ command: 'open', key: 'Mod+Alt+T' }] + } + } + })) + const arms = { before: new before(), after: new after() } + for (const platform of ['darwin', 'linux', 'win32']) { + for (const arm of Object.values(arms)) { + arm.reconcile(plugins, () => true, {}, platform) + } + const snapshot = (registry) => ({ + active: registry.list(), + previews: plugins.map((plugin) => registry.preview(plugin.pluginKey)), + errors: plugins.map((plugin) => registry.error(plugin.pluginKey)) + }) + assert.deepEqual(snapshot(arms.after), snapshot(arms.before)) + } + const iterations = 100 + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm].reconcile(plugins, () => true, {}, 'linux') + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ count, beforeMs: median(samples.before), afterMs: median(samples.after), samples }) +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 8e88aa16df5..3b39bb34275 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -215,6 +215,7 @@ const WINDOWS_PACKAGE_TESTS = [ ...LINUX_PACKAGE_TESTS, 'config/scripts/rebuild-native-deps.test.mjs', 'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs', + 'src/main/windows-registry-addon.test.ts', 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', 'src/shared/child-process/windows-command-line.win32.test.ts', diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 4f012b105b4..22677bb152f 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -179,6 +179,13 @@ describe('PR E2E gate contract', () => { 'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"' ) expect(playwrightConfig).toContain('retries: 0') + const steps = e2eWorkflow.jobs.e2e.steps.filter((step) => + step.run?.includes('tests/e2e/worktree-switch-first-paint.spec.ts') + ) + expect(steps).toHaveLength(1) + expect(steps[0].if).toBe("matrix.shard == '1/14'") + expect(steps[0].run).toContain('xvfb-run --auto-servernum') + expect(steps[0].run).toContain('--project=electron-headful --workers=1') }) it('keeps startup-exec live parity in the isolated SSH lane', () => { diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 92d4fe4c26b..ed4e1b1f1c8 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -328,10 +328,8 @@ describe('PR workflow parallelism', () => { expect(dependencyInstall.run).toContain('--ignore-scripts') expect(dependencyInstall.run).not.toContain('--os=') expect(dependencyInstall.run).not.toContain('--cpu=') - expect(pnpmWorkspace.supportedArchitectures.os).toEqual( - expect.arrayContaining(['current', 'win32']) - ) - expect(pnpmWorkspace.supportedArchitectures.cpu).toContain('current') + expect(pnpmWorkspace.supportedArchitectures.os).toEqual(['current']) + expect(pnpmWorkspace.supportedArchitectures.cpu).toEqual(['current']) const prepareRuntime = dependencyAction.runs.steps.find( (step) => step.name === 'Prepare native runtime' ) @@ -389,8 +387,8 @@ describe('PR workflow parallelism', () => { expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs') expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs') expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build') - expect(cacheStep.with.path).toContain('windows-native-registry@') - expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@') + expect(cacheStep.with.path).toContain('native/windows-registry/build') + expect(cacheStep.with.path).toContain('@vscode+windows-process-tre*') expect(cacheStep.with['restore-keys']).toBeUndefined() } expect(steps[cacheIndex].id).toBe('native-cache-restore') diff --git a/config/scripts/pty-transcript-secret-scan.mjs b/config/scripts/pty-transcript-secret-scan.mjs new file mode 100644 index 00000000000..1d93204ccda --- /dev/null +++ b/config/scripts/pty-transcript-secret-scan.mjs @@ -0,0 +1,135 @@ +// Finds account identifiers and credentials in a captured PTY transcript before it is committed. +import os from 'node:os' + +// Why same-length replacements: a transcript's value is its exact wrapping and column +// alignment. Shortening a redacted span reflows the screen and destroys the evidence. +const EMAIL_DOMAIN = '@example.com' +const PLACEHOLDER_UUID = '00000000-0000-4000-8000-000000000000' + +/** Ordered most-specific first; the first pattern to claim a span owns it. */ +function buildPatterns() { + const username = os.userInfo().username + const hostname = os.hostname() + const patterns = [ + { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g }, + { kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{20,}/g }, + { kind: 'google-refresh-token', re: /\b1\/\/[0-9A-Za-z_-]{20,}/g }, + { kind: 'vendor-key', re: /\b(?:sk-|ghp_|gho_|github_pat_|xoxb-|xoxp-)[A-Za-z0-9_-]{16,}/g }, + { kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi }, + { kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g }, + // Why a UUID counts: agy prints a resumable conversation id on exit, and installation and + // project ids look the same. They identify the operator's session, not just its shape. + { kind: 'uuid', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi }, + { kind: 'opaque-token', re: /\b[A-Za-z0-9_-]{40,}\b/g } + ] + if (username.length >= 3) { + patterns.splice(5, 0, { kind: 'local-username', re: literalPattern(username) }) + } + if (hostname.length >= 3) { + patterns.splice(5, 0, { kind: 'local-hostname', re: literalPattern(hostname) }) + } + return patterns +} + +function literalPattern(value) { + return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') +} + +/** + * @param {string} text raw transcript, escapes intact + * @returns {{kind: string, line: number, column: number, index: number, match: string}[]} + */ +export function scanTranscriptForSecrets(text) { + const claimed = [] + const findings = [] + for (const { kind, re } of buildPatterns()) { + re.lastIndex = 0 + let match = re.exec(text) + while (match !== null) { + const start = match.index + const end = start + match[0].length + if (!claimed.some(([from, to]) => start < to && end > from)) { + claimed.push([start, end]) + if (!isAlreadyScrubbed(kind, match[0])) { + findings.push({ kind, index: start, match: match[0], ...locate(text, start) }) + } + } + match = re.exec(text) + } + } + return findings.sort((left, right) => left.index - right.index) +} + +// Why: a scrubbed fixture must verify clean, so this scanner has to recognise its own +// placeholders — otherwise "prove it's gone" can never pass and the check gets ignored. +const PLACEHOLDER_DOMAIN_RE = /@(?:example\.(?:com|org|net)|localhost)$/i + +function isAlreadyScrubbed(kind, match) { + if (kind === 'email') { + return PLACEHOLDER_DOMAIN_RE.test(match) + } + if (kind === 'uuid') { + return match.toLowerCase() === PLACEHOLDER_UUID + } + return /^(.)\1*$/.test(match) +} + +function locate(text, index) { + let line = 1 + let lineStart = 0 + for (let cursor = 0; cursor < index; cursor += 1) { + if (text.charCodeAt(cursor) === 10) { + line += 1 + lineStart = cursor + 1 + } + } + return { line, column: index - lineStart + 1 } +} + +/** Same-length stand-in so redaction cannot reflow the captured screen. */ +export function placeholderFor(kind, length) { + if (kind === 'uuid' && length === PLACEHOLDER_UUID.length) { + return PLACEHOLDER_UUID + } + if (kind === 'email' && length > EMAIL_DOMAIN.length) { + return 'u'.repeat(length - EMAIL_DOMAIN.length) + EMAIL_DOMAIN + } + return kind === 'local-username' || kind === 'local-hostname' + ? 'x'.repeat(length) + : 'X'.repeat(length) +} + +/** @returns {{text: string, redacted: number}} */ +export function redactTranscript(text) { + const findings = scanTranscriptForSecrets(text) + let out = '' + let cursor = 0 + for (const finding of findings) { + out += text.slice(cursor, finding.index) + out += placeholderFor(finding.kind, finding.match.length) + cursor = finding.index + finding.match.length + } + return { text: out + text.slice(cursor), redacted: findings.length } +} + +export function formatFindings(label, findings) { + if (findings.length === 0) { + return `${label}: clean — no account identifier or credential shapes found.` + } + const rows = findings.map( + (finding) => ` ${finding.line}:${finding.column} ${finding.kind} ${preview(finding.match)}` + ) + return [`${label}: ${findings.length} finding(s) — scrub before committing.`, ...rows].join('\n') +} + +// Why a codepoint test and not a character class: a control-byte range written as an escape is +// folded back into raw 0x00-0x1f bytes by the formatter, which makes this file binary to the VCS +// and leaves the one file gating real PTY data into history unreviewable in a diff. +function preview(value) { + const head = value.length <= 24 ? value : `${value.slice(0, 21)}...` + let printable = '' + for (const char of head) { + printable += (char.codePointAt(0) ?? 0) < 0x20 ? '?' : char + } + return printable +} diff --git a/config/scripts/pty-transcript-secret-scan.test.mjs b/config/scripts/pty-transcript-secret-scan.test.mjs new file mode 100644 index 00000000000..2d3cd894da0 --- /dev/null +++ b/config/scripts/pty-transcript-secret-scan.test.mjs @@ -0,0 +1,133 @@ +// The scrub gate is the only thing standing between a live agent transcript and a +// committed account identifier, so it is pinned on the shapes those transcripts carry. +import { readdirSync, readFileSync } from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + formatFindings, + placeholderFor, + redactTranscript, + scanTranscriptForSecrets +} from './pty-transcript-secret-scan.mjs' +import { parseArgs, resolveSpawn } from './capture-agent-pty-transcript.mjs' + +describe('pty transcript secret scan', () => { + it('finds the account row of a ready screen', () => { + const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\njin.woo@acme.dev (Business)') + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ kind: 'email', line: 2, column: 1 }) + }) + + it('finds credentials an agent may echo while signing in', () => { + const kinds = scanTranscriptForSecrets( + [ + 'token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVP', + 'key: AIzaSyA1234567890abcdefghijklmnopqrstu', + 'refresh: 1//0gLm34XyZabcdefghijklmnopqrstuvwx', + 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345' + ].join('\n') + ).map((finding) => finding.kind) + expect(kinds).toEqual(['jwt', 'google-api-key', 'google-refresh-token', 'bearer-token']) + }) + + it('flags this machine’s own username, which a prompt line leaks', () => { + const username = os.userInfo().username + const findings = scanTranscriptForSecrets(`~/Users/${username}/orca/repo\n> `) + expect(findings.some((finding) => finding.kind === 'local-username')).toBe(true) + }) + + it('finds the resumable conversation id agy prints on exit', () => { + const findings = scanTranscriptForSecrets( + 'Resume with -c (or command below):\nagy --conversation=26dc1986-9eec-456a-a534-d93e5c1076c2' + ) + expect(findings).toHaveLength(1) + expect(findings[0].kind).toBe('uuid') + expect(placeholderFor('uuid', findings[0].match.length)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ) + }) + + it('reports a clean transcript as clean', () => { + const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\nSonnet 4.6 (High)\n> ') + expect(findings).toEqual([]) + expect(formatFindings('fixture', findings)).toContain('clean') + }) + + it('claims a span once, so a token inside an email is not double-reported', () => { + const findings = scanTranscriptForSecrets('longlivedaccountname@corp.internal') + expect(findings).toHaveLength(1) + }) + + it('passes a fixture that is already scrubbed, so "prove it is gone" can succeed', () => { + const scrubbed = `uuuu@example.com\n${'X'.repeat(44)}` + expect(scanTranscriptForSecrets(scrubbed)).toEqual([]) + }) +}) + +describe('redaction', () => { + it('replaces every finding with the same number of characters', () => { + // Why length matters: the fixture's value is its exact wrapping. A shorter + // replacement reflows the screen and invalidates the capture. + const text = 'Antigravity CLI 1.1.17\njin.woo@acme.dev (Antigravity Business)\n> ' + const { text: redacted, redacted: count } = redactTranscript(text) + expect(count).toBe(1) + expect(redacted).toHaveLength(text.length) + expect(redacted).not.toContain('jin.woo@acme.dev') + expect(scanTranscriptForSecrets(redacted)).toEqual([]) + expect(redactTranscript(redacted).redacted).toBe(0) + }) + + it('keeps a redacted email shaped like an email', () => { + expect(placeholderFor('email', 'a@b.example.com'.length)).toMatch(/^u+@example\.com$/) + }) + + it('leaves the rest of the screen byte-for-byte untouched', () => { + const text = 'line one\nuser@corp.io\nline three' + expect(redactTranscript(text).text.split('\n')[2]).toBe('line three') + }) +}) + +describe('committed transcripts', () => { + // Why in CI and not just in the recorder: a transcript is committed once and read forever. + // The capture-time warning is skippable; this is not. + const fixtureDir = join(import.meta.dirname, '..', '..', 'src', 'main', 'runtime', '__fixtures__') + const transcripts = readdirSync(fixtureDir).filter((entry) => entry.endsWith('.txt')) + + it.each(transcripts)('%s carries no account identifier or credential', (name) => { + const findings = scanTranscriptForSecrets(readFileSync(join(fixtureDir, name), 'utf8')) + expect(formatFindings(name, findings)).toContain('clean') + }) +}) + +describe('capture argv', () => { + it('splits recorder options from the agent command', () => { + const { options, command } = parseArgs([ + '--name', + 'antigravity-ready-personal-non-gemini', + '--cols', + '120', + '--', + 'agy', + '--model', + 'sonnet' + ]) + expect(options.name).toBe('antigravity-ready-personal-non-gemini') + expect(options.cols).toBe(120) + expect(command).toEqual(['agy', '--model', 'sonnet']) + }) + + it('collects a multi-file scan list', () => { + const { options } = parseArgs(['--scan', 'a.txt', 'b.txt', '--redact']) + expect(options.scan).toEqual(['a.txt', 'b.txt']) + expect(options.redact).toBe(true) + }) + + it('routes a Windows shim through cmd.exe, which node-pty cannot spawn directly', () => { + expect(resolveSpawn(['agy.cmd', '--model', 'sonnet'])).toEqual( + process.platform === 'win32' + ? { file: 'cmd.exe', args: ['/c', '"agy.cmd"', '--model', 'sonnet'] } + : { file: 'agy.cmd', args: ['--model', 'sonnet'] } + ) + }) +}) diff --git a/config/scripts/raw-markdown-comment-scan-benchmark.mjs b/config/scripts/raw-markdown-comment-scan-benchmark.mjs new file mode 100644 index 00000000000..cc6b5964e61 --- /dev/null +++ b/config/scripts/raw-markdown-comment-scan-benchmark.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] ?? '20ab9950654' +const file = 'src/renderer/src/components/editor/raw-markdown-html.ts' +async function load(contents) { + const result = await build({ + stdin: { + contents: `${contents}\nexport { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'`, + loader: 'ts', + resolveDir: dirname(resolve(file)) + }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + banner: { + js: `import { createRequire } from 'node:module'; const require = createRequire(${JSON.stringify(resolve('package.json'))});` + } + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const arms = { + before: await load( + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8', windowsHide: true }) + ), + after: await load(readFileSync(file, 'utf8')) +} +const key = '0123456789abcdef0123456789abcdef' +const codecs = Object.fromEntries( + Object.entries(arms).map(([arm, module]) => [arm, module.createRichMarkdownEditorCodec(key)]) +) +const results = [] +for (const [name, input] of [ + ['plain', '# Heading\nOrdinary prose.'], + ['complete', 'before after text\n'.repeat(100)], + ['unclosed-1000', `prefix ${'${'\n## Review findings\n${'Code sample with details\n'.repeat(100)}` + ], + [ + '10000-lines', + `\n## Review findings\n${'Code sample with details\n'.repeat(10000)}` + ], + ['blank-10000-lines', '# > * - _ `\n'.repeat(10000)] +]) { + const comments = Array.from({ length: 10 }, (_, id) => ({ + id, + author: 'reviewer', + authorAvatarUrl: '', + createdAt: '', + url: '', + body + })) + const arms = { before, after } + assert.equal( + after.buildPRCommentBatchConversationReplyBody(comments), + before.buildPRCommentBatchConversationReplyBody(comments) + ) + const run = (arm) => { + global.gc?.() + const start = performance.now() + const cpuStart = process.cpuUsage() + for (let i = 0; i < 10; i++) { + arms[arm].buildPRCommentBatchConversationReplyBody(comments) + } + const cpu = process.cpuUsage(cpuStart) + return { ms: (performance.now() - start) / 10, cpuMs: (cpu.user + cpu.system) / 10000 } + } + run('before') + run('after') + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + name, + beforeCpuMs: median(samples.before.map((s) => s.cpuMs)), + afterCpuMs: median(samples.after.map((s) => s.cpuMs)), + beforeMs: median(samples.before.map((s) => s.ms)), + afterMs: median(samples.after.map((s) => s.ms)), + samples + }) +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/rich-markdown-blank-run-benchmark.mjs b/config/scripts/rich-markdown-blank-run-benchmark.mjs new file mode 100644 index 00000000000..868a4c2ef6d --- /dev/null +++ b/config/scripts/rich-markdown-blank-run-benchmark.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const entry = join(root, 'src/renderer/src/components/editor/raw-markdown-html.ts') +const source = await readFile(entry, 'utf8') +const cachedProbe = + /if \(index > fenceProbe\) \{[\s\S]*?fenceMatch = fencePrefix.exec\(normalizedContent\)\n \}/ +assert.match(source, cachedProbe) +const oldProbe = String.raw`fenceMatch = normalizedContent.slice(index).match(/^\s*(\x60{3,}|~{3,})/)` +const temp = await mkdtemp(join(tmpdir(), 'orca-rich-blank-bench-')) +try { + const scanners = {} + for (const arm of ['baseline', 'current']) { + const outfile = join(temp, `${arm}.cjs`) + await build({ + stdin: { + contents: `export { encodeRawMarkdownHtmlForRichEditor as encode } from './src/renderer/src/components/editor/raw-markdown-html'; export { createRichMarkdownEditorCodec as codec } from './src/renderer/src/components/editor/rich-markdown-source-transport';`, + resolveDir: root + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + plugins: + arm === 'baseline' + ? [ + { + name: 'old-probe', + setup(plugin) { + plugin.onLoad({ filter: /raw-markdown-html\.ts$/ }, () => ({ + contents: source.replace(cachedProbe, oldProbe), + loader: 'ts', + resolveDir: join(root, 'src/renderer/src/components/editor') + })) + } + } + ] + : [] + }) + const { encode, codec } = createRequire(import.meta.url)(outfile) + scanners[arm] = (content) => encode(content, codec('0'.repeat(32))) + } + const fragments = [ + '\n', + ' \r\n', + '\u00a0\u2028', + '```\n', + '~~~~\n', + '
\n', + '
\n', + '[[doc.md]]\n', + '`inline`\n', + 'prose\n' + ] + let seed = 42 + for (let sample = 0; sample < 256; sample++) { + let content = '' + for (let i = 0; i < 20; i++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + content += fragments[seed % fragments.length] + } + assert.equal(scanners.current(content), scanners.baseline(content)) + } + for (const [name, content] of [ + ['ordinary', 'ordinary prose\n'.repeat(10000)], + ['blank-100k', '\n'.repeat(100000)], + ['blank-before-fence', `${'\n'.repeat(30000)}\x60\x60\x60\n
\n\x60\x60\x60\n[[doc.md]]`] + ]) { + const samples = { baseline: [], current: [] } + let expected + for (const arms of buildCounterbalancedSchedule(2, 'baseline', 'current')) { + for (const arm of arms) { + const start = performance.now() + const result = scanners[arm](content) + samples[arm].push(performance.now() - start) + expected ??= result + assert.equal(result, expected) + } + } + console.log( + JSON.stringify({ + name, + bytes: Buffer.byteLength(content), + samples, + baseline: summarizeBenchmarkSamples(samples.baseline), + current: summarizeBenchmarkSamples(samples.current) + }) + ) + } +} finally { + await rm(temp, { recursive: true, force: true }) +} diff --git a/config/scripts/rich-markdown-comment-scan-benchmark.mjs b/config/scripts/rich-markdown-comment-scan-benchmark.mjs new file mode 100644 index 00000000000..29c52e4b761 --- /dev/null +++ b/config/scripts/rich-markdown-comment-scan-benchmark.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] ?? '20ab9950654' +const file = 'src/renderer/src/components/editor/markdown-rich-mode.ts' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'cached-round-trip', + setup(bundler) { + bundler.onResolve({ filter: /markdown-round-trip$|^@\/i18n\/i18n$/ }, (args) => ({ + path: args.path, + namespace: 'bench' + })) + bundler.onLoad({ filter: /.*/, namespace: 'bench' }, (args) => ({ + contents: args.path.endsWith('markdown-round-trip') + ? 'export const getRichMarkdownRoundTripOutput = (content) => content' + : 'export const translate = (_key, fallback) => fallback', + loader: 'js' + })) + } + } + ] + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const arms = { + before: await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })), + after: await load(readFileSync(file, 'utf8')) +} +const results = [] +for (const [name, content] of [ + ['plain', '# Heading\nOrdinary prose with .'], + ['complete', 'text'], + ['unclosed-1000', '', null], + ['', 'html-or-jsx'], + ['tail', 'html-or-jsx'], + ['">', 'html-or-jsx'], + ['``', null], + ['```html\n\n```', null], + ['\n[a]: https://example.com', 'reference-links'], + ['\n[^a]: footnote', 'reference-links'] + ] as const)('preserves the decision for %j', (content, expected) => { + vi.mocked(getRichMarkdownRoundTripOutput).mockReturnValue(null) + expect(getMarkdownRichModeUnsupportedReason(content)).toBe(expected) + }) + + it('does not pass unclosed comment openers through a comment regex', () => { + const input = '' + ).length + expect(result).toBeNull() + expect(closerSearches).toBeLessThanOrEqual(1) + vi.mocked(getRichMarkdownRoundTripOutput).mockReturnValue( + input.replace('after', 'after') + ) + expect(getMarkdownRichModeUnsupportedReason(input)).toBe('html-or-jsx') + }) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-line-scan.test.ts b/src/renderer/src/components/editor/markdown-rich-line-scan.test.ts new file mode 100644 index 00000000000..2cadf352b82 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-rich-line-scan.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import { getMarkdownRichModeEligibilityDecision } from './markdown-rich-mode' + +const decide = (content: string) => + getMarkdownRichModeEligibilityDecision({ content, sizeOverridden: false }) + +describe('rich Markdown line scanning', () => { + it('does not inspect every prose character to find newline boundaries', () => { + const content = 'Ordinary prose '.repeat(10_000) + const spy = vi.spyOn(String.prototype, 'charCodeAt') + let decision: ReturnType + let calls: number + try { + decision = decide(content) + calls = spy.mock.calls.length + } finally { + spy.mockRestore() + } + expect(decision).toEqual({ exceedsSizeLimit: false, unsupportedReason: null }) + expect(calls).toBeLessThan(10) + }) + + it.each(['\n', '\r\n'])( + 'preserves fences and reference visibility with %j endings', + (newline) => { + for (const fence of ['```', '~~~']) { + const protectedSource = [`${fence}md`, '[ref]: /hidden', fence].join(newline) + expect(decide(protectedSource).unsupportedReason).toBeNull() + expect(decide(protectedSource + newline).unsupportedReason).toBeNull() + expect(decide(`${protectedSource}${newline}[ref]: /visible`).unsupportedReason).toBe( + 'reference-links' + ) + } + } + ) + + it.each(['', '\n', '\r', '\r\n', '`[ref]: /hidden`', '```\n[ref]: /hidden\r'])( + 'preserves empty, final-CR, inline-code and unclosed-fence input %j', + (content) => expect(decide(content).unsupportedReason).toBeNull() + ) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index 142e0cea560..4849380f8fe 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -48,7 +48,7 @@ const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [ // Why: the rich editor preserves common embedded markup via placeholder // tokens before parsing, but any HTML shape that still fails round-trip // must fall back instead of risking silent source corruption. - pattern: /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>|/ + pattern: /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/ }, { reason: 'reference-links', @@ -157,6 +157,11 @@ export function getMarkdownRichModeEligibility(params: { } function hasHtmlOrJsx(content: string, pattern: RegExp): boolean { + // A missing closer after the first opener rules out every later opener. + const commentStart = content.indexOf('', commentStart + 4)) { + return true + } for (const match of content.matchAll(new RegExp(pattern, 'g'))) { if (isHtmlOrJsxFragment(match[0])) { return true @@ -166,7 +171,7 @@ function hasHtmlOrJsx(content: string, pattern: RegExp): boolean { } function isHtmlOrJsxFragment(fragment: string): boolean { - if (fragment.startsWith('') for (let index = 0; index < content.length; index++) { if (content.charCodeAt(index) !== 60) { continue @@ -231,7 +236,7 @@ function forEachEmbeddedHtmlFragment( let fragmentEnd: number | null = null if (content.startsWith('', index + 4) + const commentEnd = index + 4 <= lastCommentClose ? content.indexOf('-->', index + 4) : -1 fragmentEnd = commentEnd === -1 ? null : commentEnd + 3 } else { fragmentEnd = getHtmlTagEnd(content, index) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts index e0e703a23e7..e333f7801a9 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts @@ -76,6 +76,14 @@ function referenceDecorationRanges(content: string): IRange[] { const CORPUS: { name: string; content: string }[] = [ { name: 'empty', content: '' }, + { + name: 'Unicode whitespace before fences', + content: '\u00a0\u2028```\n[[hidden]]\n```\n[[shown]]' + }, + { + name: 'indented blank run before fences', + content: `${`${' '.repeat(80)}\r\n`.repeat(1000)}\`\`\`\n[[hidden]]\n\`\`\`\n[[shown]]` + }, { name: 'no links', content: '# Title\n\nJust prose.\n' }, { name: 'single link', content: '# Title\n\nSee [[notes.md]] for details.\n' }, { name: 'two links on one line', content: 'See [[a.md]] and [[b.md]].\n' }, diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts index e81ba97ce1b..457213e0c12 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts @@ -41,12 +41,11 @@ function isInsideSpan(index: number, spans: number[]): boolean { return false } -const FENCE_PREFIX_RE = /\s*(?:```|~~~)/y +const FENCE_PREFIX_RE = /[^\S\n]*(?:```|~~~)/y function startsCodeFence(content: string, lineStart: number, lineEnd: number): boolean { FENCE_PREFIX_RE.lastIndex = lineStart - // Why: a sticky `\s*` run can cross the newline into the next line, so an - // out-of-line match is rejected to stay identical to the old per-line regex. + // Bound whitespace to this line so blank runs cannot trigger repeated suffix scans. return FENCE_PREFIX_RE.test(content) && FENCE_PREFIX_RE.lastIndex <= lineEnd } diff --git a/src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts b/src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts new file mode 100644 index 00000000000..81e6990f058 --- /dev/null +++ b/src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +const key = '0123456789abcdef0123456789abcdef' +afterEach(() => vi.restoreAllMocks()) + +describe('raw Markdown HTML comment scanning', () => { + it('does not repeatedly match a suffix with no comment closer', () => { + const codec = createRichMarkdownEditorCodec(key) + const input = `prefix ${' after ')} after '])( + 'keeps protected or incomplete text literal: %j', + (input) => { + expect(encodeRawMarkdownHtmlForRichEditor(input, createRichMarkdownEditorCodec(key))).toBe( + input + ) + } + ) + it('retains the existing block-only handling of an overlapping marker', () => { + const codec = createRichMarkdownEditorCodec(key) + expect(encodeRawMarkdownHtmlForRichEditor('', codec)).toBe( + codec.transport.create('block-html', '') + ) + }) +}) diff --git a/src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts b/src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts new file mode 100644 index 00000000000..1ee24f73003 --- /dev/null +++ b/src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts @@ -0,0 +1,40 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +function encodeWithDeadline(content: string): string { + // Interrupt a quadratic regression without hanging the synchronous test worker. + return runInNewContext( + 'encode(content, codec)', + { + encode: encodeRawMarkdownHtmlForRichEditor, + content, + codec: createRichMarkdownEditorCodec('0'.repeat(32)) + }, + { timeout: 1500 } + ) +} + +describe('rich Markdown blank-run encoding', () => { + it('preserves a long blank document without repeatedly searching its suffix', () => { + const content = '\n'.repeat(100_000) + expect(encodeWithDeadline(content)).toBe(content) + }) + + it('inlines a reference definition after a long blank prefix without rescanning it', () => { + const blankPrefix = '\n'.repeat(100_000) + const content = `${blankPrefix}[Docs]\n\n[docs]: https://example.com/docs\n` + expect(encodeWithDeadline(content)).toBe(`${blankPrefix}[Docs](https://example.com/docs)\n\n`) + }) + + it('preserves code and surrounding HTML after a long blank prefix', () => { + const blankPrefix = '\n'.repeat(100_000) + const content = '```\n
inside
\n```\nafter' + const codec = createRichMarkdownEditorCodec('0'.repeat(32)) + const expected = encodeRawMarkdownHtmlForRichEditor(content, codec) + expect(expected).toContain('
inside
') + expect(expected).not.toContain('after') + expect(encodeWithDeadline(blankPrefix + content)).toBe(blankPrefix + expected) + }) +}) diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index cde5311e80b..1262d5254ab 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -59,20 +59,27 @@ export function encodeRawMarkdownHtmlForRichEditor( { htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {} ): string { const normalizedContent = normalizeMarkdownReferenceLinks(content) + const lastCommentClose = normalizedContent.lastIndexOf('-->') const { transport } = codec let index = 0 let isLineStart = true let activeFence: '`' | '~' | null = null let activeFenceLength = 0 let result = '' + const nonWhitespace = /\S/g + const fencePrefix = /(`{3,}|~{3,})/y + let fenceProbe = -1 + let fenceMatch: RegExpExecArray | null = null while (index < normalizedContent.length) { if (isLineStart) { - // Why: only line starts inspect the rest of the line, so slicing the suffix on every - // character (one throwaway string per char) is pure waste — compute it here. On a large - // doc this drops O(n) suffix allocations from the rich-editor open path (#7056). - const lineRest = normalizedContent.slice(index) - const fenceMatch = lineRest.match(/^\s*(`{3,}|~{3,})/) + // Reuse the lookahead across blank lines, preserving cross-line fence semantics. + if (index > fenceProbe) { + nonWhitespace.lastIndex = index + fenceProbe = nonWhitespace.exec(normalizedContent)?.index ?? normalizedContent.length + fencePrefix.lastIndex = fenceProbe + fenceMatch = fencePrefix.exec(normalizedContent) + } if (fenceMatch) { const fenceChar = fenceMatch[1][0] as '`' | '~' const fenceLength = fenceMatch[1].length @@ -175,7 +182,11 @@ export function encodeRawMarkdownHtmlForRichEditor( continue } } - const inlineHtml = matchInlineHtml(normalizedContent.slice(index)) + // An unterminated comment cannot match; later tags must still be encoded. + const inlineHtml = + normalizedContent.startsWith('/g, ' ') + .split('\n') + .map((line) => + line + .replace(/^[\s>#*\-_`]+/, '') + .replace(/\s+/g, ' ') + .trim() + ) + .find((line) => line.length > 0) + if (!line) { + return 'comment' + } + return `comment — ${line.length > 72 ? `${line.slice(0, 71).trimEnd()}…` : line}` +} +afterEach(() => vi.restoreAllMocks()) + +describe('review acknowledgement summary', () => { + it.each([ + '', + '\n\r\n\t', + '# > ** _ - `\n\nReadable', + '\n## Hello\r\nignored', + 'onetwo\nignored', + 'visible -->', + 'a\rb\nc', + '\u00a0##\u2028Hello\u2029world', + 'a'.repeat(71), + 'a'.repeat(72), + 'a'.repeat(73), + `${'a'.repeat(70)} b`, + `${'a'.repeat(70)}😀tail`, + '\n\n', + 'first\n\nlast' + ])('preserves the existing label for %j', (body) => { + expect(describePRCommentAckTarget(comment(body))).toBe(originalSummary(body)) + }) + + it('skips tail normalization and full-document line splitting', () => { + const input = comment(`## Heading\n${'tail with whitespace\n'.repeat(10_000)}`) + const replace = vi.spyOn(String.prototype, 'replace') + const split = vi.spyOn(String.prototype, 'split') + const actual = describePRCommentAckTarget(input) + const replaces = replace.mock.calls.length + const splits = split.mock.calls.length + expect(actual).toBe('comment — Heading') + expect(replaces).toBe(3) + expect(splits).toBe(0) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts b/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts index 5122a653ef9..5a12b6bc17a 100644 --- a/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts +++ b/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts @@ -24,22 +24,26 @@ const ACK_SNIPPET_MAX_LENGTH = 72 /** First readable line of a comment body, minus HTML comments and markdown markers. */ function summarizePRCommentBody(body: string): string { - const line = body - .replace(//g, ' ') - .split('\n') - .map((candidate) => - candidate - .replace(/^[\s>#*\-_`]+/, '') - .replace(/\s+/g, ' ') - .trim() - ) - .find((candidate) => candidate.length > 0) - if (!line) { - return '' + const cleaned = body.replace(//g, ' ') + let start = 0 + while (start <= cleaned.length) { + const newline = cleaned.indexOf('\n', start) + const line = cleaned + .slice(start, newline === -1 ? cleaned.length : newline) + .replace(/^[\s>#*\-_`]+/, '') + .replace(/\s+/g, ' ') + .trim() + if (line) { + return line.length > ACK_SNIPPET_MAX_LENGTH + ? `${line.slice(0, ACK_SNIPPET_MAX_LENGTH - 1).trimEnd()}…` + : line + } + if (newline === -1) { + break + } + start = newline + 1 } - return line.length > ACK_SNIPPET_MAX_LENGTH - ? `${line.slice(0, ACK_SNIPPET_MAX_LENGTH - 1).trimEnd()}…` - : line + return '' } /** Short "what this was" label so the batched reply names each item without quoting it whole. */ diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index 94854bb623d..07bf5bfb54b 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -28,7 +28,7 @@ import { } from '@/store/slices/runtime-environment-ssh' import { isConnectedRuntimeHostState, - runtimeHostConnectionState + runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' type RepositoryHostSetupsSectionProps = { @@ -227,14 +227,7 @@ export function RepositoryHostSetupsSection({ ? runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId) : undefined const runtimeOwnerState = runtimeOwnerEnvironmentId - ? runtimeHostConnectionState({ - hasStatusEntry: Boolean(runtimeOwnerStatusEntry), - status: runtimeOwnerStatusEntry?.status, - remoteControl: - runtimeOwnerStatusEntry?.remoteControl ?? - runtimeOwnerStatusEntry?.status?.remoteControl ?? - null - }) + ? runtimeHostConnectionStateForEntry(runtimeOwnerStatusEntry) : null const runtimeOwnerReachable = runtimeOwnerState === null || isConnectedRuntimeHostState(runtimeOwnerState) diff --git a/src/renderer/src/components/settings/use-runtime-environment-catalog.ts b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts index a4acfb2b121..5470bf5b6e4 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-catalog.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts @@ -51,10 +51,7 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { // linger in the sidebar registry. useAppStore.getState().setRuntimeEnvironments(nextEnvironments) if (verified) { - useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, { - status: verified.runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() } if (mountedRef.current) { setEnvironments(visibleEnvironments) @@ -93,10 +90,7 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { const runtimeStatus = unwrapRuntimeRpcResult(response) // Why: feed the live status into the store so sidebar host pickers // reflect manual refreshes, not just the settings pane. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (!mountedRef.current) { return } @@ -114,11 +108,7 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { // Why: record the failed probe (null status) so the sidebar can // distinguish unreachable from never-checked. const remoteControl = extractRuntimeTransportDiagnostics(error) - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (!mountedRef.current) { return } diff --git a/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts index 062fde45ff4..2174c3633c6 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts @@ -40,14 +40,7 @@ export function useRuntimeEnvironmentConnectionActions({ await window.api.runtimeEnvironments.disconnect({ selector: environment.id }) // Why: disconnect is non-destructive; keep the saved server but show the // user that this live client is no longer attached to it. - useAppStore.getState().setRuntimeEnvironmentStatus( - environment.id, - { - status: null, - checkedAt: Date.now() - }, - { suppressDisconnectToast: true } - ) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, @@ -96,10 +89,7 @@ export function useRuntimeEnvironmentConnectionActions({ const compatibility = evaluateHostDetails(runtimeStatus) // Why: row Connect is reachability only. The Advanced selector is the // explicit default-host control and should be the only active-server path. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, @@ -143,11 +133,7 @@ export function useRuntimeEnvironmentConnectionActions({ } catch (error) { const message = error instanceof Error ? error.message : 'Failed to connect server.' const remoteControl = extractRuntimeTransportDiagnostics(error) - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index 03658792d94..1c6ba1ba75a 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -68,7 +68,7 @@ export function AddRemoteHostDialog({ const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) const recordSshRepoReadoptions = useAppStore((s) => s.recordSshRepoReadoptions) const setRuntimeEnvironments = useAppStore((s) => s.setRuntimeEnvironments) - const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) + const readRuntimeHostStatusSnapshots = useAppStore((s) => s.readRuntimeHostStatusSnapshots) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const busy = isSaving || isBulkImporting || resolvingConfigAlias !== null @@ -283,10 +283,7 @@ export function AddRemoteHostDialog({ } const environments = await window.api.runtimeEnvironments.list() setRuntimeEnvironments(environments) - setRuntimeEnvironmentStatus(result.environment.id, { - status: result.runtimeStatus, - checkedAt: Date.now() - }) + await readRuntimeHostStatusSnapshots() toast.success( translate('auto.components.sidebar.AddRemoteHostDialog.serverSaved', 'Remote server added.') ) diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx index 3aead74fd81..31d2fab5b91 100644 --- a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx +++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx @@ -141,13 +141,10 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS selector: parsed.environmentId, timeoutMs: 10_000 }) - const runtimeStatus = unwrapRuntimeRpcResult(response) + unwrapRuntimeRpcResult(response) // Why: feed the probe result into the shared store so the host header and // other host pickers reflect this check without a separate fetch. - useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { - status: runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() toast.success( translate( 'auto.components.sidebar.HostSectionHeaderMenu.7f1a2b3c4d', @@ -160,10 +157,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS } catch (err) { // Why: record the failed probe so the host registry can drop a previously // healthy verdict instead of showing stale "compatible" state. - useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { - status: null, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() toast.error( err instanceof Error ? err.message diff --git a/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx b/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx index 46bf7cd371f..efd47b976ec 100644 --- a/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx +++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx @@ -83,7 +83,8 @@ describe('NoticeHostGlyph', () => { ) }) - it('marks a paired runtime with no live status as disconnected', async () => { + it('marks a paired runtime a probe found unreachable as disconnected', async () => { + runtimeStatusByEnvironmentId.set('openclaw-env', { status: null }) const container = await render('runtime:openclaw-env') expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe( @@ -91,6 +92,17 @@ describe('NoticeHostGlyph', () => { ) }) + it('does not call a host disconnected before its first probe answers', async () => { + // No entry means "not asked yet", not "asked and unreachable" — collapsing the two + // painted every remote row destructive between launch and the first probe. + const container = await render('runtime:openclaw-env') + + expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe( + 'Project on openclaw' + ) + expect(container.querySelector('svg')?.getAttribute('class')).not.toContain('text-destructive') + }) + it('gives the local host the monitor glyph the run-target rows use', async () => { const container = await render('local', 'Local Mac') diff --git a/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx index db1616203dd..7c070450e7d 100644 --- a/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx +++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx @@ -5,6 +5,10 @@ import { HostRowIcon } from '../host-row-icon' import { useAppStore } from '@/store' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' +import { + isDisconnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from '@/runtime/runtime-host-connection-state' type NoticeHostGlyphProps = { hostId: ExecutionHostId @@ -26,11 +30,15 @@ export default function NoticeHostGlyph({ keyboardFocusable }: NoticeHostGlyphProps): React.JSX.Element | null { const host = parseExecutionHostId(hostId) + // Why the shared derivation, not raw truthiness: an absent entry means "not probed yet", + // which is not the same verdict as a probe that came back unreachable. const isDisconnected = useAppStore((s) => { if (host?.kind !== 'runtime') { return false } - return !s.runtimeStatusByEnvironmentId.get(host.environmentId)?.status + return isDisconnectedRuntimeHostState( + runtimeHostConnectionStateForEntry(s.runtimeStatusByEnvironmentId.get(host.environmentId)) + ) }) if (!host) { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 1fe2f4b9530..55ef3263ad4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -218,18 +218,35 @@ describe('WorktreeCard SSH reconnect prompt', () => { expect(markup).not.toContain('Retry SSH connection') }) - it('marks a runtime-host worktree disconnected when its environment has no status', () => { + it('marks a runtime-host worktree disconnected once a probe finds it unreachable', () => { + runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] + runtimeStatusByEnvironmentId.set('env-1', { status: null }) + const runtimeRepo: Repo = { + ...makeRepo(), + connectionId: undefined, + executionHostId: 'runtime:env-1' + } + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain('Remote Mac disconnected') + }) + + // Why: "not probed yet" is not "probed and unreachable" — collapsing them painted every + // remote card destructive and dimmed between launch and the first probe answering. + it('leaves a runtime-host worktree undimmed before its first probe answers', () => { runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] const runtimeRepo: Repo = { ...makeRepo(), connectionId: undefined, executionHostId: 'runtime:env-1' } - // No status entry for env-1 → host is disconnected. const markup = renderToStaticMarkup( ) - expect(markup).toContain('Remote Mac disconnected') + expect(markup).not.toContain('Remote Mac disconnected') + expect(markup).toContain('Project on Remote Mac') + expect(markup).not.toContain('opacity-60') }) it('distinguishes connected worktrees on different Orca servers', () => { diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts index 454268c24c4..6fe8d0d5901 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -77,11 +77,10 @@ describe('sidebar host options', () => { }) expect(hosts.map((host) => host.id)).toEqual(['local', 'runtime:runtime-1']) - // Without live status the focused runtime has no proof of reachability, so it - // reads 'disconnected' rather than defaulting to 'available'/"Connected". + // A first probe still in progress is not evidence of disconnection. expect(hosts.find((host) => host.id === 'runtime:runtime-1')).toMatchObject({ detail: 'Orca server', - health: 'disconnected' + health: 'connecting' }) }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index b63b37f2926..c38f0d4e36d 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -1,9 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { - const state = { - activeWorktreeId: null as string | null, - setActiveWorktree: vi.fn(), + const state: { + activeWorktreeId: string | null + setActiveWorktree: ReturnType + shutdownWorktreeBrowsers: ReturnType + shutdownWorktreeTerminals: ReturnType + suppressPtyExit: ReturnType + consumeSuppressedPtyExit: ReturnType + tabsByWorktree: Record + ptyIdsByTabId: Record + } = { + activeWorktreeId: null, + setActiveWorktree: vi.fn((worktreeId: string | null) => { + state.activeWorktreeId = worktreeId + }), shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), suppressPtyExit: vi.fn(), @@ -33,7 +44,8 @@ vi.mock('@/store', () => ({ vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/lib/worktree-sleep-intent', () => ({ clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, - markWorktreeSleepIntent: mocks.markWorktreeSleepIntent + markWorktreeSleepIntent: mocks.markWorktreeSleepIntent, + withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() })) import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' @@ -95,19 +107,17 @@ describe('runSleepWorktree', () => { expect(activeClear).toBeLessThan(browsersCall) }) - it('marks active sleep intent before clearing the active slept worktree', async () => { + it('marks sleep intent before clearing the active slept worktree and keeps it after teardown', async () => { mocks.state.activeWorktreeId = 'wt-1' await runSleepWorktree('wt-1') expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') const markCall = mocks.markWorktreeSleepIntent.mock.invocationCallOrder[0] const activeClear = mocks.state.setActiveWorktree.mock.invocationCallOrder[0] - const terminalShutdown = mocks.state.shutdownWorktreeTerminals.mock.invocationCallOrder[0] - const clearCall = mocks.clearWorktreeSleepIntent.mock.invocationCallOrder[0] expect(markCall).toBeLessThan(activeClear) - expect(terminalShutdown).toBeLessThan(clearCall) + // Why: the marker outlives a successful sleep so mounted panes stay cold until an explicit wake. + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() }) it('preserves active row position through section-scoped sidebar row ids', async () => { @@ -181,14 +191,56 @@ describe('runSleepWorktree', () => { expect(pinnedGetBoundingClientRect).not.toHaveBeenCalled() }) - it('leaves activeWorktreeId alone when sleeping a background worktree', async () => { + it('leaves activeWorktreeId alone and marks a background worktree slept', async () => { mocks.state.activeWorktreeId = 'wt-other' await runSleepWorktree('wt-1') expect(mocks.state.setActiveWorktree).not.toHaveBeenCalled() expect(mocks.state.suppressPtyExit).not.toHaveBeenCalled() - expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalled() + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() + }) + + it('leaves a worktree the user activated mid-batch awake', async () => { + let releaseFirst: () => void = () => {} + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve + }) + ) + + const run = runSleepWorktrees(['wt-1', 'wt-2']) + await Promise.resolve() + // Why: the user clicked wt-2 while wt-1 was tearing down; sleeping it anyway + // must not leave the active workspace marked with no clear pending. + mocks.state.activeWorktreeId = 'wt-2' + releaseFirst() + await run + + expect(mocks.clearWorktreeSleepIntent).toHaveBeenLastCalledWith('wt-2') + }) + + it('marks each worktree only when its own teardown starts', async () => { + let releaseFirst: () => void = () => {} + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve + }) + ) + + const run = runSleepWorktrees(['wt-1', 'wt-2']) + await Promise.resolve() + + // Why: wt-2 is still awake while wt-1 tears down; marking it early would + // hold its panes cold and swallow its activity. + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalledWith('wt-2') + releaseFirst() + await run + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-2') }) it('surfaces a toast and skips terminals when browsers throws', async () => { diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index cf474b28e54..1e414a81800 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -1,6 +1,10 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { + clearWorktreeSleepIntent, + markWorktreeSleepIntent, + withWorktreeSleepTeardown +} from '@/lib/worktree-sleep-intent' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' @@ -141,15 +145,15 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise shutdownWorktreeBrowsers, shutdownWorktreeTerminals } = useAppStore.getState() - let activeSleepIntentWorktreeId: string | null = null - if (activeWorktreeId && worktreeIds.includes(activeWorktreeId)) { - const restoreSidebarPosition = preserveSidebarWorktreePosition(activeWorktreeId) + const sleptActiveWorktreeId = + activeWorktreeId && worktreeIds.includes(activeWorktreeId) ? activeWorktreeId : null + if (sleptActiveWorktreeId) { + const restoreSidebarPosition = preserveSidebarWorktreePosition(sleptActiveWorktreeId) // Why: clearing the active workspace can unmount TerminalPanes before - // shutdownWorktreeTerminals writes PTY suppressions. Use a non-rendering - // intent marker so those exits do not stamp activity, without inserting an - // extra Zustand update that can disturb the sidebar's scroll restoration. - markWorktreeSleepIntent(activeWorktreeId) - activeSleepIntentWorktreeId = activeWorktreeId + // shutdownWorktreeTerminals writes PTY suppressions; mark first so those + // exits do not stamp activity. Kept off the store so it cannot disturb the + // sidebar's scroll restoration. + markWorktreeSleepIntent(sleptActiveWorktreeId) setActiveWorktree(null) restoreSidebarPosition() } @@ -157,13 +161,17 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise const failedWorktreeIds = new Set() try { for (const worktreeId of worktreeIds) { + // Why: the marker outlives teardown so the panes left mounted stay cold + // until an explicit wake (#10205); mark per workspace so an earlier + // slow teardown never leaves a later, still-awake one marked. + markWorktreeSleepIntent(worktreeId) try { // Why: sleep mirrors removeWorktree's shutdown sequence — browsers first // so destroyPersistentWebview unregisters the Chromium guests before any // other teardown runs, terminals second so the PTY kill uses the same // ordering on both paths. Without the browser thunk here, sleep leaks // browserPagesByWorkspace entries and live webviews for the slept worktree. - await shutdownWorktreeBrowsers(worktreeId) + await withWorktreeSleepTeardown(worktreeId, () => shutdownWorktreeBrowsers(worktreeId)) } catch (err) { console.error('[sleep-worktree] browser shutdown failed', { worktreeId, error: err }) failedWorktreeIds.add(worktreeId) @@ -178,9 +186,15 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise // history dir (local) or relay session id (SSH); it also captures // serializer buffers into buffersByLeafId for SSH wake to reseed // scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c. - await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) - if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { - await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + await withWorktreeSleepTeardown(worktreeId, async () => { + await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) + if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { + await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + } + }) + // Why: a workspace the user activated during the batch is awake by their choice. + if (useAppStore.getState().activeWorktreeId === worktreeId) { + clearWorktreeSleepIntent(worktreeId) } } catch (err) { console.error('[sleep-worktree] terminal or host suspension failed', { @@ -192,12 +206,12 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise } } } finally { - if (activeSleepIntentWorktreeId) { - clearWorktreeSleepIntent(activeSleepIntentWorktreeId) - if (failedWorktreeIds.has(activeSleepIntentWorktreeId)) { - // Why: any failed sleep step must leave the workspace visible and retryable. - setActiveWorktree(activeSleepIntentWorktreeId) - } + // Why: a failed sleep leaves the workspace awake and retryable. + for (const worktreeId of failedWorktreeIds) { + clearWorktreeSleepIntent(worktreeId) + } + if (sleptActiveWorktreeId && failedWorktreeIds.has(sleptActiveWorktreeId)) { + setActiveWorktree(sleptActiveWorktreeId) } } if (errors.length > 0) { diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index ffebad8cf75..012012eb53b 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -188,9 +188,9 @@ export function sortWorktreesSmart( // Why: `tabHasLivePty` (over `ptyIdsByTabId`) is the source of truth for // liveness — slept terminals retain `tab.ptyId` as a wake hint, so reading // it directly would falsely keep cold-start ordering off after restart. - const hasAnyLivePty = Object.values(tabsByWorktree) - .flat() - .some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) + const hasAnyLivePty = Object.values(tabsByWorktree).some((tabs) => + tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) + ) const now = Date.now() const labels = buildWorktreeSortLabels(worktrees) diff --git a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts index fc339bbe33f..d8ec20bb45a 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts @@ -10,6 +10,10 @@ import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-ov import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' +import { + isDisconnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from '@/runtime/runtime-host-connection-state' import { useAppStore } from '@/store' import { selectRuntimeAwareSshStatus, @@ -177,12 +181,17 @@ export function useWorktreeCardFoundation({ const runtimeHostLabel = runtimeHostId ? (getHostDisplayLabelOverrides(settings).get(runtimeHostId) ?? runtimeEnvironmentName) : null - // Why: runtime ("Orca server") hosts get the same disconnected dimming as SSH when their environment has no live status. + // Why the shared derivation, not raw truthiness: an absent entry means "not probed yet", + // which is not the same verdict as a probe that came back unreachable. const isRuntimeDisconnected = useAppStore((s) => { if (!runtimeOwnerEnvironmentId) { return false } - return !s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status + return isDisconnectedRuntimeHostState( + runtimeHostConnectionStateForEntry( + s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId) + ) + ) }) const [titleRenaming, setTitleRenaming] = useState(false) const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false) diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index a045a7b2d32..baf68056993 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -53,7 +53,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): // Why: the live data attribute updates before React state during navigation, // so it must own the complete active style without stale utility classes. isLineageDropTarget - ? 'border border-accent-foreground/20 bg-accent/80' + ? 'border border-worktree-sidebar-foreground/40 bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground ring-1 ring-inset ring-worktree-sidebar-ring/60' : isActiveSurface ? 'border border-transparent' : isMultiSelected @@ -72,7 +72,10 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): isRuntimeDisconnected && !isDeleting && 'opacity-60' )} data-worktree-card-surface="true" - data-worktree-card-active={isActiveSurface ? activeSurfaceVariant : undefined} + data-worktree-card-active={ + isActiveSurface && !isLineageDropTarget ? activeSurfaceVariant : undefined + } + data-worktree-lineage-drop-target={isLineageDropTarget || undefined} onClick={handleClick} onDoubleClick={affiliateListMode ? undefined : handleDoubleClick} draggable={!affiliateListMode && nativeDragEnabled && !isDeleting && !titleRenaming} diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts index dc8785f7b84..ba74bec19e1 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -14,25 +14,41 @@ describe('isWorktreeLineageDropZoneHit', () => { it('keeps the top and bottom of a card available for reorder drops', () => { const rect = { top: 100, bottom: 200 } as DOMRect - expect(isWorktreeLineageDropZoneHit({ pointerY: 120, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 107, rect })).toBe(false) expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true) - expect(isWorktreeLineageDropZoneHit({ pointerY: 180, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 193, rect })).toBe(false) }) - it('caps the parent-drop band on tall cards', () => { + it('makes most of tall cards available for nesting', () => { const rect = { top: 0, bottom: 180 } as DOMRect - expect(isWorktreeLineageDropZoneHit({ pointerY: 67, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 8, rect })).toBe(true) expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true) - expect(isWorktreeLineageDropZoneHit({ pointerY: 113, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 172, rect })).toBe(true) + }) + + it('scales down reorder gutters for compact cards', () => { + const rect = { top: 100, bottom: 120 } + + expect(isWorktreeLineageDropZoneHit({ pointerY: 103, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 104, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 116, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 117, rect })).toBe(false) + }) + + it.each([ + { top: 100, bottom: 100 }, + { top: 100, bottom: 90 } + ])('rejects empty and inverted rectangles', (rect) => { + expect(isWorktreeLineageDropZoneHit({ pointerY: 100, rect })).toBe(false) }) }) describe('getWorktreeLineageDropTargetId', () => { - it('returns the row id only when the pointer is in the card content middle band', () => { + it('returns the row id only when the pointer is away from the reorder gutters', () => { const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) - expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 107 })).toBeNull() expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') }) @@ -60,6 +76,62 @@ describe('getWorktreeLineageDropTargetId', () => { expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') } ) + + it('accepts horizontal card padding outside the content element', () => { + const { container } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) + const row = container.firstElementChild! + const padding = document.createElement('div') + row.append(padding) + + expect(getWorktreeLineageDropTargetId({ container, target: padding, pointerY: 120 })).toBe( + 'parent' + ) + expect(getWorktreeLineageDropTargetId({ container, target: row, pointerY: 180 })).toBe('parent') + expect(getWorktreeLineageDropTargetId({ container, target: padding, pointerY: 201 })).toBeNull() + }) + + it.each([true, false])( + 'keeps descendants out of the ancestor hit zone (inline content: %s)', + (insideParentContent) => { + const { container, target } = makeTarget({ + worktreeId: 'parent', + top: 100, + bottom: insideParentContent ? 300 : 200 + }) + const child = makeTarget({ worktreeId: 'child', top: 220, bottom: 300 }) + const parentContent = target.closest('[data-worktree-card-parent-content]')! + const children = document.createElement('div') + children.append(child.container.firstElementChild!) + const childrenHost = insideParentContent ? parentContent : container.firstElementChild! + childrenHost.append(children) + const childRow = children.firstElementChild as HTMLElement + childRow.getBoundingClientRect = () => ({ top: 220, bottom: 300 }) as DOMRect + + expect( + getWorktreeLineageDropTargetId({ container, target: child.target, pointerY: 250 }) + ).toBe('child') + expect(getWorktreeLineageDropTargetId({ container, target: childRow, pointerY: 250 })).toBe( + 'child' + ) + expect( + getWorktreeLineageDropTargetId({ container, target: children, pointerY: 250 }) + ).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBe('parent') + } + ) + + it('does not use a descendant content element for a row without its own content', () => { + const { container, target } = makeTarget({ worktreeId: 'child', top: 100, bottom: 200 }) + const parentRow = document.createElement('div') + parentRow.setAttribute('data-worktree-drag-id', 'parent') + parentRow.append(container.firstElementChild!) + container.append(parentRow) + + expect( + getWorktreeLineageDropTargetId({ container, target: parentRow, pointerY: 150 }) + ).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('child') + }) }) describe('getReorderedWorktreeIdsToUnnest', () => { diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts index a67c7ab3e43..9ef4e069dc2 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -5,8 +5,8 @@ import { getLineageRenderInfo } from './worktree-lineage-projection' const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-parent-content]' const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' -const LINEAGE_DROP_ZONE_RATIO = 0.4 -const LINEAGE_DROP_ZONE_MAX_HEIGHT_PX = 44 +const REORDER_GUTTER_RATIO = 0.2 +const REORDER_GUTTER_MAX_HEIGHT_PX = 8 type VerticalRect = Pick @@ -19,9 +19,9 @@ export function isWorktreeLineageDropZoneHit(args: { return false } - const zoneHeight = Math.min(height * LINEAGE_DROP_ZONE_RATIO, LINEAGE_DROP_ZONE_MAX_HEIGHT_PX) - const zoneTop = args.rect.top + (height - zoneHeight) / 2 - const zoneBottom = args.rect.bottom - (height - zoneHeight) / 2 + const gutterHeight = Math.min(height * REORDER_GUTTER_RATIO, REORDER_GUTTER_MAX_HEIGHT_PX) + const zoneTop = args.rect.top + gutterHeight + const zoneBottom = args.rect.bottom - gutterHeight return args.pointerY >= zoneTop && args.pointerY <= zoneBottom } @@ -30,26 +30,31 @@ export function getWorktreeLineageDropTargetId(args: { target: Element pointerY: number }): string | null { - const contentTarget = args.target.closest(WORKTREE_CARD_CONTENT_TARGET_SELECTOR) - if (!contentTarget || !args.container.contains(contentTarget)) { + const rowTarget = args.target.closest(WORKTREE_DRAG_ROW_SELECTOR) + if (!rowTarget || !args.container.contains(rowTarget)) { return null } - // Why: nesting should be deliberate; the top/bottom of a card stays available - // for reorder drops instead of treating the whole card as a parent target. + const contentTarget = rowTarget.querySelector(WORKTREE_CARD_CONTENT_TARGET_SELECTOR) + if (!contentTarget || contentTarget.closest(WORKTREE_DRAG_ROW_SELECTOR) !== rowTarget) { + return null + } + + const rect = contentTarget.getBoundingClientRect() + // Legacy cards include descendants inside parent content; keep their rows out of its hit zone. + const firstChildRow = contentTarget.querySelector(WORKTREE_DRAG_ROW_SELECTOR) + const bottom = firstChildRow + ? Math.min(rect.bottom, firstChildRow.getBoundingClientRect().top) + : rect.bottom if ( !isWorktreeLineageDropZoneHit({ pointerY: args.pointerY, - rect: contentTarget.getBoundingClientRect() + rect: { top: rect.top, bottom } }) ) { return null } - const rowTarget = contentTarget.closest(WORKTREE_DRAG_ROW_SELECTOR) - if (!rowTarget || !args.container.contains(rowTarget)) { - return null - } return rowTarget.getAttribute('data-worktree-drag-id') } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts new file mode 100644 index 00000000000..437891d6f69 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts @@ -0,0 +1,259 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { useWorktreeDragRuntime } from './use-runtime' +import { useWorktreePointerDragWindowEvents } from './use-pointer-window-events' +import { commitWorktreePointerDrop } from './pointer-commit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushWorktreePointerDragFrame, type WorktreePointerDragFrameArgs } from './pointer-flush' +import { NO_WORKTREE_SIDEBAR_DROP_TARGET, WORKTREE_ROW_DRAG_INITIAL_STATE } from './row-state' + +vi.mock('../../workspace-kanban-sidebar-drop', () => ({ + clearWorkspaceKanbanSidebarDropTargetVisual: vi.fn(), + hasWorkspaceKanbanSidebarDropBoard: () => true, + isWorkspaceKanbanSidebarDropPointInBoard: () => false, + updateWorkspaceKanbanSidebarDropTargetVisual: () => ({ status: null, isPinDrop: false }) +})) + +vi.mock('./pointer-commit', () => ({ commitWorktreePointerDrop: vi.fn() })) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function setup() { + let time = 0 + let nextFrame: FrameRequestCallback | null = null + vi.spyOn(performance, 'now').mockImplementation(() => time) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + nextFrame = callback + return 1 + }) + let state = WORKTREE_ROW_DRAG_INITIAL_STATE + let target = NO_WORKTREE_SIDEBAR_DROP_TARGET + const offsets = new Map([['parent', 56]]) + const args: WorktreePointerDragFrameArgs = { + drag: { + pointerId: 1, + sourceRow: document.createElement('div'), + startX: 100, + startY: 400, + currentX: 100, + currentY: 300, + worktreeId: 'child', + draggedIds: ['child'], + reorderDraggedIds: ['child'], + reorderUnitDraggedIds: ['child'], + sourceGroupKey: 'repo', + rects: [], + active: true, + preview: document.createElement('div'), + previewOffsetX: 20, + previewOffsetY: 20, + workspaceBoardDragPreviewRequested: false, + frameId: null, + reorderIntent: null, + latestBoardDropTarget: null, + latestStatusDropTarget: null + }, + ctx: { + scrollRef: { current: null }, + workspaceStatuses: [], + worktreeDragGroups: [], + worktreeDragUnitGroups: [], + refreshWorktreeDragSession: () => true, + getEligibleLineageDropTarget: () => target, + computeWorktreeDrop: () => ({ + dropIndex: 1, + dropIndicatorY: 300, + dropAnchorId: 'parent', + previewOffsetsByWorktreeId: offsets + }), + computeWorktreeStatusDrop: () => null, + commitWorktreeLineageParentDrop: () => true, + clearReorderedWorktreeParents: vi.fn(), + clearWorktreeDrag: vi.fn(), + onMoveWorktreesToStatus: vi.fn(), + onMoveWorktreesToStatusAtIndex: vi.fn(), + onReorderWorktrees: vi.fn(), + onPinWorktrees: vi.fn() + }, + workspaceBoardOpen: false, + onWorkspaceBoardDragPreviewStart: vi.fn(), + onWorkspaceBoardDragPreviewCommit: vi.fn(), + shouldShowWorkspaceBoardDropIndicator: () => false, + setDragOverStatus: vi.fn(), + setPinDragOver: vi.fn(), + setWorktreeDragState: (update) => { + state = typeof update === 'function' ? update(state) : update + } + } + return { + args, + offsets, + state: () => state, + nest: (id: string | null) => { + target = { ...NO_WORKTREE_SIDEBAR_DROP_TARGET, lineageParentId: id } + }, + tick: (ms: number) => { + time += ms + const callback = nextFrame + nextFrame = null + callback?.(time) + } + } +} + +describe('combined nesting and animated reordering', () => { + it('lets the pointer cross an edge into nesting without moving the destination', () => { + const t = setup() + flushWorktreePointerDragFrame(t.args) + expect(t.state().previewOffsetsByWorktreeId.size).toBe(0) + t.nest('parent') + t.tick(80) + expect(t.state().lineageDropTargetId).toBe('parent') + expect(t.state().previewOffsetsByWorktreeId.size).toBe(0) + t.tick(200) + expect(t.state().lineageDropTargetId).toBe('parent') + expect(t.state().dropIndicatorY).toBeNull() + }) + + it('opens the reorder gap, holds it during nesting, then restores the edge preview', () => { + const t = setup() + flushWorktreePointerDragFrame(t.args) + t.tick(160) + expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) + expect(t.state().dropIndicatorY).toBe(300) + t.nest('parent') + flushWorktreePointerDragFrame(t.args) + expect(t.state().lineageDropTargetId).toBe('parent') + expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) + expect(t.state().dropIndicatorY).toBeNull() + t.nest(null) + flushWorktreePointerDragFrame(t.args) + t.tick(160) + expect(t.state().lineageDropTargetId).toBeNull() + expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) + expect(t.state().dropIndicatorY).toBe(300) + }) + + it('clears the old line during a new intent and stops scheduling after settling', () => { + const t = setup() + flushWorktreePointerDragFrame(t.args) + t.tick(160) + expect(t.state().dropIndicatorY).toBe(300) + t.args.drag.currentY = 356 + t.args.ctx.computeWorktreeDrop = () => ({ + dropIndex: 2, + dropIndicatorY: 356, + dropAnchorId: null, + previewOffsetsByWorktreeId: t.offsets + }) + flushWorktreePointerDragFrame(t.args) + expect(t.state().dropIndicatorY).toBeNull() + expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) + t.tick(160) + expect(t.state().dropIndicatorY).toBe(356) + const frames = vi.mocked(window.requestAnimationFrame).mock.calls.length + t.tick(1000) + expect(vi.mocked(window.requestAnimationFrame).mock.calls).toHaveLength(frames) + }) +}) + +describe('stationary pointer autoscroll', () => { + it.each([1, -1])('keeps the gap tracking slots while scrolling in direction %s', (direction) => { + const t = setup() + const container = document.createElement('div') + t.args.ctx.scrollRef.current = container + let index = 10 + let offsets = new Map([['parent', 56]]) + t.args.ctx.computeWorktreeDrop = () => ({ + dropIndex: index, + dropIndicatorY: 300 - container.scrollTop, + dropAnchorId: null, + previewOffsetsByWorktreeId: offsets + }) + flushWorktreePointerDragFrame(t.args) + for (let frame = 1; frame <= 6; frame++) { + index += direction + container.scrollTop += direction * 56 + offsets = new Map([[`row-${index}`, direction * 56]]) + t.tick(80) + flushWorktreePointerDragFrame(t.args) + if (frame >= 2) { + expect(t.state().previewOffsetsByWorktreeId).toBe(offsets) + expect(t.state().dropIndicatorY).toBe(300 - container.scrollTop) + } + } + expect(t.args.drag.currentY).toBe(300) + }) +}) + +describe('Escape during pointer dragging', () => { + function renderDrag() { + const t = setup() + const cancelBoard = vi.fn() + const { result, unmount } = renderHook(() => { + const runtime = useWorktreeDragRuntime({ + worktreeDragSessionRef: { current: null }, + statusDropAnchorsRef: { current: new Map() }, + onWorkspaceBoardDragPreviewCancel: cancelBoard + }) + useWorktreePointerDragWindowEvents({ + ctx: t.args.ctx, + runtime, + beginWorktreePointerDrag: vi.fn(), + scheduleWorktreePointerDragFrame: vi.fn(), + onWorkspaceBoardDragPreviewCommit: vi.fn(), + onDropWorktreesOnWorkspaceBoard: vi.fn() + }) + return runtime + }) + result.current.worktreePointerDragRef.current = t.args.drag + if (t.args.drag.preview) { + document.body.append(t.args.drag.preview) + } + return { ...t, result, unmount, cancelBoard } + } + + it('removes the preview and cancels frames without committing on pointer release', () => { + const t = renderDrag() + const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame') + t.args.drag.frameId = 12 + t.result.current.pointerAutoscrollFrameIdRef.current = 13 + const escape = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + act(() => window.dispatchEvent(escape)) + expect(escape.defaultPrevented).toBe(true) + expect(t.result.current.worktreePointerDragRef.current).toBeNull() + expect(t.args.drag.preview?.isConnected).toBe(false) + expect(cancelFrame).toHaveBeenCalledWith(12) + expect(cancelFrame).toHaveBeenCalledWith(13) + expect(t.cancelBoard).toHaveBeenCalledOnce() + window.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 })) + expect(commitWorktreePointerDrop).not.toHaveBeenCalled() + expect(t.result.current.worktreeDragState).toBe(WORKTREE_ROW_DRAG_INITIAL_STATE) + }) + + it('leaves other keys and Escape without a drag available to the app', () => { + const t = renderDrag() + const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }) + window.dispatchEvent(enter) + expect(enter.defaultPrevented).toBe(false) + expect(t.result.current.worktreePointerDragRef.current).toBe(t.args.drag) + act(() => t.result.current.clearWorktreeDrag()) + const escape = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + window.dispatchEvent(escape) + expect(escape.defaultPrevented).toBe(false) + expect(t.cancelBoard).toHaveBeenCalledOnce() + }) + + it('removes its Escape listener on unmount', () => { + const t = renderDrag() + t.unmount() + const escape = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + window.dispatchEvent(escape) + expect(escape.defaultPrevented).toBe(false) + expect(t.cancelBoard).not.toHaveBeenCalled() + t.args.drag.preview?.remove() + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts index 3baf8e81762..0c9017fc0f5 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts @@ -11,6 +11,7 @@ import { getPointerDropStatusTarget, shouldPreferSidebarStatusDropTarget } from import type { WorktreeDropCommitContext } from './drop-commit-context' import { applyWorktreeDropPreview, + applyWorktreeLineageDropPreview, clearWorktreeDropPreview, NO_WORKTREE_SIDEBAR_DROP_TARGET, updateLatestWorktreeStatusDropTarget, @@ -19,6 +20,8 @@ import { type WorktreeSidebarLineageDropTarget } from './row-state' +const REORDER_INTENT_DELAY_MS = 160 + export type WorktreePointerDragFrameArgs = { drag: WorktreePointerDrag ctx: WorktreeDropCommitContext @@ -83,6 +86,7 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs if (!drag.active || !drag.preview) { return } + delete drag.preview.dataset.worktreeSidebarNesting updateSidebarDragPreviewPosition({ preview: drag.preview, pointerX: drag.currentX, @@ -121,6 +125,7 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs args.onWorkspaceBoardDragPreviewCommit() } if (boardTarget.status || boardTarget.isPinDrop) { + drag.reorderIntent = null drag.latestStatusDropTarget = null clearInsertionLine(args) return @@ -137,10 +142,24 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs : NO_WORKTREE_SIDEBAR_DROP_TARGET, drag.draggedIds ) - if (preferredStatusTarget.lineageParentId) { + const lineageParentId = preferredStatusTarget.lineageParentId + if (lineageParentId) { + drag.reorderIntent = null updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null) clearWorkspaceKanbanSidebarDropTargetVisual() - clearInsertionLine(args) + drag.preview.dataset.worktreeSidebarNesting = 'true' + updateSidebarDragPreviewPosition({ + preview: drag.preview, + pointerX: drag.currentX, + pointerY: drag.currentY, + offsetX: drag.previewOffsetX, + offsetY: drag.previewOffsetY + }) + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + applyWorktreeLineageDropPreview(prev, lineageParentId, drag.currentY) + ) return } if ( @@ -150,15 +169,42 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs workspaceStatuses: ctx.workspaceStatuses }) ) { + drag.reorderIntent = null showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) return } const drop = ctx.computeWorktreeDrop(drag.currentY) if (!drop) { + drag.reorderIntent = null showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) return } + // Let the pointer cross a reorder gutter into the card before moving its target. + let intent = drag.reorderIntent + if (!intent || (intent.dropIndex !== drop.dropIndex && intent.pointerY !== drag.currentY)) { + intent = { + dropIndex: drop.dropIndex, + pointerY: drag.currentY, + startedAt: performance.now() + } + } else { + // Autoscroll changes slots beneath a stationary pointer without renewing intent. + intent.dropIndex = drop.dropIndex + intent.pointerY = drag.currentY + } + drag.reorderIntent = intent + if (performance.now() - intent.startedAt < REORDER_INTENT_DELAY_MS) { + drag.latestStatusDropTarget = null + args.setWorktreeDragState((prev) => + clearWorktreeDropPreview(prev, { + pointerY: drag.currentY, + preserveOffsets: true + }) + ) + drag.frameId = window.requestAnimationFrame(() => flushWorktreePointerDragFrame(args)) + return + } drag.latestStatusDropTarget = null clearWorkspaceKanbanSidebarDropTargetVisual() args.setDragOverStatus(null) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts new file mode 100644 index 00000000000..f6b21d04667 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + applyWorktreeDropPreview, + applyWorktreeLineageDropPreview, + clearWorktreeDropPreview, + WORKTREE_ROW_DRAG_INITIAL_STATE +} from './row-state' + +const reorderPreview = { + dropAnchorId: 'parent', + dropIndex: 2, + dropIndicatorY: 180, + previewOffsetsByWorktreeId: new Map([['parent', -60]]) +} + +function reorderedState() { + return applyWorktreeDropPreview( + { ...WORKTREE_ROW_DRAG_INITIAL_STATE, draggingWorktreeId: 'child' }, + reorderPreview, + { pointerY: 150, matchPointerY: true } + ) +} + +describe('lineage drop preview', () => { + it('keeps the hovered card in place while replacing the reorder line with nesting', () => { + const before = reorderedState() + const nesting = applyWorktreeLineageDropPreview(before, 'parent', 150) + expect(nesting.previewOffsetsByWorktreeId).toBe(before.previewOffsetsByWorktreeId) + expect(nesting.dropIndex).toBeNull() + expect(nesting.dropIndicatorY).toBeNull() + expect(nesting.lineageDropTargetId).toBe('parent') + expect(applyWorktreeLineageDropPreview(nesting, 'parent', 150)).toBe(nesting) + }) + + it('repaints a target change even when the pointer stays at the same height', () => { + const before = applyWorktreeLineageDropPreview(reorderedState(), 'parent', 150) + const after = applyWorktreeLineageDropPreview(before, 'other-parent', 150) + expect(after).not.toBe(before) + expect(after.lineageDropTargetId).toBe('other-parent') + }) + + it('clears nesting feedback when returning to a reorder edge at the same height', () => { + const before = applyWorktreeLineageDropPreview(reorderedState(), 'parent', 150) + const after = applyWorktreeDropPreview(before, reorderPreview, { + pointerY: 150, + matchPointerY: true + }) + expect(after.lineageDropTargetId).toBeNull() + expect(after.dropIndicatorY).toBe(180) + }) + + it('clears nesting when leaving the sidebar even without pointer Y movement or offsets', () => { + const before = applyWorktreeLineageDropPreview(WORKTREE_ROW_DRAG_INITIAL_STATE, 'parent', 150) + const after = clearWorktreeDropPreview(before, { pointerY: 150, matchPointerY: true }) + expect(after).not.toBe(before) + expect(after.lineageDropTargetId).toBeNull() + expect(after.previewOffsetsByWorktreeId.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts index 480f78d0a6e..1fd257df808 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts @@ -13,6 +13,7 @@ export type WorktreeRowDragState = { dropIndicatorY: number | null previewOffsetsByWorktreeId: ReadonlyMap pointerY: number | null + lineageDropTargetId: string | null } export const EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS: ReadonlyMap = new Map() @@ -23,7 +24,8 @@ export const WORKTREE_ROW_DRAG_INITIAL_STATE: WorktreeRowDragState = { dropIndex: null, dropIndicatorY: null, previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null + pointerY: null, + lineageDropTargetId: null } export type WorktreePointerDrag = { @@ -45,6 +47,7 @@ export type WorktreePointerDrag = { previewOffsetY: number workspaceBoardDragPreviewRequested: boolean frameId: number | null + reorderIntent: { dropIndex: number; pointerY: number; startedAt: number } | null latestBoardDropTarget: WorkspaceKanbanCardTrackedDropTarget | null latestStatusDropTarget: WorktreeSidebarTrackedStatusDropTarget | null } @@ -97,20 +100,24 @@ export function updateLatestWorktreeStatusDropTarget( // updates deliberately keep the previous state identity in that case. export function clearWorktreeDropPreview( previous: WorktreeRowDragState, - args: { pointerY: number | null; matchPointerY?: boolean } + args: { pointerY: number | null; matchPointerY?: boolean; preserveOffsets?: boolean } ): WorktreeRowDragState { const unchanged = + previous.lineageDropTargetId === null && previous.dropIndex === null && previous.dropIndicatorY === null && - previous.previewOffsetsByWorktreeId.size === 0 && + (args.preserveOffsets === true || previous.previewOffsetsByWorktreeId.size === 0) && (args.matchPointerY !== true || previous.pointerY === args.pointerY) return unchanged ? previous : { ...previous, + lineageDropTargetId: null, dropIndex: null, dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + previewOffsetsByWorktreeId: args.preserveOffsets + ? previous.previewOffsetsByWorktreeId + : EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, pointerY: args.pointerY } } @@ -121,6 +128,7 @@ export function applyWorktreeDropPreview( args: { pointerY: number; matchPointerY?: boolean } ): WorktreeRowDragState { const unchanged = + previous.lineageDropTargetId === null && previous.dropIndex === drop.dropIndex && previous.dropIndicatorY === drop.dropIndicatorY && (args.matchPointerY !== true || previous.pointerY === args.pointerY) && @@ -128,5 +136,30 @@ export function applyWorktreeDropPreview( previous.previewOffsetsByWorktreeId, drop.previewOffsetsByWorktreeId ) - return unchanged ? previous : { ...previous, ...drop, pointerY: args.pointerY } + return unchanged + ? previous + : { ...previous, ...drop, lineageDropTargetId: null, pointerY: args.pointerY } +} + +export function applyWorktreeLineageDropPreview( + previous: WorktreeRowDragState, + lineageDropTargetId: string, + pointerY: number +): WorktreeRowDragState { + if ( + previous.lineageDropTargetId === lineageDropTargetId && + previous.dropIndex === null && + previous.dropIndicatorY === null && + previous.pointerY === pointerY + ) { + return previous + } + // Keep the target under the pointer when switching from a reorder gap to nesting. + return { + ...previous, + lineageDropTargetId, + dropIndex: null, + dropIndicatorY: null, + pointerY + } } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts index b3f7f2f54b7..e83ff97334b 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts @@ -72,6 +72,7 @@ export function useWorktreeNativeDrag(args: { setWorktreeDragState({ draggingWorktreeId: worktreeId, sourceGroupKey, + lineageDropTargetId: null, dropIndex: null, dropIndicatorY: null, previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts index 7db25f797eb..1b751a18e9b 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts @@ -136,6 +136,7 @@ export function useWorktreePointerDrag(args: { setWorktreeDragState({ draggingWorktreeId: drag.worktreeId, sourceGroupKey: drag.sourceGroupKey, + lineageDropTargetId: null, dropIndex: null, dropIndicatorY: null, previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, @@ -207,6 +208,7 @@ export function useWorktreePointerDrag(args: { previewOffsetY: 0, workspaceBoardDragPreviewRequested: false, frameId: null, + reorderIntent: null, latestBoardDropTarget: null, latestStatusDropTarget: null } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts index b2989fb06ac..fa8be3a21b6 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts @@ -78,10 +78,21 @@ export function useWorktreePointerDragWindowEvents(args: { clearWorktreeDrag() } + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !worktreePointerDragRef.current) { + return + } + event.preventDefault() + event.stopPropagation() + clearWorktreeDrag() + } + + window.addEventListener('keydown', handleKeyDown, { capture: true }) window.addEventListener('pointermove', handlePointerMove, { capture: true }) window.addEventListener('pointerup', handlePointerUp, { capture: true }) window.addEventListener('pointercancel', handlePointerCancel, { capture: true }) return () => { + window.removeEventListener('keydown', handleKeyDown, { capture: true }) window.removeEventListener('pointermove', handlePointerMove, { capture: true }) window.removeEventListener('pointerup', handlePointerUp, { capture: true }) window.removeEventListener('pointercancel', handlePointerCancel, { capture: true }) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx index 91ebb321726..775ecdaedb6 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx @@ -23,7 +23,7 @@ import type { LineageToggleHandler } from '../../worktree-lineage-toggle-handler import { stopNestedWorktreeCardBubble } from './header-event-guards' import type { WorktreeItemRow } from '../listing/renderable-rows' import { getWorktreeOptionId } from './option-dom' -import type { WorktreePointerDrag, WorktreeRowDragState } from '../drag/row-state' +import type { WorktreeRowDragState } from '../drag/row-state' export type WorktreeItemRowContext = { settings: AppState['settings'] @@ -33,7 +33,6 @@ export type WorktreeItemRowContext = { groupIndexByRowKey: ReadonlyMap agentSendTargetWorktreeId: string | null worktreeDragState: WorktreeRowDragState - worktreePointerDragRef: React.MutableRefObject nativeLineageDropTargetId: string | null activeWorktreeId: string | null activeWorkspaceExecutionHostId: ExecutionHostId | null @@ -139,8 +138,7 @@ export function renderWorktreeItemRow( const worktreeIdentity = getWorktreeHostIdentity(itemRow.worktree) const isLineageDropTarget = ctx.worktreeDragState.draggingWorktreeId && - (ctx.worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId === - itemRow.worktree.id || + (ctx.worktreeDragState.lineageDropTargetId === itemRow.worktree.id || ctx.nativeLineageDropTargetId === itemRow.worktree.id) const isActiveWorktree = ctx.activeWorktreeId === itemRow.worktree.id && diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx index 2981021c76a..2954a2ad861 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx @@ -124,6 +124,7 @@ function renderLineageGroupVirtualRow( ref={ctx.measureVirtualRowElement} className={cn( 'absolute left-0 right-0 top-0', + parent?.worktree.id === ctx.worktreeDragState.draggingWorktreeId && 'pointer-events-none', ctx.worktreeDragState.draggingWorktreeId !== null && 'transition-transform duration-150 ease-out will-change-transform' )} @@ -234,6 +235,7 @@ export function renderWorktreeVirtualRow( data-workspace-status={itemWorkspaceStatus ?? undefined} className={cn( 'absolute left-0 right-0 top-0', + row.worktree.id === ctx.worktreeDragState.draggingWorktreeId && 'pointer-events-none', ctx.worktreeDragState.draggingWorktreeId !== null && 'transition-transform duration-150 ease-out will-change-transform' )} diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index 2787b93a002..ff6572b9e51 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -117,7 +117,6 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual groupIndexByRowKey: session.groupIndexByRowKey, agentSendTargetWorktreeId: props.agentSendTargetWorktreeId, worktreeDragState: runtime.worktreeDragState, - worktreePointerDragRef: runtime.worktreePointerDragRef, nativeLineageDropTargetId: runtime.nativeLineageDropTargetId, activeWorktreeId: props.activeWorktreeId, activeWorkspaceExecutionHostId: props.activeWorkspaceExecutionHostId, diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts index 54f302f872b..1aa2f523311 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts @@ -59,8 +59,10 @@ export function updateSidebarDragPreviewPosition(args: { offsetX: number offsetY: number }): void { - const x = args.pointerX - args.offsetX - const y = args.pointerY - args.offsetY + const nesting = args.preview.hasAttribute('data-worktree-sidebar-nesting') + // Keep the destination title visible while the pointer rests inside it. + const x = nesting ? args.pointerX + 16 : args.pointerX - args.offsetX + const y = nesting ? args.pointerY + 16 : args.pointerY - args.offsetY args.preview.style.transform = `translate3d(${x}px, ${y}px, 0) scale(1.015)` } diff --git a/src/renderer/src/components/skills/skill-agent-filter.test.ts b/src/renderer/src/components/skills/skill-agent-filter.test.ts new file mode 100644 index 00000000000..da59a1002a6 --- /dev/null +++ b/src/renderer/src/components/skills/skill-agent-filter.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import type { DiscoveredSkill } from '../../../../shared/skills' +import { skillAgentOptions, skillMatchesAgent } from './skill-agent-filter' + +function skill(overrides: Partial = {}): DiscoveredSkill { + return { + id: 'id', + name: 'Review', + description: 'Code review', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/a', + directoryPath: '/a/review', + skillFilePath: '/a/review/SKILL.md', + installed: true, + updatedAt: null, + ...overrides + } +} + +/** Pre-change implementation, kept verbatim as the parity oracle. */ +function legacySkillMatchesAgent( + target: DiscoveredSkill, + agentId: string, + agentByRootPath: ReadonlyMap +): boolean { + const agents = (): string[] => { + const roots = target.rootPaths?.length ? target.rootPaths : [target.rootPath] + return [...new Set(roots.map((root) => agentByRootPath.get(root)).filter(Boolean))] as string[] + } + return agentId === 'all' || agents().includes(agentId) +} + +/** Counts ownership lookups so the before/after difference is deterministic. */ +class LookupCountingMap extends Map { + lookups = 0 + override get(key: string): string | undefined { + this.lookups += 1 + return super.get(key) + } +} + +const WINDOWS_ROOT = 'C:\\Users\\dev\\.agents\\skills' +const OPAQUE_REMOTE_ROOT = 'orca-ssh://build-box/srv/shared/.agents/skills' + +const OWNERS_WITH_EMPTY: [string, string][] = [ + ['/a', 'claude'], + ['/b', 'codex'], + ['/c', 'codex'], + ['/empty', ''], + [WINDOWS_ROOT, 'shared'], + [OPAQUE_REMOTE_ROOT, 'claude'] +] +const OWNERS_WITHOUT_EMPTY: [string, string][] = OWNERS_WITH_EMPTY.filter( + ([, owner]) => owner !== '' +).map(([root, owner]) => (root === '/a' ? [root, 'shared'] : [root, owner])) + +describe('skillMatchesAgent', () => { + it('matches the legacy agent list across sparse, repeated, and opaque roots', () => { + const rootPathsCases: (string[] | undefined)[] = [ + undefined, + [], + ['/a'], + ['/a', '/a'], + ['/a', '/b'], + ['/b', '/a'], + ['/missing'], + ['/a', '/missing'], + ['/empty'], + ['/a', '/empty'], + [WINDOWS_ROOT], + [OPAQUE_REMOTE_ROOT] + ] + const rootPathCases = ['/a', '/missing', '/empty', WINDOWS_ROOT, OPAQUE_REMOTE_ROOT] + const agentIds = ['all', 'claude', 'codex', 'shared', '', 'unknown'] + const maps = [new Map(OWNERS_WITH_EMPTY), new Map(OWNERS_WITHOUT_EMPTY)] + + let cases = 0 + for (const rootPaths of rootPathsCases) { + for (const rootPath of rootPathCases) { + for (const agentId of agentIds) { + for (const agentByRootPath of maps) { + const row = skill({ rootPath, rootPaths }) + expect({ + rootPaths, + rootPath, + agentId, + matched: skillMatchesAgent(row, agentId, agentByRootPath) + }).toEqual({ + rootPaths, + rootPath, + agentId, + matched: legacySkillMatchesAgent(row, agentId, agentByRootPath) + }) + cases += 1 + } + } + } + } + expect(cases).toBe(720) + }) + + it('treats populated rootPaths as authoritative instead of unioning rootPath', () => { + const agentByRootPath = new Map(OWNERS_WITH_EMPTY) + const row = skill({ rootPath: '/a', rootPaths: ['/b'] }) + expect(skillMatchesAgent(row, 'claude', agentByRootPath)).toBe(false) + expect(skillMatchesAgent(row, 'codex', agentByRootPath)).toBe(true) + }) + + it('falls back to rootPath only when rootPaths is absent or empty', () => { + const agentByRootPath = new Map(OWNERS_WITH_EMPTY) + expect(skillMatchesAgent(skill({ rootPaths: undefined }), 'claude', agentByRootPath)).toBe(true) + expect(skillMatchesAgent(skill({ rootPaths: [] }), 'claude', agentByRootPath)).toBe(true) + }) + + it('never lets an empty owner become a filter', () => { + const agentByRootPath = new Map(OWNERS_WITH_EMPTY) + expect(skillMatchesAgent(skill({ rootPath: '/empty' }), '', agentByRootPath)).toBe(false) + expect(skillMatchesAgent(skill({ rootPaths: ['/empty'] }), '', agentByRootPath)).toBe(false) + expect(skillMatchesAgent(skill({ rootPath: '/a' }), '', agentByRootPath)).toBe(false) + }) + + it('keeps "all" a success before any ownership lookup', () => { + const agentByRootPath = new LookupCountingMap(OWNERS_WITH_EMPTY) + expect( + skillMatchesAgent(skill({ rootPaths: ['/a', '/b', '/c'] }), 'all', agentByRootPath) + ).toBe(true) + expect(agentByRootPath.lookups).toBe(0) + }) + + it('cuts ownership lookups from 30,000 to 10,000 for 10,000 three-root rows', () => { + const rows = Array.from({ length: 10_000 }, (_, index) => + skill({ id: `skill-${index}`, rootPaths: ['/a', '/b', '/c'] }) + ) + const before = new LookupCountingMap(OWNERS_WITH_EMPTY) + const after = new LookupCountingMap(OWNERS_WITH_EMPTY) + + const legacyMatches = rows.map((row) => legacySkillMatchesAgent(row, 'claude', before)) + const matches = rows.map((row) => skillMatchesAgent(row, 'claude', after)) + + expect(matches).toEqual(legacyMatches) + expect(matches.every(Boolean)).toBe(true) + expect(before.lookups).toBe(30_000) + expect(after.lookups).toBe(10_000) + }) + + it('still scans every root when none of them owns the filtered agent', () => { + const agentByRootPath = new LookupCountingMap(OWNERS_WITH_EMPTY) + expect( + skillMatchesAgent(skill({ rootPaths: ['/a', '/b', '/c'] }), 'unknown', agentByRootPath) + ).toBe(false) + expect(agentByRootPath.lookups).toBe(3) + }) +}) + +describe('skillAgentOptions', () => { + it('still counts every owning root once per skill', () => { + const options = skillAgentOptions({ + scannedAt: 1, + sources: [ + { + id: 'a', + label: 'A', + path: '/a', + sourceKind: 'home', + providers: [], + owner: 'claude', + exists: true + }, + { + id: 'b', + label: 'B', + path: '/b', + sourceKind: 'repo', + providers: [], + owner: 'codex', + exists: true + }, + { + id: 'shared', + label: 'S', + path: '/s', + sourceKind: 'repo', + providers: [], + owner: null, + exists: true + } + ], + skills: [ + skill({ id: 'one', rootPaths: ['/a', '/a', '/b'] }), + skill({ id: 'two', rootPaths: ['/a'] }), + skill({ id: 'three', rootPath: '/s', rootPaths: undefined }), + skill({ id: 'four', rootPath: '/missing', rootPaths: undefined }) + ] + }) + expect(options.map((option) => [option.id, option.count])).toEqual([ + ['claude', 2], + ['codex', 1], + ['shared', 1] + ]) + }) +}) diff --git a/src/renderer/src/components/skills/skill-agent-filter.ts b/src/renderer/src/components/skills/skill-agent-filter.ts index 78c35057137..bb497206f13 100644 --- a/src/renderer/src/components/skills/skill-agent-filter.ts +++ b/src/renderer/src/components/skills/skill-agent-filter.ts @@ -35,12 +35,22 @@ function skillAgents( return [...new Set(roots.map((root) => agentByRootPath.get(root)).filter(Boolean))] as string[] } +/** Membership only, so it stops at the first owning root instead of materializing + * the deduplicated agent list `skillAgentOptions` still needs for counting. */ export function skillMatchesAgent( skill: DiscoveredSkill, agentId: string, agentByRootPath: ReadonlyMap ): boolean { - return agentId === 'all' || skillAgents(skill, agentByRootPath).includes(agentId) + if (agentId === 'all') { + return true + } + if (!agentId) { + return false // An empty owner is never a filter; parity with the dropped `.filter(Boolean)`. + } + return skill.rootPaths?.length + ? skill.rootPaths.some((root) => agentByRootPath.get(root) === agentId) + : agentByRootPath.get(skill.rootPath) === agentId } /** Only agents that actually hold a skill; an empty root is not a filter. */ diff --git a/src/renderer/src/components/skills/skill-source-inventory.test.ts b/src/renderer/src/components/skills/skill-source-inventory.test.ts index 67fd41965e8..1d863ea3f6b 100644 --- a/src/renderer/src/components/skills/skill-source-inventory.test.ts +++ b/src/renderer/src/components/skills/skill-source-inventory.test.ts @@ -41,6 +41,61 @@ function result(overrides: Partial = {}): SkillDiscoveryRe } describe('summarizeSkillSources', () => { + it('counts each skill once per root, including a primary root omitted from rootPaths', () => { + const shared = skill({ rootPaths: ['/other', '/other', '/unknown'] }) + const home = source() + const other = source({ path: '/other' }) + const entries = summarizeSkillSources( + result({ + sources: [home, other, other, source({ path: '/OTHER' })], + skills: [shared, shared, skill({ rootPaths: [home.path, home.path] })] + }) + ) + expect(entries.map((entry) => entry.skillCount)).toEqual([3, 2, 2, 0]) + expect(entries[0].source).toBe(home) + expect(entries[1].source).toBe(other) + expect(entries[2].source).toBe(other) + }) + + it('does not scan every skill again for each source', () => { + let rootReads = 0 + const skills = Array.from({ length: 1000 }, () => ({ + ...skill(), + get rootPath() { + rootReads++ + return '/home/dev/.agents/skills' + } + })) + const sources = Array.from({ length: 87 }, (_, index) => source({ id: `${index}` })) + const entries = summarizeSkillSources(result({ skills, sources })) + expect(entries.every((entry) => entry.skillCount === 1000)).toBe(true) + expect(rootReads).toBeLessThanOrEqual(skills.length) + }) + + it('does not inspect skills without sources', () => { + const unused = { + ...skill(), + get rootPath(): string { + throw new Error('No source needs a count') + } + } + expect(summarizeSkillSources(null)).toEqual([]) + expect(summarizeSkillSources(result({ skills: [unused] }))).toEqual([]) + }) + + it('accepts frozen ownership lists and inputs without changing them', () => { + const home = Object.freeze(source()) + const item = skill({ rootPaths: [home.path, '/co-owner', home.path] }) + Object.freeze(item.rootPaths) + Object.freeze(item) + const discovery = result({ sources: [home, source({ path: '/co-owner' })], skills: [item] }) + Object.freeze(discovery.sources) + Object.freeze(discovery.skills) + Object.freeze(discovery) + expect(summarizeSkillSources(discovery).map((entry) => entry.skillCount)).toEqual([1, 1]) + expect(item.rootPaths).toEqual([home.path, '/co-owner', home.path]) + }) + it('counts a symlinked skill under every root that reached it', () => { const shared = source({ id: 'repo', path: '/repo/.agents/skills', sourceKind: 'repo' }) const entries = summarizeSkillSources( diff --git a/src/renderer/src/components/skills/skill-source-inventory.ts b/src/renderer/src/components/skills/skill-source-inventory.ts index 1bedd5fa02b..708334530f1 100644 --- a/src/renderer/src/components/skills/skill-source-inventory.ts +++ b/src/renderer/src/components/skills/skill-source-inventory.ts @@ -13,8 +13,6 @@ export type SkillSourceInventoryEntry = { } function ownsSkill(source: SkillDiscoverySource, skill: DiscoveredSkill): boolean { - // Why: a symlinked skill is deduped to one row but keeps every root that - // reached it, so counting only `rootPath` would zero out the co-owning roots. return skill.rootPath === source.path || (skill.rootPaths?.includes(source.path) ?? false) } @@ -37,12 +35,38 @@ function sourceStatus(source: SkillDiscoverySource): SkillSourceStatus { export function summarizeSkillSources( result: SkillDiscoveryResult | null ): SkillSourceInventoryEntry[] { - if (!result) { + if (!result || result.sources.length === 0) { return [] } + // With no repeated skill traversal, the direct count needs no index. + if (result.sources.length === 1 || result.skills.length === 0) { + return result.sources.map((source) => ({ + source, + skillCount: result.skills.filter((skill) => ownsSkill(source, skill)).length, + status: sourceStatus(source) + })) + } + const counts = new Map( + result.sources.map((source) => [source.path, { count: 0, lastSkillIndex: -1 }]) + ) + const countRoot = (rootPath: string, skillIndex: number): void => { + const count = counts.get(rootPath) + // Symlinked skills can name one owning root more than once. + if (count && count.lastSkillIndex !== skillIndex) { + count.count++ + count.lastSkillIndex = skillIndex + } + } + for (let index = 0; index < result.skills.length; index++) { + const skill = result.skills[index] + countRoot(skill.rootPath, index) + for (const rootPath of skill.rootPaths ?? []) { + countRoot(rootPath, index) + } + } return result.sources.map((source) => ({ source, - skillCount: result.skills.filter((skill) => ownsSkill(source, skill)).length, + skillCount: counts.get(source.path)?.count ?? 0, status: sourceStatus(source) })) } diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index be74c0fd7f2..cfaeeeb6bdc 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -33,7 +33,7 @@ import { } from './remote-host-connection-status' import { isConnectedRuntimeHostState, - runtimeHostConnectionState, + runtimeHostConnectionStateForEntry, runtimeStatusForOverall } from '@/runtime/runtime-host-connection-state' import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler' @@ -74,7 +74,7 @@ export function SshStatusSegment({ const settings = useAppStore((s) => s.settings) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) + const readRuntimeHostStatusSnapshots = useAppStore((s) => s.readRuntimeHostStatusSnapshots) const hydrateRuntimeEnvironmentStatuses = useAppStore((s) => s.hydrateRuntimeEnvironmentStatuses) const remoteWorkspaceSyncStatusByTargetId = useAppStore( (s) => s.remoteWorkspaceSyncStatusByTargetId @@ -105,7 +105,7 @@ export function SshStatusSegment({ return { id: environment.id, label: override || environment.name || environment.id, - hasStatusEntry: Boolean(statusEntry), + snapshot: statusEntry?.snapshot, status: statusEntry?.status ?? null, active: settings?.activeRuntimeEnvironmentId === environment.id, remoteControl: statusEntry?.remoteControl ?? statusEntry?.status?.remoteControl ?? null @@ -113,7 +113,7 @@ export function SshStatusSegment({ }) const runtimeHostRows = runtimeHosts.map((host) => ({ ...host, - state: runtimeHostConnectionState(host) + state: runtimeHostConnectionStateForEntry(runtimeStatusByEnvironmentId.get(host.id)) })) // Available remote servers are online even when they are not the active runtime. // Keep host health separate from the advanced active-server selection. @@ -152,11 +152,7 @@ export function SshStatusSegment({ async (environmentId: string): Promise => { try { await window.api.runtimeEnvironments.disconnect({ selector: environmentId }) - setRuntimeEnvironmentStatus( - environmentId, - { status: null, checkedAt: Date.now() }, - { suppressDisconnectToast: true } - ) + await readRuntimeHostStatusSnapshots() recordFeatureInteraction('ssh') } catch (err) { toast.error( @@ -169,7 +165,7 @@ export function SshStatusSegment({ ) } }, - [recordFeatureInteraction, setRuntimeEnvironmentStatus] + [recordFeatureInteraction, readRuntimeHostStatusSnapshots] ) if (targets.length === 0 && runtimeHosts.length === 0) { diff --git a/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx b/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx new file mode 100644 index 00000000000..085795246cf --- /dev/null +++ b/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { UpdateStatus } from '../../../../shared/update-status-types' +import { useAppStore } from '../../store' +import { UpdateCard } from '../UpdateCard' +import { TooltipProvider } from '../ui/tooltip' +import { UpdateStatusSegment } from './UpdateStatusSegment' + +const check = vi.fn() +const message = 'Could not reach the update server: net::ERR_CONNECTION_REFUSED' +const error: UpdateStatus = { state: 'error', message } +const actionableErrors: UpdateStatus[] = [ + { ...error, userInitiated: true }, + { ...error, version: '1.4.200' }, + { + ...error, + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.4.200' + } + } +] + +function setStatus(status: UpdateStatus): void { + act(() => useAppStore.getState().setUpdateStatus(status)) +} + +function renderUpdateControls(): void { + render( + + + + + ) +} + +function errorToggle(): HTMLElement { + return screen.getByRole('button', { name: 'Update failed. Click to expand.' }) +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + check.mockReset().mockResolvedValue(undefined) + vi.stubGlobal( + 'matchMedia', + vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + ) + Object.defineProperty(window, 'api', { + configurable: true, + value: { updater: { check } } + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(useAppStore.getInitialState(), true) + vi.unstubAllGlobals() +}) + +describe('update status disclosure', () => { + it('opens background check failure details on the first click and offers a re-check', () => { + renderUpdateControls() + setStatus({ state: 'checking' }) + setStatus(error) + + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + fireEvent.click(errorToggle()) + + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + expect(errorToggle().getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: 'Show details' })) + expect(screen.getByText(message)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Re-check' })) + expect(check).toHaveBeenCalledWith({ includePrerelease: false }) + + fireEvent.click(errorToggle()) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + expect(errorToggle().getAttribute('aria-expanded')).toBe('false') + }) + + it('announces a quiet automatic check failure as collapsed', () => { + setStatus(error) + renderUpdateControls() + + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + expect(errorToggle().getAttribute('aria-expanded')).toBe('false') + }) + + it('preserves the disclosure choice until the next automatic check', () => { + renderUpdateControls() + setStatus(error) + fireEvent.click(errorToggle()) + setStatus({ ...error, message: 'Still unavailable' }) + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + + fireEvent.click(errorToggle()) + setStatus(error) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + fireEvent.click(errorToggle()) + + setStatus({ state: 'checking' }) + setStatus(error) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + expect(errorToggle().getAttribute('aria-expanded')).toBe('false') + }) + + it.each([ + { state: 'checking', userInitiated: true }, + { state: 'available', version: '1.4.200', changelog: null }, + { state: 'downloading', version: '1.4.200', percent: 50 }, + { state: 'downloaded', version: '1.4.200' } + ])('opens an error after $state without requiring a status click', (previousStatus) => { + setStatus(previousStatus) + setStatus(error) + renderUpdateControls() + + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + expect(errorToggle().getAttribute('aria-expanded')).toBe('true') + }) + + it.each(actionableErrors)( + 'opens an explicit actionable error on initial receipt: %j', + (status) => { + setStatus(status) + renderUpdateControls() + + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + expect( + screen.getByRole('button', { name: /Click to expand/ }).getAttribute('aria-expanded') + ).toBe('true') + } + ) + + it.each(actionableErrors)( + 'opens a newly actionable error and preserves dismissal on repeat: %j', + (status) => { + renderUpdateControls() + setStatus(error) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + + setStatus(status) + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: /Click to expand/ })) + setStatus({ ...status }) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + } + ) +}) diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 7e5dd27b6bb..5cd992e2b60 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -39,13 +39,13 @@ import { buildResourceSessionBindingIndex, type ResourceSessionBindingIndex } from './resource-session-bindings' +import { + resolveResourceFolderWorkspace, + resolveResourceWorkspaceHost +} from './resource-workspace-host' // ─── Helpers ──────────────────────────────────────────────────────── -function deriveRepoIdFromWorktreeId(worktreeId: string): string { - return getRepoIdFromWorktreeId(worktreeId) -} - function deriveWorktreeNameFromWorktreeId(worktreeId: string): string { return getWorktreePathBasenameFromId(worktreeId) ?? worktreeId } @@ -156,24 +156,7 @@ export function mergeSnapshotAndSessions( daemonSessions.map((session) => [session.id, session.agentOwnership]) ) - function isRepoRemote(repoId: string): boolean { - // Why: missing entry === we don't know about this repo (typically the - // unattributed bucket or a session whose repo metadata never made it - // into the renderer). Treat unknown as not-remote so a missing-data - // edge case can never spuriously flip the chip on. The chip should - // only fire when we have positive evidence the repo is SSH-backed. - return ctx.repoConnectionIdById.get(repoId) != null - } - - function isRuntimeScopedRepo(repoId: string): boolean { - return ctx.repoRuntimeScopedById.get(repoId) === true - } - - function ensureRepo( - repoId: string, - repoName: string, - initiallyHasRemoteChildren = false - ): UnifiedProjectGroup { + function ensureRepo(repoId: string, repoName: string): UnifiedProjectGroup { const existing = repos.get(repoId) if (existing) { return existing @@ -183,7 +166,7 @@ export function mergeSnapshotAndSessions( repoName, cpu: null, memory: null, - hasRemoteChildren: initiallyHasRemoteChildren || isRepoRemote(repoId), + hasRemoteChildren: false, worktrees: [] } repos.set(repoId, next) @@ -200,6 +183,7 @@ export function mergeSnapshotAndSessions( function appendWorktreeRow(repo: UnifiedProjectGroup, row: UnifiedWorktreeRow): void { repo.worktrees.push(row) + repo.hasRemoteChildren ||= row.isRemote const rows = worktreeRowsByRepo.get(repo.repoId)! if (!rows.has(row.worktreeId)) { rows.set(row.worktreeId, row) @@ -209,12 +193,16 @@ export function mergeSnapshotAndSessions( // ── Step 1: ingest snapshot worktrees as the local-truth foundation. if (snapshot) { for (const wt of snapshot.worktrees as readonly WorktreeMemory[]) { + const worktree = resolveResourceFolderWorkspace(ctx, wt.worktreeId) + const repoId = worktree?.repoId ?? wt.repoId + const repoName = (worktree && ctx.repoDisplayNameById.get(repoId)) || wt.repoName + const { isRemote, isRuntimeScoped } = resolveResourceWorkspaceHost(ctx, wt.worktreeId, repoId) // Why: local snapshot data must never render under a runtime-hosted repo // row; belt-and-braces with the matching session-ingest guard below. - if (isRuntimeScopedRepo(wt.repoId)) { + if (isRuntimeScoped) { continue } - const repo = ensureRepo(wt.repoId, wt.repoName) + const repo = ensureRepo(repoId, repoName) const sessions: UnifiedSessionRow[] = wt.sessions.map((s) => { seenSessionIds.add(s.sessionId) const tabId = index.ptyIdToTabId.get(s.sessionId) ?? null @@ -233,14 +221,14 @@ export function mergeSnapshotAndSessions( }) appendWorktreeRow(repo, { worktreeId: wt.worktreeId, - worktreeName: wt.worktreeName, - repoId: wt.repoId, - repoName: wt.repoName, + worktreeName: worktree?.displayName?.trim() || wt.worktreeName, + repoId, + repoName, cpu: wt.cpu, memory: wt.memory, history: wt.history, hasLocalSamples: true, - isRemote: isRepoRemote(wt.repoId), + isRemote, sessions, browsers: [] }) @@ -258,35 +246,37 @@ export function mergeSnapshotAndSessions( const tabId = index.ptyIdToTabId.get(session.id) ?? null let worktreeId = tabId ? (index.tabIdToWorktreeId.get(tabId) ?? null) : null - // 2b: @@-parse — recover worktreeId from the minted session id format. + // Prefer daemon metadata; older publishers may only encode the workspace in the session id. if (!worktreeId) { - worktreeId = parsePtySessionId(session.id).worktreeId + worktreeId = session.worktreeId || parsePtySessionId(session.id).worktreeId } // 2c: unattributed bucket. const isUnattributed = !worktreeId const finalWorktreeId = worktreeId ?? `${UNATTRIBUTED_REPO_ID}::${session.id}` + const worktree = resolveResourceFolderWorkspace(ctx, finalWorktreeId) const finalRepoId = isUnattributed ? UNATTRIBUTED_REPO_ID - : deriveRepoIdFromWorktreeId(finalWorktreeId) + : (worktree?.repoId ?? getRepoIdFromWorktreeId(finalWorktreeId)) const finalRepoName = isUnattributed ? UNATTRIBUTED_REPO_NAME : ctx.repoDisplayNameById.get(finalRepoId) || finalRepoId const finalWorktreeName = isUnattributed ? session.title || session.id.slice(0, 12) - : deriveWorktreeNameFromWorktreeId(finalWorktreeId) + : worktree?.displayName?.trim() || deriveWorktreeNameFromWorktreeId(finalWorktreeId) // Why: the current daemon inputs are local/SSH only; this guard prevents a // future local daemon row accidentally exposing kill actions for runtime PTYs. - if (isRuntimeScopedRepo(finalRepoId)) { + const { isRemote, isRuntimeScoped } = resolveResourceWorkspaceHost( + ctx, + finalWorktreeId, + finalRepoId + ) + if (isRuntimeScoped) { continue } - const repoIsRemote = isRepoRemote(finalRepoId) - const repo = ensureRepo(finalRepoId, finalRepoName, repoIsRemote) - if (repoIsRemote) { - repo.hasRemoteChildren = true - } + const repo = ensureRepo(finalRepoId, finalRepoName) let row = findWorktreeRow(repo, finalWorktreeId) if (!row) { @@ -299,7 +289,7 @@ export function mergeSnapshotAndSessions( memory: null, history: [], hasLocalSamples: false, - isRemote: repoIsRemote, + isRemote, sessions: [], browsers: [] } @@ -339,7 +329,7 @@ export function mergeSnapshotAndSessions( memory: null, history: [], hasLocalSamples: false, - isRemote: isRepoRemote(worktree.repoId), + isRemote: resolveResourceWorkspaceHost(ctx, worktreeId, worktree.repoId).isRemote, sessions: [], browsers: [] } @@ -348,10 +338,7 @@ export function mergeSnapshotAndSessions( row.browsers = browsers } - // ── Step 4: per-repo aggregates. Remote children are identified by the - // repo's connectionId, not by missing data — `!hasLocalSamples` would - // mislabel warm-reattached local PTYs. The aggregate still skips rows - // we can't sample (worktree.cpu === null) so the numbers stay honest. + // Only sampled rows contribute to project totals. for (const repo of repos.values()) { let cpuSum = 0 let memSum = 0 diff --git a/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts b/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts index fafe3cc155b..ec7c7a62848 100644 --- a/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts +++ b/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts @@ -16,7 +16,7 @@ describe('resource session classification parity', () => { const source = readFileSync(DERIVED_MODEL_PATH, 'utf8') const mergeCall = source.slice( source.indexOf('mergeSnapshotAndSessions(resourceSnapshot'), - source.indexOf('worktreeById\n })') + source.indexOf('ambiguousWorktreeIds\n })') ) expect(mergeCall).toContain('...resourceSessionBindings') diff --git a/src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts b/src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts new file mode 100644 index 00000000000..6dc264bc975 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import type { MemorySnapshot, WorktreeMemory } from '../../../../shared/process-stats-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { MergeContext } from './resource-usage-merge-types' +import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions' + +const workspace = { + id: 'folder:notes', + repoId: 'folder-workspace:docs', + displayName: 'Release notes' +} as Worktree +const secondWorkspace = { ...workspace, id: 'folder:research', displayName: 'Research' } +const oldRow: WorktreeMemory = { + worktreeId: workspace.id, + worktreeName: workspace.id, + repoId: workspace.id, + repoName: workspace.id, + cpu: 2, + memory: 2048, + history: [1024, 2048], + sessions: [{ sessionId: 'sampled', paneKey: null, pid: 123, cpu: 2, memory: 2048 }] +} + +function context(overrides: Partial = {}): MergeContext { + return { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + workspaceSessionReady: true, + repoDisplayNameById: new Map([[workspace.repoId, 'Documentation']]), + repoConnectionIdById: new Map(), + repoRuntimeScopedById: new Map(), + worktreeById: new Map([ + [workspace.id, workspace], + [secondWorkspace.id, secondWorkspace] + ]), + ...overrides + } +} + +describe('folder Resource Manager rows', () => { + it('names daemon-only folders using the host-reported workspace identity', () => { + const groups = mergeSnapshotAndSessions( + null, + [ + { + id: 'folder:notes@@session', + worktreeId: workspace.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'unknown' + } + ], + context() + ) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ + repoId: workspace.repoId, + repoName: 'Documentation', + cpu: null, + memory: null, + worktrees: [ + { + worktreeId: workspace.id, + worktreeName: 'Release notes', + hasLocalSamples: false, + isRemote: false, + sessions: [{ bound: false, agentOwnership: 'unknown' }] + } + ] + }) + }) + + it('merges old snapshot labels and daemon-only rows into the same named folder without losing metrics', () => { + const groups = mergeSnapshotAndSessions( + { worktrees: [oldRow] } as MemorySnapshot, + [ + { + id: 'extra', + worktreeId: workspace.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'present' + }, + { + id: 'second', + worktreeId: secondWorkspace.id, + cwd: '/research', + title: 'Shell', + agentOwnership: 'unknown' + } + ], + context() + ) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ repoName: 'Documentation', cpu: 2, memory: 2048 }) + expect(groups[0].worktrees).toHaveLength(2) + expect(groups[0].worktrees[0]).toMatchObject({ + worktreeName: 'Release notes', + cpu: 2, + memory: 2048, + history: [1024, 2048] + }) + expect(groups[0].worktrees[0].sessions.map((s) => s.sessionId)).toEqual(['sampled', 'extra']) + expect(groups[0].worktrees[1].worktreeName).toBe('Research') + }) + + it('keeps old snapshot labels when the folder catalog is unavailable', () => { + const groups = mergeSnapshotAndSessions( + { worktrees: [oldRow] } as MemorySnapshot, + [], + context({ worktreeById: new Map() }) + ) + expect(groups[0].worktrees[0]).toMatchObject({ + worktreeId: workspace.id, + worktreeName: workspace.id, + memory: 2048 + }) + }) + + it('excludes runtime-owned folder sessions from local snapshot and daemon inputs', () => { + expect( + mergeSnapshotAndSessions( + { worktrees: [oldRow] } as MemorySnapshot, + [ + { + id: 'foreign', + worktreeId: workspace.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'present' + } + ], + context({ + worktreeById: new Map([[workspace.id, { ...workspace, hostId: 'runtime:paired' }]]) + }) + ) + ).toEqual([]) + }) + + it('keeps an SSH folder marked remote even without a numeric sample', () => { + const groups = mergeSnapshotAndSessions( + null, + [ + { + id: 'ssh-folder', + worktreeId: workspace.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'present' + } + ], + context({ + worktreeById: new Map([[workspace.id, { ...workspace, hostId: 'ssh:ssh-target' }]]) + }) + ) + expect(groups[0]).toMatchObject({ hasRemoteChildren: true, memory: null }) + expect(groups[0].worktrees[0]).toMatchObject({ isRemote: true, memory: null }) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index 5943b744dfd..a67f5e0ec66 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -35,7 +35,7 @@ export type UnifiedWorktreeRow = { memory: Metric history: number[] hasLocalSamples: boolean - /** Why: repo connectionId, not sample presence, drives the remote chip. */ + /** Execution-host metadata drives the remote chip; missing samples do not. */ isRemote: boolean sessions: UnifiedSessionRow[] browsers: BrowserWorkspace[] @@ -46,7 +46,7 @@ export type UnifiedProjectGroup = { repoName: string cpu: Metric memory: Metric - /** Why: kept for callsite stability; this now means SSH-backed repo rows. */ + /** True when any workspace in this project runs over SSH. */ hasRemoteChildren: boolean worktrees: UnifiedWorktreeRow[] } @@ -64,7 +64,7 @@ export type MergeContext = { runtimePaneTitlesByTabId: Record> /** From useAppStore: false until renderer state can distinguish bound/orphan. */ workspaceSessionReady: boolean - /** Repo display names by repo id for daemon-only groups. */ + /** Project display names for sampled and daemon-only groups. */ repoDisplayNameById: Map /** Repo connectionId by repo id (null/missing == local). */ repoConnectionIdById: Map @@ -72,6 +72,8 @@ export type MergeContext = { repoRuntimeScopedById: Map /** Browser inventory is open-only; the Resource Manager never scans it in the background. */ browserTabsByWorktree?: Record - /** Canonical worktrees keep browser-only workspace rows out of synthetic buckets. */ + /** Canonical workspace names and grouping for every resource source. */ worktreeById?: ReadonlyMap + /** Ids present on more than one execution host; their catalog row cannot name a host. */ + ambiguousWorktreeIds?: ReadonlySet } diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts index 85663bc2090..21f3aee3e40 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { getResourceUsageAllWorktrees, getResourceUsageDeferredSshSessionIdsByTabId, + getResourceUsageFolderWorkspaces, + getResourceUsageProjectGroups, getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, @@ -42,6 +44,19 @@ const worktree = (): AppState['worktreesByRepo'][string][number] => ({ }) describe('resource usage open slices', () => { + it('subscribes to folder and group catalogs only while open', () => { + const folderWorkspaces: AppState['folderWorkspaces'] = [] + const projectGroups: AppState['projectGroups'] = [] + expect(getResourceUsageFolderWorkspaces({ folderWorkspaces }, true)).toBe(folderWorkspaces) + expect(getResourceUsageProjectGroups({ projectGroups }, true)).toBe(projectGroups) + expect(getResourceUsageFolderWorkspaces({ folderWorkspaces }, false)).toBe( + getResourceUsageFolderWorkspaces({ folderWorkspaces: [] }, false) + ) + expect(getResourceUsageProjectGroups({ projectGroups }, false)).toBe( + getResourceUsageProjectGroups({ projectGroups: [] }, false) + ) + }) + it('returns stable empty slices while the popover is closed', () => { const tabsByWorktree = { 'wt-1': [terminalTab('tab-1')] } const ptyIdsByTabId = { 'tab-1': ['pty-1'] } diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts index ca50f59b8b8..32bd7237397 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts @@ -8,6 +8,8 @@ const EMPTY_DEFERRED_SSH_SESSION_IDS_BY_TAB_ID: AppState['deferredSshSessionIdsB const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} const EMPTY_REPOS: AppState['repos'] = [] +const EMPTY_FOLDER_WORKSPACES: AppState['folderWorkspaces'] = [] +const EMPTY_PROJECT_GROUPS: AppState['projectGroups'] = [] const EMPTY_WORKTREES: ReturnType = [] export function getResourceUsageTabsByWorktree( @@ -65,3 +67,17 @@ export function getResourceUsageAllWorktrees( ): ReturnType { return open ? getAllWorktreesFromState(state) : EMPTY_WORKTREES } + +export function getResourceUsageFolderWorkspaces( + state: Pick, + open: boolean +): AppState['folderWorkspaces'] { + return open ? state.folderWorkspaces : EMPTY_FOLDER_WORKSPACES +} + +export function getResourceUsageProjectGroups( + state: Pick, + open: boolean +): AppState['projectGroups'] { + return open ? state.projectGroups : EMPTY_PROJECT_GROUPS +} diff --git a/src/renderer/src/components/status-bar/resource-workspace-host.ts b/src/renderer/src/components/status-bar/resource-workspace-host.ts new file mode 100644 index 00000000000..5ea2e9df33e --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-workspace-host.ts @@ -0,0 +1,32 @@ +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import type { Worktree } from '../../../../shared/worktree/types' +import type { MergeContext } from './resource-usage-merge-types' + +/** Folder catalog row for host/name attribution; a duplicated id cannot choose a host, so it yields nothing. */ +export function resolveResourceFolderWorkspace( + ctx: MergeContext, + worktreeId: string +): Worktree | undefined { + if ( + parseWorkspaceKey(worktreeId)?.type !== 'folder' || + ctx.ambiguousWorktreeIds?.has(worktreeId) + ) { + return undefined + } + return ctx.worktreeById?.get(worktreeId) +} + +export function resolveResourceWorkspaceHost( + ctx: MergeContext, + worktreeId: string, + repoId: string +): { isRemote: boolean; isRuntimeScoped: boolean } { + const folder = resolveResourceFolderWorkspace(ctx, worktreeId) + const host = folder ? parseExecutionHostId(folder.hostId ?? 'local') : null + return { + // Folder siblings may execute on different hosts within the same project group. + isRemote: host ? host.kind === 'ssh' : ctx.repoConnectionIdById.get(repoId) != null, + isRuntimeScoped: host ? host.kind === 'runtime' : ctx.repoRuntimeScopedById.get(repoId) === true + } +} diff --git a/src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx b/src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx new file mode 100644 index 00000000000..568afdea1ee --- /dev/null +++ b/src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ResourceManagerWorktreeTarget } from './resource-manager-worktree-target' + +const mocks = vi.hoisted(() => ({ + activateAndRevealWorkspace: vi.fn(), + activateAndRevealWorktree: vi.fn(), + worktrees: [] as ResourceManagerWorktreeTarget[] +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: mocks.activateAndRevealWorkspace, + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ activateTabAndFocusPane: vi.fn() })) +vi.mock('../../store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('../../store/selectors', () => ({ getAllWorktreesFromState: () => mocks.worktrees })) +vi.mock('../sidebar/delete-worktree-flow', () => ({ runWorktreeDelete: vi.fn() })) + +import { useResourceUsageActions } from './use-resource-usage-actions' + +function renderActions() { + return renderHook(() => + useResourceUsageActions({ + setCollapsedRepos: vi.fn(), + setCollapsedWorktrees: vi.fn(), + tabsByWorktree: {}, + setOpen: vi.fn(), + setActiveView: vi.fn(), + openModal: vi.fn(), + openSpacePage: vi.fn(), + refreshSessions: vi.fn(async () => {}), + removeSession: vi.fn(), + removeSessions: vi.fn(), + sessions: [], + resourceSessionBindings: { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + workspaceSessionReady: true + }, + workspaceSessionReady: true, + killConfirm: null, + setKillConfirm: vi.fn(), + setKilling: vi.fn(), + mountedRef: { current: true }, + cancelPopoverBodyFocusFrame: vi.fn(), + popoverBodyRef: { current: null }, + popoverBodyFocusFrameRef: { current: null } + }) + ).result.current +} + +beforeEach(() => { + mocks.activateAndRevealWorkspace.mockReset() + mocks.activateAndRevealWorktree.mockReset() + mocks.worktrees = [{ id: 'repo::/notes', hostId: 'ssh:box' }] +}) +afterEach(cleanup) + +describe('Resource Manager row navigation', () => { + it('activates a folder workspace row through the workspace dispatcher', () => { + renderActions().navigateToWorktree('folder:notes') + + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('folder:notes') + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) + + it('still routes worktree rows through the host-resolved activator', () => { + renderActions().navigateToWorktree('repo::/notes') + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo::/notes', { + executionHostId: 'ssh:box' + }) + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts index 06a2d0ad177..f94237a0bbe 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts @@ -1,11 +1,12 @@ import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { useAppStore } from '../../store' import type { AppState } from '../../store/types' import { getAllWorktreesFromState } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' import type { DaemonSession, UnifiedSessionRow } from './resource-usage-merge-types' import type { ResourceSessionBindingInputs } from './resource-session-bindings' @@ -92,6 +93,11 @@ export function useResourceUsageActions({ if (worktreeId === ORPHAN_WORKTREE_ID || worktreeId.startsWith(`${UNATTRIBUTED_REPO_ID}::`)) { return } + // Why: the target resolve below only knows worktrees, so a folder key never matched; the folder activator owns host and path-status gating. + if (parseWorkspaceKey(worktreeId)?.type === 'folder') { + activateAndRevealWorkspace(worktreeId) + return + } const target = resolveResourceManagerWorktreeTarget( worktreeId, getAllWorktreesFromState(useAppStore.getState()) diff --git a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx new file mode 100644 index 00000000000..f5e1bac9dbb --- /dev/null +++ b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx @@ -0,0 +1,196 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { MemorySnapshot, WorktreeMemory } from '../../../../shared/process-stats-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { DaemonSession } from './resource-usage-merge-types' +import { useResourceUsageDerivedModel } from './use-resource-usage-derived-model' + +const local = { + id: 'folder:local', + repoId: 'folder-workspace:group', + displayName: 'Local notes', + hostId: 'local' +} as Worktree +const sampled: WorktreeMemory = { + worktreeId: local.id, + worktreeName: 'Published local notes', + repoId: local.repoId, + repoName: 'Local project', + cpu: 2, + memory: 2048, + history: [1024, 2048], + sessions: [{ sessionId: 'sampled', paneKey: null, pid: 123, cpu: 2, memory: 2048 }] +} +const group = { id: 'group', name: 'Local project', executionHostId: 'local' } as ProjectGroup + +function derive( + worktrees: Worktree[], + sessions: DaemonSession[] = [], + row = sampled, + projectGroups = [group], + browserTabsByWorktree: Record = {} +) { + const snapshot = { + worktrees: [row], + host: { totalMemory: 16384 }, + totalMemory: 2048, + totalCpu: 2, + processMemoryMetric: 'rss' + } as MemorySnapshot + return renderHook(() => + useResourceUsageDerivedModel({ + open: true, + resourceSnapshot: snapshot, + sessions, + resourceSessionBindings: { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + workspaceSessionReady: true + }, + runtimePaneTitlesByTabId: {}, + repos: [], + allWorktrees: worktrees, + projectGroups, + browserTabsByWorktree, + workspaceSessionReady: true, + sessionCount: sessions.length, + sessionsError: false, + memorySnapshotError: null, + snapshot, + spaceScanReady: false + }) + ).result.current.unifiedRepos +} + +afterEach(cleanup) + +describe('Resource Manager folder ownership', () => { + it.each(['ssh:box', 'runtime:paired'] as const)( + 'keeps local samples when a sibling folder belongs to %s', + (hostId) => { + const sibling = { ...local, id: 'folder:remote', displayName: 'Remote notes', hostId } + const groups = derive( + [local, sibling], + [ + { + id: 'remote-session', + worktreeId: sibling.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'present' + } + ] + ) + + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ + cpu: 2, + memory: 2048, + hasRemoteChildren: hostId === 'ssh:box' + }) + expect(groups[0].worktrees[0]).toMatchObject({ + worktreeId: local.id, + worktreeName: local.displayName, + isRemote: false, + cpu: 2, + memory: 2048, + sessions: [{ sessionId: 'sampled', memory: 2048 }] + }) + if (hostId === 'ssh:box') { + expect(groups[0].worktrees[1]).toMatchObject({ + worktreeId: sibling.id, + isRemote: true, + cpu: null, + memory: null + }) + } else { + expect(groups[0].worktrees).toHaveLength(1) + } + } + ) + + it.each([false, true])( + 'preserves sampled ownership with duplicate folder ids (reverse=%s)', + (reverse) => { + const remote = { + ...local, + repoId: 'folder-workspace:remote', + displayName: 'Foreign notes', + hostId: 'runtime:paired' + } as Worktree + const groups = derive(reverse ? [remote, local] : [local, remote]) + + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ + repoId: sampled.repoId, + repoName: sampled.repoName, + memory: 2048 + }) + expect(groups[0].worktrees[0]).toMatchObject({ + worktreeName: sampled.worktreeName, + isRemote: false, + memory: 2048, + history: sampled.history + }) + } + ) + + it.each(['repo::/notes', 'folder:shared'])( + 'keeps browser-only rows when %s exists on two hosts', + (worktreeId) => { + const here = { ...local, id: worktreeId, repoId: 'repo', displayName: 'Notes' } as Worktree + const there = { ...here, hostId: 'ssh:box' } as Worktree + const browser = { id: 'browser-1', worktreeId, title: 'Docs' } as BrowserWorkspace + const groups = derive([here, there], [], sampled, [group], { [worktreeId]: [browser] }) + + expect(groups.find((project) => project.repoId === 'repo')).toMatchObject({ + worktrees: [{ worktreeId, isRemote: false, browsers: [browser] }] + }) + } + ) + + it('does not replace a sampled project name with a same-id group from another host', () => { + const foreignGroup = { ...group, name: 'Foreign project', executionHostId: 'runtime:paired' } + const groups = derive([local], [], sampled, [group, foreignGroup]) + expect(groups[0].repoName).toBe('Local project') + }) + + it('keeps git snapshot identity when the catalog contains a different host', () => { + const row = { ...sampled, worktreeId: 'repo::/notes', repoId: 'repo' } + const foreign = { + ...local, + id: row.worktreeId, + repoId: 'foreign-repo', + displayName: 'Foreign notes', + hostId: 'runtime:paired' + } as Worktree + const groups = derive([foreign], [], row) + + expect(groups[0]).toMatchObject({ repoId: row.repoId, repoName: row.repoName, memory: 2048 }) + expect(groups[0].worktrees[0].worktreeName).toBe(row.worktreeName) + }) + + it('keeps git daemon identity when the catalog contains a different host', () => { + const worktreeId = 'repo::/notes' + const foreign = { + ...local, + id: worktreeId, + repoId: 'foreign-repo', + displayName: 'Foreign notes', + hostId: 'runtime:paired' + } as Worktree + const groups = derive( + [foreign], + [{ id: 'git-session', worktreeId, cwd: '/notes', title: '', agentOwnership: 'absent' }] + ) + + expect(groups.find((project) => project.repoId === 'repo')).toMatchObject({ + repoName: 'repo', + worktrees: [{ worktreeId, worktreeName: 'notes', isRemote: false }] + }) + expect(groups.some((project) => project.repoId === foreign.repoId)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts index 6cf5c780351..cc56fd7f357 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts @@ -17,6 +17,7 @@ import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' import { formatMemory } from './resource-usage-metrics' +import { findAmbiguousWorktreeIds, findDuplicateIds } from '../../lib/unified-tab-host-ownership' export function useResourceUsageDerivedModel({ open, @@ -26,6 +27,7 @@ export function useResourceUsageDerivedModel({ runtimePaneTitlesByTabId, repos, allWorktrees, + projectGroups, browserTabsByWorktree, workspaceSessionReady, sessionCount, @@ -41,6 +43,7 @@ export function useResourceUsageDerivedModel({ runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] repos: AppState['repos'] allWorktrees: Worktree[] + projectGroups: AppState['projectGroups'] browserTabsByWorktree: AppState['browserTabsByWorktree'] workspaceSessionReady: boolean sessionCount: number @@ -57,8 +60,14 @@ export function useResourceUsageDerivedModel({ map.set(repo.id, display) } } + const ambiguousGroupIds = findDuplicateIds(projectGroups) + for (const group of projectGroups) { + if (!ambiguousGroupIds.has(group.id)) { + map.set(`folder-workspace:${group.id}`, group.name) + } + } return map - }, [repos]) + }, [repos, projectGroups]) // Why: non-null connectionId is the only honest "remote" signal (SSH PTYs run remote); build from the store, not a missing memory sample. const repoConnectionIdById = useMemo(() => { @@ -83,6 +92,9 @@ export function useResourceUsageDerivedModel({ () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), [allWorktrees] ) + // Why: a bare resource identity cannot choose between the same workspace id on different + // hosts, but the id still exists; keep the map whole and let the merge gate attribution only. + const ambiguousWorktreeIds = useMemo(() => findAmbiguousWorktreeIds(allWorktrees), [allWorktrees]) // Why: skip the merge when closed; the always-mounted segment recomputing on every keystroke-driven store mutation made the app laggy. const unifiedRepos = useMemo( @@ -98,7 +110,8 @@ export function useResourceUsageDerivedModel({ repoConnectionIdById, repoRuntimeScopedById, browserTabsByWorktree, - worktreeById + worktreeById, + ambiguousWorktreeIds }) : [], [ @@ -111,7 +124,8 @@ export function useResourceUsageDerivedModel({ repoConnectionIdById, repoRuntimeScopedById, browserTabsByWorktree, - worktreeById + worktreeById, + ambiguousWorktreeIds ] ) diff --git a/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts index 9dd9c22666d..e565d0cc64b 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts @@ -5,10 +5,13 @@ import { useDaemonActions } from '../shared/useDaemonActions' import type { UnifiedSessionRow } from './resource-usage-merge-types' import type { ResourceSessionBindingInputs } from './resource-session-bindings' import type { SortOption } from './resource-usage-resource-tree' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import { getResourceUsageAllWorktrees, getResourceUsageBrowserTabsByWorktree, getResourceUsageDeferredSshSessionIdsByTabId, + getResourceUsageFolderWorkspaces, + getResourceUsageProjectGroups, getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, @@ -67,7 +70,13 @@ export function useResourceUsageStatusController() { getResourceUsageRuntimePaneTitlesByTabId(s, open) ) const repos = useAppStore((s) => getResourceUsageRepos(s, open)) - const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) + const gitWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) + const folders = useAppStore((s) => getResourceUsageFolderWorkspaces(s, open)) + const projectGroups = useAppStore((s) => getResourceUsageProjectGroups(s, open)) + const allWorktrees = useMemo( + () => [...gitWorktrees, ...folders.map(folderWorkspaceToWorktree)], + [gitWorktrees, folders] + ) const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open)) const browserTabsByWorktree = useAppStore((s) => getResourceUsageBrowserTabsByWorktree(s, open)) // Why: full binding maps stay behind open sentinels so unchanged counts don't rerender the closed segment. @@ -186,6 +195,7 @@ export function useResourceUsageStatusController() { runtimePaneTitlesByTabId, repos, allWorktrees, + projectGroups, browserTabsByWorktree, workspaceSessionReady, sessionCount: sessionInventory.count, diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 1b34ff4712f..970968a132c 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -202,6 +202,11 @@ function findChildrenByType(node: unknown, typeName: string): ReactElementLike[] if (matchedName === typeName) { results.push(el) } + if (matchedName === 'TabBarStaticCreateMenu' && typeof el.type === 'function') { + // Expand the deferred pure menu component in this shallow renderer. + visit(el.type(el.props)) + return + } if (el.props && 'children' in el.props) { visit(el.props.children) } diff --git a/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx index 9a7a19a9186..45c39c2da55 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx @@ -13,7 +13,7 @@ import { import type { TabBarProps } from './tab-bar-props' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' -export function renderTabBarStaticCreateMenu({ +export function TabBarStaticCreateMenu({ terminalOnly, mobileEmulatorEnabled, managedBrowserCreationEnabled, diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index dcf22611227..0e9540870e3 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -22,7 +22,7 @@ import type { TabBarCreateMenuController } from './use-tab-bar-create-menu-contr import type { TabBarItemProjection } from './use-tab-bar-item-projection' import type { TabBarItem } from './tab-bar-item-model' import { renderTabBarItems } from './tab-bar-item-surface' -import { renderTabBarStaticCreateMenu } from './tab-bar-static-create-menu' +import { TabBarStaticCreateMenu } from './tab-bar-static-create-menu' import ClientHostedBrowserTabRows from './ClientHostedBrowserTabRows' import type { ClientHostedBrowserRow } from '../../../../shared/client-hosted-browser-rows' @@ -99,24 +99,6 @@ export function renderTabBarSurface({ activeClientHostedBrowserRowId, togglePinned }) - const standardCreateMenuItems = renderTabBarStaticCreateMenu({ - props, - terminalOnly, - mobileEmulatorEnabled, - managedBrowserCreationEnabled, - mobileEmulatorCreationEnabled, - workspaceHasSimulatorTab, - showMobileEmulatorIntroCallout, - windowsShellEntries, - defaultWindowsPowerShellImplementation, - pwshAvailable: windowsTerminalCapabilities.pwshAvailable, - newTerminalShortcut, - newBrowserShortcut, - newSimulatorShortcut, - newFileShortcut, - openMarkdownShortcut, - queueNewActiveTerminalFocusAfterNewTabMenuClose - }) return (
: null} ) : null} - {showStaticCreateMenuItems ? standardCreateMenuItems : null} + {showStaticCreateMenuItems ? ( + + ) : null} {showStaticCreateMenuItems && showAgentLaunchItems ? ( <> diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts new file mode 100644 index 00000000000..3497cef7cac --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { classifyHostUrl } from './tab-create-entry-url-classification' + +// Why this file exists: the suffix check behind `host/path` navigation moved off `psl.isValid`, and +// nothing else in the tree exercises it directly. These cases pin the listed/unlisted split that +// decides whether a typed string navigates or falls through to search. +describe('classifyHostUrl suffix gate', () => { + it('navigates for a listed suffix carrying a path', () => { + expect(classifyHostUrl('example.com/foo')).toEqual({ + kind: 'host-url', + url: 'https://example.com/foo' + }) + expect(classifyHostUrl('example.co.uk/foo')).toEqual({ + kind: 'host-url', + url: 'https://example.co.uk/foo' + }) + }) + + // The PRIVATE section has to stay in: these are navigable hosts, not search terms. + it('navigates for a private-section suffix carrying a path', () => { + expect(classifyHostUrl('foo.github.io/bar')).toEqual({ + kind: 'host-url', + url: 'https://foo.github.io/bar' + }) + expect(classifyHostUrl('foo.vercel.app/bar')).toEqual({ + kind: 'host-url', + url: 'https://foo.vercel.app/bar' + }) + }) + + // Why this is the fix: psl's 2024 snapshot did not know `api.br`, so `isValid` called it a domain + // and a typed `api.br/x` navigated to a bare public suffix instead of searching. + it('refuses a bare public suffix carrying a path', () => { + expect(classifyHostUrl('api.br/foo')).toBeNull() + expect(classifyHostUrl('co.uk/foo')).toBeNull() + expect(classifyHostUrl('github.io/foo')).toBeNull() + }) + + it('keeps localhost and IPv4 on http without consulting the suffix list', () => { + expect(classifyHostUrl('localhost:3000/foo')).toEqual({ + kind: 'host-url', + url: 'http://localhost:3000/foo' + }) + expect(classifyHostUrl('127.0.0.1:8080/foo')).toEqual({ + kind: 'host-url', + url: 'http://127.0.0.1:8080/foo' + }) + }) + + // The gate only applies once an authority ends; a bare host still navigates so unlisted intranet + // names typed on their own are not forced into search. + it('leaves a bare host unfiltered by the suffix list', () => { + expect(classifyHostUrl('api.br')).toEqual({ kind: 'host-url', url: 'https://api.br/' }) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts index a18f59d1ccf..ef99f648b9c 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts @@ -1,5 +1,5 @@ import { translate } from '@/i18n/i18n' -import { isValid as isListedDomain } from 'psl' +import { parse as parseDomain } from 'tldts' import { classifySchemeLessLocalDevAddress } from '../../../../shared/browser-url' const HOST_FILE_EXTENSIONS = new Set([ @@ -54,6 +54,13 @@ function parseHttpUrl(query: string): ExplicitUrlClassification { } } +// Why: a bare public suffix (`api.br`) is not a navigable host, so the typed string stays a search. +// tldts' PRIVATE section is included so `foo.github.io` still reads as a domain. +function isListedDomain(host: string): boolean { + const parsed = parseDomain(host, { allowPrivateDomains: true }) + return parsed.domain !== null && (parsed.isIcann === true || parsed.isPrivate === true) +} + function splitHostCandidate(query: string): { host: string; port: string | null } | null { if (/[\\\s]/.test(query)) { return null diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts new file mode 100644 index 00000000000..55f24f0c3e5 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import type { PointerSensorOptions, SensorProps } from '@dnd-kit/core' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TabDragPointerSensor } from './tab-drag-pointer-sensor' + +function startSensor(options: PointerSensorOptions = {}) { + const callbacks = { + onAbort: vi.fn(), + onPending: vi.fn(), + onStart: vi.fn(), + onCancel: vi.fn(), + onMove: vi.fn(), + onEnd: vi.fn() + } + new TabDragPointerSensor({ + active: 'tab-1', + event: new PointerEvent('pointerdown', { clientX: 10, clientY: 10 }), + options, + ...callbacks + } as unknown as SensorProps) + return callbacks +} + +function movePointer(): void { + document.dispatchEvent(new PointerEvent('pointermove', { clientX: 100, clientY: 50 })) +} + +beforeEach(() => vi.useFakeTimers()) + +afterEach(() => { + window.dispatchEvent(new Event('resize')) + vi.runOnlyPendingTimers() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('tab drag pointer sensor cancellation', () => { + it('cancels an active gesture on blur and stops subsequent moves and drops', () => { + const callbacks = startSensor() + expect(callbacks.onStart).toHaveBeenCalledOnce() + movePointer() + expect(callbacks.onMove).toHaveBeenCalledOnce() + + window.dispatchEvent(new Event('blur')) + expect(callbacks.onCancel).toHaveBeenCalledOnce() + expect(callbacks.onAbort).not.toHaveBeenCalled() + movePointer() + document.dispatchEvent(new PointerEvent('pointerup')) + + expect(callbacks.onMove).toHaveBeenCalledOnce() + expect(callbacks.onEnd).not.toHaveBeenCalled() + }) + + it.each([ + { distance: 12 }, + { delay: 100, tolerance: 10 } + ])('aborts a pending gesture on blur before activation: %j', (activationConstraint) => { + const callbacks = startSensor({ activationConstraint }) + expect(callbacks.onStart).not.toHaveBeenCalled() + + window.dispatchEvent(new Event('blur')) + expect(callbacks.onAbort).toHaveBeenCalledWith('tab-1') + expect(callbacks.onCancel).toHaveBeenCalledOnce() + vi.advanceTimersByTime(150) + movePointer() + movePointer() + document.dispatchEvent(new PointerEvent('pointerup')) + + expect(callbacks.onStart).not.toHaveBeenCalled() + expect(callbacks.onMove).not.toHaveBeenCalled() + expect(callbacks.onEnd).not.toHaveBeenCalled() + }) + + it('still completes an uninterrupted gesture on pointerup', () => { + const callbacks = startSensor() + movePointer() + document.dispatchEvent(new PointerEvent('pointerup')) + window.dispatchEvent(new Event('blur')) + movePointer() + + expect(callbacks.onEnd).toHaveBeenCalledOnce() + expect(callbacks.onMove).toHaveBeenCalledOnce() + expect(callbacks.onCancel).not.toHaveBeenCalled() + expect(callbacks.onAbort).not.toHaveBeenCalled() + }) + + it('does not restart when a captured activation callback arrives after cancellation', () => { + const schedule = vi.spyOn(window, 'setTimeout') + const callbacks = startSensor({ activationConstraint: { delay: 100, tolerance: 10 } }) + const activate = schedule.mock.calls[0]?.[0] + expect(activate).toBeTypeOf('function') + window.dispatchEvent(new Event('blur')) + ;(activate as () => void)() + + expect(callbacks.onStart).not.toHaveBeenCalled() + expect(callbacks.onCancel).toHaveBeenCalledOnce() + }) + + it('ignores the old Escape listener while a new gesture is active', () => { + const previous = startSensor() + window.dispatchEvent(new Event('blur')) + const current = startSensor() + movePointer() + document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape' })) + document.dispatchEvent(new PointerEvent('pointerup')) + + expect(previous.onCancel).toHaveBeenCalledOnce() + expect(previous.onMove).not.toHaveBeenCalled() + expect(current.onStart).toHaveBeenCalledOnce() + expect(current.onMove).toHaveBeenCalledOnce() + expect(current.onCancel).toHaveBeenCalledOnce() + expect(current.onEnd).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts index ab96dc5e394..40448c6b197 100644 --- a/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts +++ b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts @@ -140,6 +140,7 @@ export class TabDragPointerSensor implements SensorInstance { autoScrollEnabled = true private activated = false + private ended = false private readonly document: Document private readonly initialCoordinates: PointerCoordinates private readonly pointerDownTime = performance.now() @@ -174,6 +175,7 @@ export class TabDragPointerSensor implements SensorInstance { this.windowListeners.add(win, 'dragstart', preventDefault) this.windowListeners.add(win, 'visibilitychange', this.handleCancel) this.windowListeners.add(win, 'contextmenu', preventDefault) + this.windowListeners.add(win, 'blur', this.handleCancel) this.windowListeners.add(win, 'focus', this.handleCancel) this.documentListeners.add(this.document, 'keydown', this.handleKeydown) @@ -200,6 +202,7 @@ export class TabDragPointerSensor implements SensorInstance { } private detach(): void { + this.ended = true this.pointerListeners.removeAll() this.windowListeners.removeAll() window.setTimeout(this.documentListeners.removeAll, 50) @@ -217,7 +220,7 @@ export class TabDragPointerSensor implements SensorInstance { } private handleStart(): void { - if (this.activated) { + if (this.activated || this.ended) { return } this.activated = true @@ -228,6 +231,9 @@ export class TabDragPointerSensor implements SensorInstance { } private handleMove(event: PointerEvent): void { + if (this.ended) { + return + } const coordinates = getPointerCoordinates(event) const { activationConstraint } = this.props.options if (!coordinates) { @@ -277,6 +283,9 @@ export class TabDragPointerSensor implements SensorInstance { } private handleEnd(): void { + if (this.ended) { + return + } this.detach() if (!this.activated) { this.props.onAbort(this.props.active) @@ -285,6 +294,9 @@ export class TabDragPointerSensor implements SensorInstance { } private handleCancel(): void { + if (this.ended) { + return + } this.detach() if (!this.activated) { this.props.onAbort(this.props.active) diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index bdd5edd97af..f57380b09b1 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -16,6 +16,8 @@ import { useTabDragSplit } from './useTabDragSplit' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.mock('../browser-pane/host-guest/webview-registry', () => ({ acquireWebviewsDragPassthrough: vi.fn(() => vi.fn()) })) @@ -94,10 +96,13 @@ function makeDragEvent(activeData: TabDragItemData, pointer: { x: number; y: num } } -function renderDragHook(): ReturnType { +function renderDragHook( + onRender?: (drag: ReturnType) => void +): ReturnType { let result: ReturnType | null = null function Probe(): null { result = useTabDragSplit({ worktreeId: WT }) + onRender?.(result) return null } @@ -267,6 +272,68 @@ describe('canDropTabIntoPaneBody', () => { }) describe('useTabDragSplit', () => { + it.each(['split', 'insertion'])( + 'does not restore a %s preview after blur cleared the drag', + async (preview) => { + if (preview === 'split') { + addPanelGeometry( + 'group-2', + rect({ left: 500, top: 0, width: 400, height: 600 }), + rect({ left: 500, top: 32, width: 400, height: 568 }) + ) + } + const onRender = vi.fn() + const drag = renderDragHook(onRender) + const event = { + ...makeDragEvent(makeDragData('group-1'), { x: 880, y: 300 }), + ...(preview === 'insertion' + ? { + over: { + data: { current: makeDragData('group-2', 'tab-2') }, + rect: rect({ left: 500, top: 0, width: 400, height: 32 }) + } + } + : {}) + } + const previewKey = preview === 'split' ? 'hoveredDropTarget' : 'hoveredTabInsertion' + act(() => drag.onDragStart(event as unknown as Parameters[0])) + act(() => drag.onDragMove(event as unknown as Parameters[0])) + expect(onRender.mock.lastCall?.[0][previewKey]).not.toBeNull() + + await act(async () => { + window.dispatchEvent(new Event('blur')) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + }) + expect(onRender.mock.lastCall?.[0][previewKey]).toBeNull() + act(() => drag.onDragMove(event as unknown as Parameters[0])) + + expect(onRender.mock.lastCall?.[0][previewKey]).toBeNull() + expect(drag.isTabDragActiveRef.current).toBe(false) + } + ) + + it('does not commit a drop after blur cleared the drag', async () => { + addPanelGeometry( + 'group-2', + rect({ left: 500, top: 0, width: 400, height: 600 }), + rect({ left: 500, top: 32, width: 400, height: 568 }) + ) + const dropUnifiedTab = vi.fn(() => true) + const reorderUnifiedTabs = vi.fn() + useAppStore.setState({ dropUnifiedTab, reorderUnifiedTabs }) + const drag = renderDragHook() + const event = makeDragEvent(makeDragData('group-1'), { x: 880, y: 300 }) + act(() => drag.onDragStart(event as unknown as Parameters[0])) + await act(async () => { + window.dispatchEvent(new Event('blur')) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + }) + act(() => drag.onDragEnd(event as unknown as Parameters[0])) + + expect(dropUnifiedTab).not.toHaveBeenCalled() + expect(reorderUnifiedTabs).not.toHaveBeenCalled() + }) + it.each(['pointerup', 'pointercancel', 'blur', 'focus'])( 'clears a stuck active drag when %s arrives without a dnd end event', async (eventName) => { diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index a3520174aed..ca1724e2bf4 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -209,6 +209,10 @@ export function useTabDragSplit({ const onDragMove = useCallback( (event: DragMoveEvent) => { + // A missed-end cleanup can run before dnd-kit delivers its last move. + if (!tabDragActiveRef.current) { + return + } handleDragUpdate(event) }, [handleDragUpdate] @@ -221,6 +225,10 @@ export function useTabDragSplit({ const onDragEnd = useCallback( (event: DragEndEvent) => { + if (!tabDragActiveRef.current) { + finishDrag(true) + return + } commitTabDragDrop({ event, worktreeId, diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index d57cb82d766..8b6e96467ee 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -16,6 +16,7 @@ import type { TerminalParkingFoundation } from './use-terminal-parking-foundatio export function applyTerminalColdActivation(controller: TerminalParkingFoundation) { const { + activationDeferralPlanRevisionRef, activationDeferredMountTabIdsByWorktreeRef, activeGroupIdByWorktree, activeTabId, @@ -96,7 +97,7 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio if (lastActivationWorktreeIdRef.current !== renderedActiveWorktreeId) { lastActivationWorktreeIdRef.current = renderedActiveWorktreeId const tabById = new Map(worktreeTabs.map((tab) => [tab.id, tab])) - planColdActivationTabDeferral({ + const installedDeferralPlan = planColdActivationTabDeferral({ restrictions: backgroundMountTabIdsByWorktreeRef.current, deferredMountTabIdsByWorktree: activationDeferredMountTabIdsByWorktreeRef.current, worktreeId: renderedActiveWorktreeId, @@ -118,6 +119,11 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio }, immediateTabIds }) + // Why: the install mutates only refs, so without a returned revision the + // admission drain's effect deps never change and the plan strands. + if (installedDeferralPlan) { + activationDeferralPlanRevisionRef.current += 1 + } } else if (!coldActivationDeferralEnabled || !activationHostSupportsDeferral) { backgroundMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId) activationDeferredMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId) @@ -165,7 +171,10 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio groupsByWorktree, activeGroupIdByWorktree ) - return { anyMountedWorktreeHasLayout } + return { + anyMountedWorktreeHasLayout, + activationDeferralPlanRevision: activationDeferralPlanRevisionRef.current + } } export type TerminalColdActivationController = TerminalParkingFoundation & diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx index d2b8efd3efa..0531720fe47 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx @@ -4,6 +4,11 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import CloseTerminalDialog from './CloseTerminalDialog' +import { translate } from '@/i18n/i18n' + +vi.mock('@/i18n/i18n', () => ({ + translate: vi.fn((_key: string, fallback: string) => fallback) +})) const mountedRoots: Root[] = [] @@ -49,6 +54,22 @@ describe('CloseTerminalDialog', () => { document.body.innerHTML = '' }) + it('does no dialog-copy work while closed, then builds the opened confirmation', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + const props = { onCancel: vi.fn(), onConfirm: vi.fn() } + vi.mocked(translate).mockClear() + + await act(async () => root.render()) + expect(translate).not.toHaveBeenCalled() + + await act(async () => root.render()) + expect(document.body.textContent).toContain('Stop running command?') + expect(translate).toHaveBeenCalled() + }) + it('renders running command copy and confirms without skipping by default', async () => { const onConfirm = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx index c4da5244457..9edbca8b22e 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx @@ -70,71 +70,104 @@ export default function CloseTerminalDialog({ }} > - - - {isAgent - ? translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title', - 'Stop this agent?' - ) - : translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title', - 'Stop running command?' - )} - - - {isAgent - ? translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description', - "Closing this terminal will stop the agent's current work." - ) - : translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description', - 'Closing this terminal will stop the command running inside it.' - )} - - - {trimmedTabLabel ? ( -

- {trimmedTabLabel} -

- ) : null} -
- setDontAskAgain(checked === true)} - /> - -
- - - - +
) } + +// Keep translation and element construction behind the dialog portal's mount boundary. +function CloseTerminalDialogBody({ + isAgent, + trimmedTabLabel, + checkboxId, + dontAskAgain, + setDontAskAgain, + onCancel, + onConfirm +}: { + isAgent: boolean + trimmedTabLabel: string | undefined + checkboxId: string + dontAskAgain: boolean + setDontAskAgain: (value: boolean) => void + onCancel: () => void + onConfirm: (dontAskAgain: boolean) => void +}): React.JSX.Element { + return ( + <> + + + {isAgent + ? translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title', + 'Stop this agent?' + ) + : translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title', + 'Stop running command?' + )} + + + {isAgent + ? translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description', + "Closing this terminal will stop the agent's current work." + ) + : translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description', + 'Closing this terminal will stop the command running inside it.' + )} + + + {trimmedTabLabel ? ( +

+ {trimmedTabLabel} +

+ ) : null} +
+ setDontAskAgain(checked === true)} + /> + +
+ + + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 58a39f28a50..bd25abcce74 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import TerminalContextMenu from './TerminalContextMenu' +import { translate } from '@/i18n/i18n' import type { KeybindingOverrides } from '../../../../shared/keybindings' type ItemProps = { onSelect?: () => void; children?: React.ReactNode } @@ -13,9 +14,12 @@ vi.mock('@/components/ui/dropdown-menu', async () => { const React_ = await import('react') const passthrough = ({ children }: { children?: React.ReactNode }) => React_.createElement(React_.Fragment, null, children) + const OpenContext = React_.createContext(false) return { - DropdownMenu: passthrough, - DropdownMenuContent: passthrough, + DropdownMenu: ({ open, children }: { open: boolean; children?: React.ReactNode }) => + React_.createElement(OpenContext.Provider, { value: open }, children), + DropdownMenuContent: ({ children }: { children?: React.ReactNode }) => + React_.useContext(OpenContext) ? passthrough({ children }) : null, DropdownMenuLabel: passthrough, DropdownMenuSeparator: () => null, DropdownMenuShortcut: ({ children }: { children?: React.ReactNode }) => { @@ -36,7 +40,7 @@ vi.mock('@/components/ui/dropdown-menu', async () => { } } }) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ translate: vi.fn((_key: string, fallback: string) => fallback) })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) vi.mock('./terminal-context-menu-dismiss', () => ({ shouldIgnoreTerminalMenuPointerDownOutside: () => false @@ -104,6 +108,7 @@ function renderMenu(overrides: Record = {}): string { describe('TerminalContextMenu', () => { beforeEach(() => { + vi.mocked(translate).mockClear() items.list = [] shortcuts.list = [] vi.stubGlobal('navigator', { userAgent: 'Linux' }) @@ -113,6 +118,16 @@ describe('TerminalContextMenu', () => { vi.unstubAllGlobals() }) + it('does no menu-copy work while closed, then builds the opened menu', () => { + renderMenu({ open: false }) + expect(translate).not.toHaveBeenCalled() + expect(items.list).toHaveLength(0) + + renderMenu() + expect(translate).toHaveBeenCalled() + expect(items.list.length).toBeGreaterThan(0) + }) + it('renders a "Copy Context" item that triggers onCopyAgentSessionContext (issue #5020)', () => { const onCopyAgentSessionContext = vi.fn() const onForkAgentSession = vi.fn() @@ -167,6 +182,7 @@ describe('TerminalContextMenu', () => { item?.onSelect?.() expect(onCopyAgentSessionId).toHaveBeenCalledTimes(1) + vi.mocked(translate).mockClear() items.list = [] renderMenu({ canCopyAgentSessionId: false }) expect( diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 2cc76cd7164..5236d61d1a6 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -78,11 +78,59 @@ type TerminalContextMenuProps = { onCopyAgentSessionId: () => void } -export default function TerminalContextMenu({ - open, +export default function TerminalContextMenu(props: TerminalContextMenuProps): React.JSX.Element { + const { open, onOpenChange, menuPoint, menuOpenedAtRef } = props + return ( + { + if (!nextOpen && Date.now() - menuOpenedAtRef.current < 100) { + return + } + onOpenChange(nextOpen) + }} + modal={false} + > + + | 0:-@html 17:delimiter.curly.svelte@svelte 18:-@typescript 27:delimiter.curly.svelte@svelte 28:-@html 29:delimiter.curly.svelte@svelte 30:-@typescript 35:delimiter.curly.svelte@svelte 36:-@html | embed=html", + "{@html 'raw'} | 0:keyword.control.svelte@svelte 6:-@typescript 21:delimiter.curly.svelte@svelte | embed=none", + " | | embed=html", + " | 0:tag.svelte@svelte | embed=none", ] `) }) -}) -describe('svelte tokenizer regressions', () => { - // Regression: when a Svelte file starts with `{#if}`, `{name}`, or `{@html}`, - // no html embed is active yet. Earlier drafts unconditionally emitted - // `nextEmbedded: '@pop'` from root, which Monaco rejects with - // "cannot pop embedded language if not inside one". The fix splits the - // entry-only `root` state from the html-embedded `markup` state. - it('does not pop a non-existent embed when a file starts with a Svelte block', () => { - const action = findRuleAction('root', '{#if foo}') - expect(action).toMatchObject({ switchTo: '@svelteBlockExpressionEnter' }) - expect(action?.nextEmbedded).toBeUndefined() + // Regression (the field failure): the first interpolation of a file threw + // "cannot pop embedded language if not inside one" — every Svelte file with a + // `{}` in it, which is essentially all of them. + it('highlights every interpolation of a markup line', () => { + const [line] = tokenizeSvelte('

a {first} b {second} c

') + + expect(tokenLanguages(line)).toEqual([ + 'html', + 'svelte', + 'typescript', + 'svelte', + 'html', + 'svelte', + 'typescript', + 'svelte', + 'html' + ]) }) - it('starts the html embed and switches to markup when markup begins', () => { - expect(findRuleAction('root', '

Counter

')).toMatchObject({ - switchTo: '@markup', - nextEmbedded: 'html' - }) + it('opens a file on a Svelte block without popping a missing embed', () => { + // No html embed exists yet at file start, so the block's entry rule must not + // pop one — Monarch throws outright if it does. + const [line] = tokenizeSvelte('{#if count > 0}') + + expect(tokenTypeAt(line, 0)).toBe('keyword.control') + expect(tokenLanguages(line)).toEqual(['svelte', 'typescript', 'svelte']) }) - // Regression: while the html embed is active, only parent rules whose action - // pops the embed are consulted before delegating to html. The first draft - // omitted `nextEmbedded: '@pop'` from ``)).toEqual([ + ['html'], + ['svelte'], + [embeddedLanguageId], + ['svelte'] + ]) + }) + + it.each([ + ['`)).toEqual([ + ['html'], + ['svelte'], + [embeddedLanguageId], + ['svelte'] + ]) + }) +}) + +describe('svelte root state invariant', () => { + // Structural on purpose: behaviour can only reach the root rules some fixture + // happens to exercise, and a root rule that pops an embed throws on the very + // first character of a file. Guard every root rule, exercised or not. + it('has no root rule that pops an embedded language', () => { + const rootRules = (svelteMonarchLanguage.tokenizer as Record).root + const popRules = rootRules.filter( + (rule) => + Array.isArray(rule) && (rule[1] as { nextEmbedded?: string })?.nextEmbedded === '@pop' + ) + + expect(popRules).toEqual([]) }) }) diff --git a/src/renderer/src/lib/monaco-languages/register-vue.test.ts b/src/renderer/src/lib/monaco-languages/register-vue.test.ts index 3898dea7a38..8483b1a9713 100644 --- a/src/renderer/src/lib/monaco-languages/register-vue.test.ts +++ b/src/renderer/src/lib/monaco-languages/register-vue.test.ts @@ -1,110 +1,28 @@ import { describe, expect, it, vi } from 'vitest' +import { + endEmbeddedLanguages, + formatTokenizedLines, + tokenizeMonarchDocument, + tokenLanguages, + tokenLanguagesPerLine +} from './monarch-tokenizer-test-harness' import { registerVueLanguage, vueLanguageConfiguration, vueMonarchLanguage } from './register-vue' -type MonarchAction = { - next?: string - nextEmbedded?: string - switchTo?: string -} -type MonarchRule = [RegExp, string | MonarchAction, string?] | { include: string } - -function normalizeState(nextState: string): string { - return nextState.startsWith('@') ? nextState.slice(1) : nextState +// Driven through the real `MonarchTokenizer`: a rule-table walk cannot tell a +// working grammar from one that throws on every `{{ }}`, which is how broken +// Vue highlighting shipped green. +function tokenizeVue(source: string) { + return tokenizeMonarchDocument('vue', vueMonarchLanguage, source) } -function isRuleEntry(rule: MonarchRule): rule is [RegExp, string | MonarchAction, string?] { - return Array.isArray(rule) +/** Which languages actually cover each line — a dropped embed shows up as `vue`. */ +function languagesPerLine(source: string): string[][] { + return tokenLanguagesPerLine(tokenizeVue(source)) } -function getRuleAction(rule: [RegExp, string | MonarchAction, string?]): MonarchAction | undefined { - const [, action, nextStateShortcut] = rule - return typeof action === 'object' - ? action - : nextStateShortcut - ? { next: nextStateShortcut } - : undefined -} - -function findRuleAction(state: string, source: string): MonarchAction | undefined { - const tokenizer = vueMonarchLanguage.tokenizer as Record - const stateRules = tokenizer[state] ?? tokenizer[state.split('.')[0]] - const matchedRule = stateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(source) - return match !== null && match.index === 0 - }) - - return matchedRule && isRuleEntry(matchedRule) ? getRuleAction(matchedRule) : undefined -} - -function collectFixtureRuleActions(source: string): { - line: number - state: string - matched: string - nextState?: string - nextEmbedded?: string - switchTo?: string -}[] { - const ruleActions: { - line: number - state: string - matched: string - nextState?: string - nextEmbedded?: string - switchTo?: string - }[] = [] - const tokenizer = vueMonarchLanguage.tokenizer as Record - const lines = source.split('\n') - const checks: { line: number; state: string; pattern: string }[] = [ - { line: 1, state: 'root', pattern: '' }, - { line: 2, state: 'templateBody', pattern: '{{' }, - { line: 2, state: 'templateExpression', pattern: '}}' }, - { line: 3, state: 'templateBody', pattern: '' }, - { line: 5, state: 'root', pattern: '' }, - { line: 7, state: 'scriptBody.typescript', pattern: '' }, - { line: 9, state: 'root', pattern: '' }, - { line: 11, state: 'styleBody.css', pattern: '' } - ] - - checks.forEach((check) => { - const line = lines.at(check.line - 1) ?? '' - const stateRules = tokenizer[check.state] ?? tokenizer[check.state.split('.')[0]] - const matchedRule = stateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(line) - return match !== null && match[0] === check.pattern - }) - if (!matchedRule || !isRuleEntry(matchedRule)) { - return - } - - const actionObject = getRuleAction(matchedRule) - - ruleActions.push({ - line: check.line, - state: check.state, - matched: check.pattern, - nextState: actionObject?.next ? normalizeState(actionObject.next) : undefined, - nextEmbedded: actionObject?.nextEmbedded, - switchTo: actionObject?.switchTo ? normalizeState(actionObject.switchTo) : undefined - }) - }) - - return ruleActions -} - -describe('registerVueLanguage', () => { +describe('registerVueLanguage registration', () => { + // Structural by necessity: covers the registration call itself (ids, + // extensions, idempotence), which tokenizing cannot observe. it('registers the vue language, Monarch tokenizer, and configuration once', () => { const languages: { id: string }[] = [{ id: 'typescript' }] const register = vi.fn((entry: { id: string }) => { @@ -136,8 +54,10 @@ describe('registerVueLanguage', () => { expect(setLanguageConfiguration).toHaveBeenCalledTimes(1) expect(setLanguageConfiguration).toHaveBeenCalledWith('vue', vueLanguageConfiguration) }) +}) - it('captures Vue tokenizer transitions for a representative SFC fixture', () => { +describe('vue tokenization', () => { + it('tokenizes a representative SFC', () => { const fixture = ` @@ -150,121 +70,110 @@ const message = 'hello' p { color: rebeccapurple; } ` - const ruleActions = collectFixtureRuleActions(fixture) - - expect(ruleActions).toMatchInlineSnapshot(` + expect(formatTokenizedLines(tokenizeVue(fixture))).toMatchInlineSnapshot(` [ - { - "line": 1, - "matched": "", - "nextEmbedded": "html", - "nextState": undefined, - "state": "templateOpen", - "switchTo": "templateBody", - }, - { - "line": 2, - "matched": "{{", - "nextEmbedded": "@pop", - "nextState": undefined, - "state": "templateBody", - "switchTo": "templateExpressionEnter", - }, - { - "line": 2, - "matched": "}}", - "nextEmbedded": "@pop", - "nextState": undefined, - "state": "templateExpression", - "switchTo": "templateBodyReenter", - }, - { - "line": 3, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "templateBody", - "switchTo": undefined, - }, - { - "line": 5, - "matched": "", - "nextEmbedded": "$S2", - "nextState": undefined, - "state": "scriptOpen.typescript", - "switchTo": "scriptBody.$S2", - }, - { - "line": 7, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "scriptBody.typescript", - "switchTo": undefined, - }, - { - "line": 9, - "matched": "", - "nextEmbedded": "$S2", - "nextState": undefined, - "state": "styleOpen.css", - "switchTo": "styleBody.$S2", - }, - { - "line": 11, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "styleBody.css", - "switchTo": undefined, - }, + " | 0:tag.vue@vue | embed=none", + " | | embed=none", + " | 0:tag.vue@vue | embed=none", + " | | embed=none", + " | 0:tag.vue@vue | embed=none", ] `) }) - it('tracks embedded languages from Vue block attributes', () => { - expect(findRuleAction('templateExpressionEnter', 'message }}')).toMatchObject({ - nextEmbedded: 'typescript', - switchTo: '@templateExpression' - }) - expect(findRuleAction('scriptLangValue.typescript', '"js"')).toMatchObject({ - switchTo: '@scriptOpen.javascript' - }) - expect(findRuleAction('scriptLangValue.javascript', '"ts"')).toMatchObject({ - switchTo: '@scriptOpen.typescript' - }) - expect(findRuleAction('scriptLangValue.typescript', 'js')).toMatchObject({ - switchTo: '@scriptOpen.javascript' - }) - expect(findRuleAction('styleLangValue.css', '"scss"')).toMatchObject({ - switchTo: '@styleOpen.scss' - }) - expect(findRuleAction('styleLangValue.css', 'less')).toMatchObject({ - switchTo: '@styleOpen.less' - }) + // Regression: every `{{ }}` threw "cannot pop embedded language if not inside + // one" once the template body lost its html embed. + it('highlights every interpolation in a template line', () => { + const [, line] = tokenizeVue('') + + expect(tokenLanguages(line)).toEqual([ + 'html', + 'vue', + 'typescript', + 'vue', + 'html', + 'vue', + 'typescript', + 'vue', + 'html' + ]) + }) + + it('embeds the template body as html', () => { + expect(endEmbeddedLanguages(tokenizeVue(''))).toEqual([ + 'html', + 'html', + null + ]) + }) + + it('keeps the template embedded across a comment before it', () => { + expect(languagesPerLine('\n')).toEqual([ + ['vue'], + ['vue'], + ['html'], + ['vue'] + ]) + }) + + it('does not enter typescript for an empty interpolation', () => { + // `{{}}` pops html on entry but never pushes typescript; the close must + // unwind only the state, or it pops an embed that is not there. + const [, line] = tokenizeVue('') + + expect(tokenLanguages(line)).toEqual(['html', 'vue', 'html']) + }) +}) + +describe('vue embedded language attributes', () => { + it.each([ + ['`)).toEqual([ + ['vue'], + [embeddedLanguageId], + ['vue'] + ]) + }) + + it.each([ + ['`)).toEqual([ + ['vue'], + [embeddedLanguageId], + ['vue'] + ]) + }) +}) + +describe('vue root state invariant', () => { + // Structural on purpose: behaviour can only reach the root rules some fixture + // happens to exercise, and a root rule that pops an embed throws on the very + // first character of a file. Guard every root rule, exercised or not. + it('has no root rule that pops an embedded language', () => { + const rootRules = (vueMonarchLanguage.tokenizer as Record).root + const popRules = rootRules.filter( + (rule) => + Array.isArray(rule) && (rule[1] as { nextEmbedded?: string })?.nextEmbedded === '@pop' + ) + + expect(popRules).toEqual([]) }) }) diff --git a/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts b/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts index c14525ec6d7..202beb7c84b 100644 --- a/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts +++ b/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts @@ -34,12 +34,23 @@ function phrasePlacement(field: PaletteIndexedField, normalizedQuery: string): n if (text.startsWith(normalizedQuery)) { return 0 } + // Merge the ordered occurrence and word-start streams: occurrences only count at word + // starts, so each side advances monotonically and never rescans the other. let index = text.indexOf(normalizedQuery, 1) + let wordIndex = 0 while (index !== -1) { - if (field.words.some((word) => word.start === index)) { + let wordStart = field.words[wordIndex]?.start + while (wordStart !== undefined && wordStart < index) { + wordIndex += 1 + wordStart = field.words[wordIndex]?.start + } + if (wordStart === undefined) { + break + } + if (wordStart === index) { return 1 } - index = text.indexOf(normalizedQuery, index + 1) + index = text.indexOf(normalizedQuery, wordStart) } return 2 } diff --git a/src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts b/src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts new file mode 100644 index 00000000000..1b71d4a7798 --- /dev/null +++ b/src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { buildPaletteTabDocument } from './tab-document' +import { matchPaletteTabDocument, preparePaletteTabQuery } from './tab-match' + +function documentWithTitle(title: string) { + return buildPaletteTabDocument({ + id: 'page', + title, + secondaryTexts: [], + worktreeName: '', + branch: '', + repoName: '' + }) +} + +describe('palette phrase placement', () => { + it.each([ + ['aa example', 'aa', 0], + ['baaa aa example', 'aa', 1], + ['baaa baaa', 'aa', 2], + ['prefix fooBar baz', 'bar baz', 1], + ['prefix café noir', 'café noir', 1], + ['prefix foo/bar baz', 'bar baz', 1], + ['prefix 123abc', 'abc', 1], + ['prefix baaa aa', 'aa', 1] + ])('preserves placement for %s / %s', (title, query, placement) => { + expect( + matchPaletteTabDocument(documentWithTitle(title), preparePaletteTabQuery(query)!)?.rank + .placement + ).toBe(placement) + }) + + it('bounds word-start reads with repeated interior substring matches', () => { + const document = documentWithTitle('baaa '.repeat(1000)) + const field = document.visibleFields[0] + let reads = 0 + for (const word of field.words) { + const start = word.start + Object.defineProperty(word, 'start', { + get: () => { + reads += 1 + return start + } + }) + } + const match = matchPaletteTabDocument(document, preparePaletteTabQuery('aa')!) + expect(match?.rank.placement).toBe(2) + expect(match?.titleRanges).toEqual([{ start: 1, end: 3 }]) + expect(reads).toBeLessThanOrEqual(field.words.length * 10) + }) +}) diff --git a/src/renderer/src/lib/palette-match/palette-query.ts b/src/renderer/src/lib/palette-match/palette-query.ts index 66ee5ccf737..6282a82c440 100644 --- a/src/renderer/src/lib/palette-match/palette-query.ts +++ b/src/renderer/src/lib/palette-match/palette-query.ts @@ -103,14 +103,14 @@ export function preparePaletteQuery(query: string): PreparedPaletteQuery { } seen.add(raw) tokens.push(createPaletteQueryToken(raw, tokens.length)) + if (tokens.length > PALETTE_QUERY_MAX_TOKENS) { + return { state: 'invalid', reason: 'too-many-tokens' } + } } if (!tokens.length) { return { state: 'empty' } } - if (tokens.length > PALETTE_QUERY_MAX_TOKENS) { - return { state: 'invalid', reason: 'too-many-tokens' } - } return { state: 'ready', normalized, diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 80111781534..9753d8303b7 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -389,6 +389,43 @@ describe('createSessionWriteSubscriber', () => { cleanup() }) + it('ignores recovery-ledger-only changes', () => { + // Why: the ledger is stripped from the persisted session, so churning it + // must not rebuild and rewrite the durable payload on every remount. + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + ...makeTerminalSessionState('bash') + }) + vi.advanceTimersByTime(200) + persist.mockClear() + + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': [ + { + ...useAppStore.getState().tabsByWorktree['wt-1'][0], + recovery: { + attemptedAt: [1], + generation: 1, + outcome: 'pending', + startedAt: 1, + reason: 'reattach-unverifiable', + tabGeneration: 0 + } + } + ] + } + }) + vi.advanceTimersByTime(200) + + expect(persist).not.toHaveBeenCalled() + cleanup() + }) + it('ignores decorative unified terminal label churn', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index d54675e15ab..685a8e56e3f 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -14,7 +14,10 @@ type UnifiedTab = UnifiedTabsByWorktree[string][number] const TERMINAL_TAB_LIVE_TITLE_KEYS = new Set(['title']) // Why: this handoff flag is stripped from workspace sessions, so toggling it // alone should not rebuild and rewrite the durable session payload. -const TERMINAL_TAB_TRANSIENT_SESSION_KEYS = new Set(['pendingActivationSpawn']) +const TERMINAL_TAB_TRANSIENT_SESSION_KEYS = new Set([ + 'pendingActivationSpawn', + 'recovery' +]) function terminalTabChangedForSession(prev: TerminalTab, next: TerminalTab): boolean { if (prev === next) { diff --git a/src/renderer/src/lib/sha256.ts b/src/renderer/src/lib/sha256.ts index 73f51e295de..cc363dca529 100644 --- a/src/renderer/src/lib/sha256.ts +++ b/src/renderer/src/lib/sha256.ts @@ -1,83 +1,2 @@ -// Why: the LAN web client runs in non-secure browser contexts where -// crypto.subtle is undefined, but hook-trust hashes must stay byte-identical to -// the crypto.subtle SHA-256 hashes stored on Electron/HTTPS — otherwise the -// shared trust store mismatches and re-prompts. tweetnacl only offers SHA-512, -// so this is a self-contained SHA-256 for the fallback path. - -const K = new Uint32Array([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 -]) - -function rotr(value: number, bits: number): number { - return (value >>> bits) | (value << (32 - bits)) -} - -export function sha256(message: Uint8Array): Uint8Array { - const h = new Uint32Array([ - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 - ]) - - // Pad: append 0x80, then zeros, then the 64-bit big-endian bit length. - const bitLength = message.length * 8 - const paddedLength = ((message.length + 8) >> 6) * 64 + 64 - const bytes = new Uint8Array(paddedLength) - bytes.set(message) - bytes[message.length] = 0x80 - // Bit length fits in 32 bits for any realistic hook script; high word stays 0. - const view = new DataView(bytes.buffer) - view.setUint32(paddedLength - 4, bitLength >>> 0, false) - view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false) - - const w = new Uint32Array(64) - for (let offset = 0; offset < paddedLength; offset += 64) { - for (let i = 0; i < 16; i += 1) { - w[i] = view.getUint32(offset + i * 4, false) - } - for (let i = 16; i < 64; i += 1) { - const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3) - const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10) - w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0 - } - - let [a, b, c, d, e, f, g, hh] = h - for (let i = 0; i < 64; i += 1) { - const sigma1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25) - const ch = (e & f) ^ (~e & g) - const t1 = (hh + sigma1 + ch + K[i] + w[i]) | 0 - const sigma0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22) - const maj = (a & b) ^ (a & c) ^ (b & c) - const t2 = (sigma0 + maj) | 0 - hh = g - g = f - f = e - e = (d + t1) | 0 - d = c - c = b - b = a - a = (t1 + t2) | 0 - } - - h[0] = (h[0] + a) | 0 - h[1] = (h[1] + b) | 0 - h[2] = (h[2] + c) | 0 - h[3] = (h[3] + d) | 0 - h[4] = (h[4] + e) | 0 - h[5] = (h[5] + f) | 0 - h[6] = (h[6] + g) | 0 - h[7] = (h[7] + hh) | 0 - } - - const digest = new Uint8Array(32) - new DataView(digest.buffer).setUint32(0, h[0], false) - for (let i = 0; i < 8; i += 1) { - new DataView(digest.buffer).setUint32(i * 4, h[i], false) - } - return digest -} +// The HTTP web client needs a synchronous fallback when crypto.subtle is unavailable. +export { sha256 } from '../../../shared/sha256' diff --git a/src/renderer/src/lib/terminal-quick-command-score-budget.test.ts b/src/renderer/src/lib/terminal-quick-command-score-budget.test.ts new file mode 100644 index 00000000000..cc3b717dc61 --- /dev/null +++ b/src/renderer/src/lib/terminal-quick-command-score-budget.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from 'vitest' +import type { TerminalQuickCommand } from '../../../shared/terminal-quick-command-types' +import { searchTerminalQuickCommands } from './terminal-quick-command-search' + +it.each(['Review', 'codex'])('does not scan prompts that cannot improve the %s match', (query) => { + let promptReads = 0 + const commands: TerminalQuickCommand[] = Array.from({ length: 40 }, (_, index) => ({ + id: String(index), + label: `Review changes ${index}`, + action: 'agent-prompt', + agent: 'codex', + get prompt() { + promptReads++ + return 'Inspect all source code. '.repeat(240) + } + })) + expect(searchTerminalQuickCommands(commands, query)).toEqual(commands) + expect(promptReads).toBe(0) +}) + +it('keeps body matches that beat an agent substring match', () => { + const commands: TerminalQuickCommand[] = [ + { id: 'agent-only', label: 'Other', action: 'agent-prompt', agent: 'codex', prompt: 'nothing' }, + { id: 'body-exact', label: 'Other', action: 'agent-prompt', agent: 'codex', prompt: 'dex' }, + { id: 'label', label: 'dex', command: 'nothing', appendEnter: true } + ] + expect(searchTerminalQuickCommands(commands, 'dex').map((command) => command.id)).toEqual([ + 'label', + 'body-exact', + 'agent-only' + ]) +}) + +it('keeps equal scores in input order and still searches bodies without a metadata match', () => { + const commands: TerminalQuickCommand[] = [ + { id: 'first', label: 'codex', command: 'codex', appendEnter: true }, + { id: 'second', label: 'codex', action: 'agent-prompt', agent: 'codex', prompt: 'codex' }, + { id: 'body', label: 'Other', command: 'run the task', appendEnter: true }, + { id: 'none', label: 'Other', command: 'nothing', appendEnter: true } + ] + expect(searchTerminalQuickCommands(commands, 'codex').map((command) => command.id)).toEqual([ + 'first', + 'second' + ]) + expect(searchTerminalQuickCommands(commands, 'task').map((command) => command.id)).toEqual([ + 'body' + ]) + expect(searchTerminalQuickCommands(commands, 'absent')).toEqual([]) +}) diff --git a/src/renderer/src/lib/terminal-quick-command-search.ts b/src/renderer/src/lib/terminal-quick-command-search.ts index 4c780dc3808..5e6effcc302 100644 --- a/src/renderer/src/lib/terminal-quick-command-search.ts +++ b/src/renderer/src/lib/terminal-quick-command-search.ts @@ -73,12 +73,15 @@ export function getTerminalQuickCommandPickerValue({ } function scoreQuickCommand(command: TerminalQuickCommand, query: string): number { - const body = getTerminalQuickCommandBody(command) - const scores = [scoreCandidate(query, command.label, 0), scoreCandidate(query, body, 400)] - if (isTerminalAgentQuickCommand(command)) { - scores.push(scoreCandidate(query, command.agent, 200)) + let score = scoreCandidate(query, command.label, 0) + // A field cannot improve a score already at or below its base score. + if (score > 200 && isTerminalAgentQuickCommand(command)) { + score = Math.min(score, scoreCandidate(query, command.agent, 200)) } - return Math.min(...scores) + if (score > 400) { + score = Math.min(score, scoreCandidate(query, getTerminalQuickCommandBody(command), 400)) + } + return score } function scoreCandidate(query: string, rawCandidate: string, baseScore: number): number { diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index 2f604fb67bb..d2084d6fd03 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -182,7 +182,15 @@ describe('buildWorkspaceSessionPatch', () => { title: 'shell', ptyId: 'pty-1', worktreeId: localWorktreeId, - pendingActivationSpawn: true + pendingActivationSpawn: true, + recovery: { + attemptedAt: [1], + generation: 1, + outcome: 'pending', + startedAt: 1, + reason: 'reattach-unverifiable', + tabGeneration: 1 + } } as never ] }, @@ -217,6 +225,9 @@ describe('buildWorkspaceSessionPatch', () => { ].sort() ) expect('pendingActivationSpawn' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) + // Why: the recovery ledger describes a mounted pane's in-flight heal; a + // persisted one would refuse the first legitimate recovery after restart. + expect('recovery' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) expect(patch.terminalLayoutsByTabId?.['tab-local'].buffersByLeafId).toBeUndefined() expect(patch.terminalLayoutsByTabId?.['tab-local'].scrollbackRefsByLeafId).toBeUndefined() }) diff --git a/src/renderer/src/lib/workspace-session-unified-tabs.ts b/src/renderer/src/lib/workspace-session-unified-tabs.ts index 38660c2ea3a..f75ca449919 100644 --- a/src/renderer/src/lib/workspace-session-unified-tabs.ts +++ b/src/renderer/src/lib/workspace-session-unified-tabs.ts @@ -7,10 +7,6 @@ type PersistedUnifiedTabSessionData = Pick< 'activeGroupIdByWorktree' | 'tabGroupLayouts' | 'tabGroups' | 'unifiedTabs' > -function dedupePersistedTabIds(tabIds: string[]): string[] { - return Array.from(new Set(tabIds)) -} - function prunePersistedLayoutForGroups( root: TabGroupLayoutNode, validGroupIds: Set @@ -43,17 +39,18 @@ function buildPersistedGroupsForWorktree(tabs: Tab[], groups: TabGroup[]): TabGr return groups .map((group) => { - const tabOrder = dedupePersistedTabIds([ + const orderedTabIds = new Set([ ...group.tabOrder.filter((tabId) => validTabIds.has(tabId)), ...(tabIdsByGroup.get(group.id) ?? []) ]) + const tabOrder = Array.from(orderedTabIds) const activeTabId = - group.activeTabId && tabOrder.includes(group.activeTabId) ? group.activeTabId : null + group.activeTabId && orderedTabIds.has(group.activeTabId) ? group.activeTabId : null return { ...group, activeTabId, tabOrder, - recentTabIds: group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId)) + recentTabIds: group.recentTabIds?.filter((tabId) => orderedTabIds.has(tabId)) } }) .filter((group) => group.tabOrder.length > 0) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 330a150b636..affa2b36ca4 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -204,12 +204,14 @@ export function buildSanitizedTabsByWorktree( tabsByWorktree: WorkspaceSessionSnapshot['tabsByWorktree'] ): WorkspaceSessionState['tabsByWorktree'] { // Why: strip transient pendingActivationSpawn — session:set persists without Zod re-parse, so a stale flag would drop the first PTY spawn on restart. + // Same for the recovery ledger: it describes a mounted pane's in-flight heal, so a persisted one would refuse the first recovery after restart. return Object.fromEntries( Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ worktreeId, tabs.map((tab) => { - const { pendingActivationSpawn: _unused, ...rest } = tab + const { pendingActivationSpawn: _unused, recovery: _recovery, ...rest } = tab void _unused + void _recovery return rest }) ]) diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 839f454eef5..d4409d10549 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -19,7 +19,10 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode' import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup' -import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session' +import { + launchStructuredWorktreeSession, + type WorktreeCreationStructuredSessionResult +} from '@/lib/worktree-creation-structured-session' import { completeWorktreeCreation } from '@/lib/worktree-creation-completion' import { markStructuredWorktreeLaunchUnconfirmed } from '@/lib/worktree-creation-structured-recovery' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' @@ -164,28 +167,41 @@ export async function executeWorktreeCreation( (completionState.activeView === 'terminal' && completionState.activePendingCreationId === null)) + // Why: the worktree exists past this point and nothing awaits this caller, so + // each follow-up step is best-effort — an escaped throw would strand the + // creation surface over the finished workspace instead of reaching completion. let activation: ActivateAndRevealResult | false = false - let primaryTabId: string | null + let primaryTabId: string | null = null if (shouldActivateOnCompletion && !structuredLaunch) { - activation = activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', - ...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}), - ...(result.setup ? { setup: result.setup } : {}), - ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), - ...(startupOpt ? { startup: startupOpt } : {}), - ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), - ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) - }) - primaryTabId = activation === false ? null : activation.primaryTabId - } else { - // Keep chat creation on its pending surface until the session is ready. - const hasExplicitTerminalWork = Boolean( - startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs - ) - primaryTabId = - preparedRequest.agent !== null && !hasExplicitTerminalWork - ? null - : ensureWorktreeHasInitialTerminal( + try { + activation = activateAndRevealWorktree(worktree.id, { + sidebarRevealBehavior: 'auto', + ...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}), + ...(result.setup ? { setup: result.setup } : {}), + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + ...(startupOpt ? { startup: startupOpt } : {}), + ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + }) + primaryTabId = activation === false ? null : activation.primaryTabId + } catch (error) { + console.error('worktree create: activate-and-reveal failed', worktree.id, error) + // Activation can publish the worktree before a later step throws. Do not + // infer a primary tab from default-tab ordering; only a fresh seed may + // return one here. + const stateAfterActivationFailure = useAppStore.getState() + const existingTabs = stateAfterActivationFailure.tabsByWorktree[worktree.id] ?? [] + const launchAgent = startupOpt?.launchAgent ?? preparedRequest.agent + const verifiedLaunchTabId = + result.startupTerminal?.tabId ?? + (launchAgent ? existingTabs.find((tab) => tab.launchAgent === launchAgent)?.id : undefined) + if (verifiedLaunchTabId) { + // Startup terminal ids and stamped agent tabs are the only safe primary + // ids when activation returned no result. + primaryTabId = verifiedLaunchTabId + } else if (existingTabs.length === 0) { + try { + primaryTabId = ensureWorktreeHasInitialTerminal( useAppStore.getState(), worktree.id, startupOpt, @@ -193,17 +209,67 @@ export async function executeWorktreeCreation( preparedRequest.issueCommand, result.defaultTabs, { - activateCreatedTabs: false, ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) } ) + } catch (recoveryError) { + console.error( + 'worktree create: activation recovery seeding failed', + worktree.id, + recoveryError + ) + } + } + if (!backendSpawned) { + try { + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { + startup: startupOpt, + agent: preparedRequest.agent + }) + } catch (recoveryError) { + console.error( + 'worktree create: activation recovery after-wake seeding failed', + worktree.id, + recoveryError + ) + } + } + } + } else { + // Keep chat creation on its pending surface until the session is ready. + const hasExplicitTerminalWork = Boolean( + startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs + ) + if (preparedRequest.agent === null || hasExplicitTerminalWork) { + try { + primaryTabId = ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + worktree.id, + startupOpt, + result.setup, + preparedRequest.issueCommand, + result.defaultTabs, + { + activateCreatedTabs: false, + ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + } + ) + } catch (error) { + console.error('worktree create: initial terminal seeding failed', worktree.id, error) + } + } if (!structuredLaunch && !backendSpawned) { - ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { - startup: startupOpt, - agent: preparedRequest.agent, - activate: false - }) + try { + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { + startup: startupOpt, + agent: preparedRequest.agent, + activate: false + }) + } catch (error) { + console.error('worktree create: after-wake terminal seeding failed', worktree.id, error) + } } } @@ -213,25 +279,34 @@ export async function executeWorktreeCreation( agentLaunchRoute === 'structured-native-chat' && isAgentSessionHandleProvider(preparedRequest.agent) ) { - const structuredSession = await launchStructuredWorktreeSession({ - creationId, - request: preparedRequest, - agentLaunchRoute, - worktreeId: worktree.id, - shouldActivateOnCompletion, - fallbackStartupOpt, - activation, - primaryTabId - }) - structuredLaunchAccepted = structuredSession.accepted - activation = structuredSession.activation - primaryTabId = structuredSession.primaryTabId - if (structuredSession.cancelled) { - return + let structuredSession: WorktreeCreationStructuredSessionResult | null = null + try { + structuredSession = await launchStructuredWorktreeSession({ + creationId, + request: preparedRequest, + agentLaunchRoute, + worktreeId: worktree.id, + shouldActivateOnCompletion, + fallbackStartupOpt, + activation, + primaryTabId + }) + } catch (error) { + // Why: plan.launch is guarded inside, but its sync prologue is not; treat + // an escaped throw like a failed launch (accepted) and still complete. + console.error('worktree create: structured session launch failed', worktree.id, error) } - if (structuredSession.visibilityUnknown) { - markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id) - return + if (structuredSession) { + structuredLaunchAccepted = structuredSession.accepted + activation = structuredSession.activation + primaryTabId = structuredSession.primaryTabId + if (structuredSession.cancelled) { + return + } + if (structuredSession.visibilityUnknown) { + markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id) + return + } } } diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts new file mode 100644 index 00000000000..143645e9008 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts @@ -0,0 +1,377 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + PendingWorktreeCreation, + WorktreeCreationRequest +} from '@/lib/pending-worktree-creation' +import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' + +// Guards executeWorktreeCreation's post-create tail: callers fire and forget, +// so a throw after createWorktree succeeds must be contained per-step and the +// creation must still reach completeWorktreeCreation, which tears the creation +// surface down. Also covers the caller-side .catch() backstop: a rejection that +// still escapes (e.g. pre-create preparation) becomes a visible error state +// plus toast instead of a panel silently stuck at "creating". + +type TestActiveView = 'terminal' | 'tasks' + +const store = { + settings: { + activeRuntimeEnvironmentId: null as string | null, + experimentalNativeChat: undefined as boolean | undefined, + openAgentTabsInChatByDefault: undefined as boolean | undefined + }, + activeView: 'terminal' as TestActiveView, + activePendingCreationId: 'creation-1' as string | null, + repos: [] as { id: string; connectionId: string | null }[], + pendingWorktreeCreations: {} as Record, + beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => { + store.pendingWorktreeCreations[entry.creationId] = entry + store.activePendingCreationId = entry.creationId + }), + updatePendingWorktreeCreation: vi.fn( + (creationId: string, patch: Partial) => { + const entry = store.pendingWorktreeCreations[creationId] + if (entry) { + store.pendingWorktreeCreations[creationId] = { ...entry, ...patch } + } + } + ), + // Mirrors pending-worktree-creation.ts: drop the entry and the active pointer. + removePendingWorktreeCreation: vi.fn((creationId: string) => { + delete store.pendingWorktreeCreations[creationId] + if (store.activePendingCreationId === creationId) { + store.activePendingCreationId = null + } + }), + setActivePendingWorktreeCreation: vi.fn((creationId: string | null) => { + store.activePendingCreationId = creationId + }), + setActiveView: vi.fn((view: TestActiveView) => { + store.activeView = view + }), + setSidebarOpen: vi.fn(), + updateWorktreeMeta: vi.fn(), + createWorktree: vi.fn(), + tabsByWorktree: {} as Record, + unifiedTabsByWorktree: {} +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => store + } +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() } +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: vi.fn() +})) + +vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({ + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn() +})) + +vi.mock('@/lib/workspace-activation-terminal-focus', () => ({ + queueWorkspaceActivationTerminalFocus: vi.fn() +})) + +vi.mock('@/lib/new-workspace', () => ({ + ensureAgentStartupInTerminal: vi.fn() +})) + +vi.mock('@/lib/worktree-creation-agent-seeds', () => ({ + seedAgentTabStateAfterWorktreeCreate: vi.fn() +})) + +vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({ + prepareEphemeralVmWorkspaceTarget: vi.fn() +})) + +vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({ + prepareRequestForCreate: vi.fn( + async (_creationId: string, request: WorktreeCreationRequest) => request + ), + attachEphemeralVmRuntimeToWorkspace: vi.fn(async () => undefined), + cleanupEphemeralVmRuntimeForFailedCreate: vi.fn(async () => undefined) +})) + +vi.mock('@/lib/worktree-creation-structured-recovery', () => ({ + markStructuredWorktreeLaunchUnconfirmed: vi.fn(), + retryStructuredWorktreeLaunch: vi.fn() +})) + +import { toast } from 'sonner' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' +import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' +import { ensureAgentStartupInTerminal } from '@/lib/new-workspace' +import { prepareRequestForCreate } from '@/lib/ephemeral-vm-worktree-creation' +import { executeWorktreeCreation } from './worktree-creation-flow-execute' +import { runBackgroundWorktreeCreation } from './worktree-creation-flow' + +function makeRequest(overrides: Partial = {}): WorktreeCreationRequest { + return { + repoId: 'repo-1', + name: 'feature', + setupDecision: 'inherit', + agent: null, + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null, + ...overrides + } as WorktreeCreationRequest +} + +function seedPendingCreation(request: WorktreeCreationRequest): void { + store.pendingWorktreeCreations = { + 'creation-1': { + creationId: 'creation-1', + phase: 'fetching', + status: 'creating', + startedAt: 1, + indeterminate: false, + loaderVisible: true, + request + } + } + store.activePendingCreationId = 'creation-1' +} + +function surfaceInput(activeView: TestActiveView): { + activeView: TestActiveView + activePendingCreationId: string | null + hasActivePendingCreation: boolean +} { + return { + activeView, + activePendingCreationId: store.activePendingCreationId, + hasActivePendingCreation: + store.activePendingCreationId !== null && + store.pendingWorktreeCreations[store.activePendingCreationId] !== undefined + } +} + +beforeEach(() => { + // resetAllMocks: implementations from prior tests (the injected throws) must not leak. + vi.resetAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + store.activeView = 'terminal' + store.repos = [{ id: 'repo-1', connectionId: null }] + store.tabsByWorktree = {} + store.pendingWorktreeCreations = {} + store.activePendingCreationId = null + store.createWorktree.mockResolvedValue({ + worktree: { id: 'wt-1', repoId: 'repo-1' } + }) +}) + +describe('a throw after createWorktree succeeds no longer strands the creation surface', () => { + it('activating branch: a throw in activateAndRevealWorktree recovers a terminal and completes', async () => { + const request = makeRequest() + seedPendingCreation(request) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('recovered-tab') + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('activation exploded') + }) + + await executeWorktreeCreation('creation-1', request) + + expect(console.error).toHaveBeenCalledWith( + 'worktree create: activate-and-reveal failed', + 'wt-1', + expect.any(Error) + ) + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + undefined, + undefined, + undefined, + {} + ) + // Contained: completion still tears the surface down. + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBeNull() + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('activating branch: leaves existing default tabs untouched after a partial failure', async () => { + const request = makeRequest({ issueCommand: { command: 'echo setup' } }) + seedPendingCreation(request) + store.tabsByWorktree = { 'wt-1': [{ id: 'existing-tab' }] } + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('reveal exploded after tab creation') + }) + + await executeWorktreeCreation('creation-1', request) + + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + }) + + it('activating branch: routes draft and follow-up delivery to the stamped agent tab', async () => { + const request = makeRequest({ + agent: 'codex', + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + draftPrompt: 'draft context', + followupPrompt: 'follow-up context', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + seedPendingCreation(request) + store.tabsByWorktree = { + 'wt-1': [{ id: 'default-tab' }, { id: 'agent-tab', launchAgent: 'codex' }] + } + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('reveal exploded after default tabs were created') + }) + + await executeWorktreeCreation('creation-1', request) + + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(ensureAgentStartupInTerminal).toHaveBeenCalledWith( + expect.objectContaining({ primaryTabId: 'agent-tab' }) + ) + }) + + it('background branch: a throw in after-wake seeding is contained after tabs are seeded', async () => { + // User left the terminal view mid-create, so the non-activating branch runs. + store.activeView = 'tasks' + const request = makeRequest() + seedPendingCreation(request) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') + vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => { + throw new Error('after-wake exploded') + }) + + await executeWorktreeCreation('creation-1', request) + + // Tabs were seeded for the new worktree... + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + undefined, + undefined, + undefined, + expect.objectContaining({ activateCreatedTabs: false }) + ) + expect(console.error).toHaveBeenCalledWith( + 'worktree create: after-wake terminal seeding failed', + 'wt-1', + expect.any(Error) + ) + // ...and the creation still completed instead of stranding the entry. + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBeNull() + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('concurrent create: a throw completing a backgrounded creation still tears its entry down', async () => { + // A second submitted create repointed activePendingCreationId, so this + // creation's completion takes the non-activating branch on the terminal view. + store.activeView = 'terminal' + const request = makeRequest() + seedPendingCreation(request) + store.activePendingCreationId = 'creation-2' + store.createWorktree.mockResolvedValue({ + worktree: { id: 'wt-1', repoId: 'repo-1' }, + setup: { runnerScriptPath: '/tmp/setup.sh' } + }) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') + vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => { + throw new Error('after-wake exploded') + }) + + await executeWorktreeCreation('creation-1', request) + + // Blank terminal + Setup tab are seeded by this one synchronous call. + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + { runnerScriptPath: '/tmp/setup.sh' }, + undefined, + undefined, + expect.objectContaining({ activateCreatedTabs: false }) + ) + // The entry is gone; the pointer stays on the other in-flight creation. + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBe('creation-2') + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('control: with no throw the same flow completes and tears the surface down', async () => { + store.activeView = 'tasks' + const request = makeRequest() + seedPendingCreation(request) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') + + await executeWorktreeCreation('creation-1', request) + + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBeNull() + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('backstop: a rejection that escapes the execute promise becomes a visible inline error', async () => { + // Pre-create preparation runs before the in-function try/catch. + vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded')) + + const creationId = runBackgroundWorktreeCreation(makeRequest()) + + await vi.waitFor(() => { + expect(store.pendingWorktreeCreations[creationId]).toMatchObject({ + status: 'error', + error: 'prepare exploded' + }) + }) + expect(toast.error).not.toHaveBeenCalled() + expect(store.removePendingWorktreeCreation).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + 'worktree create: unhandled failure', + creationId, + expect.any(Error) + ) + }) + + it('backstop: a rejection after leaving the panel is announced with a toast', async () => { + store.activeView = 'tasks' + vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded')) + + const creationId = runBackgroundWorktreeCreation(makeRequest()) + // The pending surface is revealed synchronously; move away before the + // rejected preparation reaches the fire-and-forget backstop. + store.activeView = 'tasks' + + await vi.waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('prepare exploded') + }) + expect(store.pendingWorktreeCreations[creationId]).toMatchObject({ status: 'error' }) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index dfde4e7fe58..7a46e708417 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -1,3 +1,4 @@ +import { toast } from 'sonner' import { useAppStore } from '@/store' import { findPendingLinkedWorkItemCreationId, @@ -11,11 +12,34 @@ import { getWorktreeCreationIndeterminate } from '@/lib/worktree-creation-flow-startup' import { retryStructuredWorktreeLaunch } from '@/lib/worktree-creation-structured-recovery' +import { + formatWorkspaceCreateError, + getWorkspaceCreateErrorToastMessage +} from '@/lib/workspace-create-error-format' type ContinueBackgroundWorktreeCreationOptions = { revealCreationSurface?: boolean } +// Why: nothing awaits these creations, so an escaped rejection would otherwise +// strand the pending entry — and the creation surface — with no error shown. +function startWorktreeCreation(creationId: string, request: WorktreeCreationRequest): void { + executeWorktreeCreation(creationId, request).catch((error: unknown) => { + console.error('worktree create: unhandled failure', creationId, error) + const store = useAppStore.getState() + if (!store.pendingWorktreeCreations[creationId]) { + return + } + const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error)) + store.updatePendingWorktreeCreation(creationId, { status: 'error', error: message }) + // Why: the panel renders this error inline while its surface is visible; + // only announce it separately after the user has navigated away. + if (!(store.activeView === 'terminal' && store.activePendingCreationId === creationId)) { + toast.error(message) + } + }) +} + function revealPendingCreation( creationId: string, request: WorktreeCreationRequest, @@ -62,7 +86,7 @@ export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): // client over plain HTTP). createBrowserUuid falls back to getRandomValues. const creationId = createBrowserUuid() revealPendingCreation(creationId, request, getInitialWorktreeCreationPhase(request)) - void executeWorktreeCreation(creationId, request) + startWorktreeCreation(creationId, request) return creationId } @@ -101,7 +125,7 @@ export function continueBackgroundWorktreeCreation( store.setActiveView('terminal') store.setSidebarOpen(true) } - void executeWorktreeCreation(creationId, request) + startWorktreeCreation(creationId, request) return true } @@ -133,5 +157,5 @@ export function retryBackgroundWorktreeCreation(creationId: string): void { ) return } - void executeWorktreeCreation(creationId, entry.request) + startWorktreeCreation(creationId, entry.request) } diff --git a/src/renderer/src/lib/worktree-sleep-intent.ts b/src/renderer/src/lib/worktree-sleep-intent.ts index 7beac240803..6512abec692 100644 --- a/src/renderer/src/lib/worktree-sleep-intent.ts +++ b/src/renderer/src/lib/worktree-sleep-intent.ts @@ -1,13 +1,69 @@ +// Why: a slept workspace keeps its panes mounted with only dead PTYs behind them. +// Any pane connect that runs while the marker is set waits here, and the clear +// that marks the workspace awake resumes every waiting connect. const sleepingWorktreeIds = new Set() +const tearingDownWorktreeIds = new Set() +const wakeListenersByWorktreeId = new Map void>>() export function markWorktreeSleepIntent(worktreeId: string): void { sleepingWorktreeIds.add(worktreeId) } -export function clearWorktreeSleepIntent(worktreeId: string): void { +/** + * Why: a spawn that resolves while the sleep teardown is still awaiting its host + * would bind a PTY and clear the marker, waking every waiting pane mid-sleep. + * Binds during the teardown window are not wakes. + */ +export async function withWorktreeSleepTeardown( + worktreeId: string, + teardown: () => Promise +): Promise { + tearingDownWorktreeIds.add(worktreeId) + try { + return await teardown() + } finally { + tearingDownWorktreeIds.delete(worktreeId) + } +} + +export function clearWorktreeSleepIntent(worktreeId: string | null): void { + if (!worktreeId || tearingDownWorktreeIds.has(worktreeId)) { + return + } + if (!sleepingWorktreeIds.delete(worktreeId)) { + return + } + const listeners = wakeListenersByWorktreeId.get(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) + for (const listener of listeners ?? []) { + try { + listener() + } catch (error) { + // Why: one pane's connect failure must not strand its siblings or throw out of a store action. + console.error('[sleep-intent] wake listener failed', { worktreeId, error }) + } + } +} + +// Why: a purged worktree must not wake its panes; they are being unmounted. +export function forgetWorktreeSleepIntent(worktreeId: string): void { sleepingWorktreeIds.delete(worktreeId) + tearingDownWorktreeIds.delete(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) } export function hasWorktreeSleepIntent(worktreeId: string | null): boolean { return worktreeId !== null && sleepingWorktreeIds.has(worktreeId) } + +export function onWorktreeSleepIntentCleared(worktreeId: string, listener: () => void): () => void { + const listeners = wakeListenersByWorktreeId.get(worktreeId) ?? new Set<() => void>() + listeners.add(listener) + wakeListenersByWorktreeId.set(worktreeId, listeners) + return () => { + listeners.delete(listener) + if (listeners.size === 0 && wakeListenersByWorktreeId.get(worktreeId) === listeners) { + wakeListenersByWorktreeId.delete(worktreeId) + } + } +} diff --git a/src/renderer/src/runtime/runtime-host-connection-state.test.ts b/src/renderer/src/runtime/runtime-host-connection-state.test.ts index 8db0c0eb010..30ee06a5e98 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.test.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import type { RuntimeStatus } from '../../../shared/runtime-types' import { isConnectedRuntimeHostState, + isDisconnectedRuntimeHostState, runtimeHostConnectionState, + runtimeHostConnectionStateForEntry, runtimeStatusForOverall } from './runtime-host-connection-state' @@ -177,3 +179,72 @@ describe('runtime host connection state', () => { ).toBe('disconnected') }) }) + +describe('runtime host connection state for a recorded status entry', () => { + it('separates a host that was never probed from one a probe found unreachable', () => { + // The sidebar read raw truthiness, which collapsed these two into the same red glyph. + expect(runtimeHostConnectionStateForEntry(undefined)).toBe('checking') + expect(runtimeHostConnectionStateForEntry({ status: null })).toBe('disconnected') + }) + + it('reads the remote-control diagnostics recorded beside a failed probe', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: null, + remoteControl: remoteControl('reconnecting') + }) + ).toBe('reconnecting') + }) + + it('agrees with the status bar that a closed control channel is disconnected', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: makeStatus({ remoteControl: remoteControl('closed') }) + }) + ).toBe('disconnected') + }) + + it('names only the disconnected verdict as disconnected', () => { + expect(isDisconnectedRuntimeHostState('disconnected')).toBe(true) + for (const state of [ + 'connected', + 'checking', + 'reconnecting', + 'runtime-unavailable', + 'workspace-window-closed' + ] as const) { + expect(isDisconnectedRuntimeHostState(state)).toBe(false) + } + }) +}) + +function remoteControl( + state: NonNullable['state'] +): NonNullable { + return { + state, + pendingRequestCount: 0, + subscriptionCount: 0, + reconnectAttempt: 1, + lastConnectedAt: null, + lastClose: null, + lastError: null + } +} + +it('does not report reconnecting after verification is terminally blocked', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: null, + snapshot: { + environmentId: 'browser', + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status: null, + verification: 'blocked', + transport: 'disconnected' + } + }) + ).toBe('disconnected') +}) diff --git a/src/renderer/src/runtime/runtime-host-connection-state.ts b/src/renderer/src/runtime/runtime-host-connection-state.ts index 6094c995804..c10477415a1 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' import type { RuntimeStatus } from '../../../shared/runtime-types' import { isRuntimeWorkspaceWindowClosed } from '../../../shared/runtime-workspace-window-availability' @@ -97,3 +98,44 @@ export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): state === 'connected' || state === 'runtime-unavailable' || state === 'workspace-window-closed' ) } + +/** + * Only this verdict earns the destructive glyph. 'checking' and 'reconnecting' are + * unverifiable, not down, per docs/reference/ssh-execution-boundary.md. + */ +export function isDisconnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean { + return state === 'disconnected' +} + +/** The same derivation, read straight off a recorded status entry. */ +export function runtimeHostConnectionStateForEntry( + entry: + | { + status: RuntimeStatus | null + remoteControl?: RuntimeStatus['remoteControl'] | null + snapshot?: RuntimeHostStatusSnapshot + } + | null + | undefined +): RuntimeHostConnectionState { + if (entry?.snapshot) { + const snapshot = entry.snapshot + if (snapshot.retired || snapshot.verification === 'blocked') { + return 'disconnected' + } + if (snapshot.transport === 'disconnected') { + return 'reconnecting' + } + if (snapshot.verification === 'checking' && !entry.status) { + return 'checking' + } + if (snapshot.transport === 'ready' && snapshot.verification !== 'verified') { + return 'runtime-unavailable' + } + } + return runtimeHostConnectionState({ + hasStatusEntry: Boolean(entry), + status: entry?.status ?? null, + remoteControl: entry?.remoteControl ?? entry?.status?.remoteControl ?? null + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts new file mode 100644 index 00000000000..d1992a27030 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { TerminalTab, TerminalTabRecoveryLedger } from '../../../../shared/terminal-tab-types' +import { buildMirroredTerminalTabs } from './terminal-build' +import { toWebTerminalSurfaceTabId } from '../web-terminal-surface-id' + +/** + * The recovery ledger is client-local. The host publishes no such field, so a + * rebuild that does not carry the existing one restores this tab's remount + * allowance on EVERY snapshot — which is the remount storm (b5cfc6ca) the + * ledger exists to end, re-armed on the host's publication cadence. + * + * `generation` is deliberately not asserted here: the host carries none and the + * rebuild emits none, which is why `isSupersededLedger` compares strictly + * forward (`>`) rather than `!==`. See terminal-tab-recovery-ledger.ts. + */ +const WORKTREE = 'repo-1::worktree-1' +const ENVIRONMENT = 'env-1' +const HOST_TAB = 'host-tab-1' + +const LEDGER: TerminalTabRecoveryLedger = { + attemptedAt: [1_000], + generation: 1, + outcome: 'failed', + startedAt: 1_000, + reason: 'reattach-unverifiable', + tabGeneration: 1 +} + +function snapshot(): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'surface-1', + parentTabId: HOST_TAB, + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'handle-1', + isActive: true + } + ] + } as RuntimeMobileSessionTabsResult +} + +function rebuild(existing?: Partial): TerminalTab { + const localTabId = toWebTerminalSurfaceTabId(HOST_TAB) + const existingById = new Map( + existing ? [[localTabId, { id: localTabId, ...existing } as TerminalTab]] : [] + ) + const [mirrored] = buildMirroredTerminalTabs(snapshot(), ENVIRONMENT, existingById, {}, 0, 1_000) + return mirrored!.tab +} + +describe('buildMirroredTerminalTabs recovery ledger', () => { + it('carries the client-local ledger across a host snapshot rebuild', () => { + expect(rebuild({ recovery: LEDGER }).recovery).toEqual(LEDGER) + }) + + it('emits none for a tab that never recovered', () => { + expect(rebuild({}).recovery).toBeUndefined() + }) + + it('emits none for a tab the client has never seen', () => { + expect(rebuild().recovery).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts index dfc3759009d..aacb445bbe3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -171,6 +171,10 @@ export function buildMirroredTerminalTabs( // without this dropped the client's agent-prompt label on every snapshot. ...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}), ...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}), + // Why: the recovery ledger is client-local and the host carries none, so + // rebuilding without it would restore this tab's remount allowance on + // every snapshot — the counting loop recovery is meant to end (b5cfc6ca). + ...(existing?.recovery ? { recovery: existing.recovery } : {}), ...(quickCommandLabel ? { quickCommandLabel } : {}), ...(startupCwd ? { startupCwd } : {}), customTitle: existing?.customTitle ?? null, diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts deleted file mode 100644 index 22ba23cbbb1..00000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' -import type { RuntimeEnvironmentStatus } from './runtime-status' - -const diagnosticsGenerationByEnvironment = new Map() - -export function updateRuntimeEnvironmentStatusOverlay( - state: Map, - environmentId: string, - status: RuntimeEnvironmentStatus -): Map { - const current = state.get(environmentId) - if (!current || current.status?.runtimeId !== status.status?.runtimeId) { - return state - } - return new Map(state).set(environmentId, status) -} - -export function acceptRuntimeEnvironmentDiagnosticsGeneration( - environmentId: string, - transportGeneration: number -): boolean { - const previous = diagnosticsGenerationByEnvironment.get(environmentId) - if (previous !== undefined && transportGeneration < previous) { - return false - } - diagnosticsGenerationByEnvironment.set(environmentId, transportGeneration) - return true -} - -export function clearRuntimeEnvironmentDiagnosticsGenerationsForTests(): void { - diagnosticsGenerationByEnvironment.clear() -} - -export function mergePushedRuntimeEnvironmentDiagnostics(args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - current: RuntimeEnvironmentStatus | undefined - publish: (status: RuntimeEnvironmentStatus) => void -}): void { - if ( - !args.current?.status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) || - !acceptRuntimeEnvironmentDiagnosticsGeneration(args.environmentId, args.transportGeneration) - ) { - return - } - args.publish({ - ...args.current, - status: { ...args.current.status, remoteControl: args.diagnostics } - }) -} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts deleted file mode 100644 index e51c8621bae..00000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' -import type { AppState } from '../types' -import type { RuntimeEnvironmentStatus } from './runtime-status' -import * as diagnosticsGeneration from './runtime-status-diagnostics-generation' -import * as runtimeStatusRecheck from './runtime-status-recheck' - -export function updateRuntimeStatusStore( - state: AppState, - updater: (state: Map) => Map -): AppState | Pick { - const next = updater(state.runtimeStatusByEnvironmentId) - return next === state.runtimeStatusByEnvironmentId - ? state - : { runtimeStatusByEnvironmentId: next } -} - -export function publishRuntimeEnvironmentDiagnostics(args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - getCurrent: () => RuntimeEnvironmentStatus | undefined - updateState: (status: RuntimeEnvironmentStatus) => boolean - afterPublish?: (status: RuntimeEnvironmentStatus) => void -}): void { - diagnosticsGeneration.mergePushedRuntimeEnvironmentDiagnostics({ - environmentId: args.environmentId, - transportGeneration: args.transportGeneration, - diagnostics: args.diagnostics, - current: args.getCurrent(), - publish: (status) => { - if (args.updateState(status)) { - args.afterPublish?.(status) - } - } - }) -} - -export function applyRuntimeEnvironmentStatusOverlay(args: { - environmentId: string - status: RuntimeEnvironmentStatus - setState: ( - updater: (state: Map) => Map - ) => void -}): boolean { - let updated = false - args.setState((state) => { - const next = diagnosticsGeneration.updateRuntimeEnvironmentStatusOverlay( - state, - args.environmentId, - args.status - ) - updated = next !== state - return next - }) - return updated -} - -export function createRuntimeEnvironmentDiagnosticsPublisher(args: { - getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined - setState: ( - updater: (state: Map) => Map - ) => void - afterPublish: (environmentId: string, status: RuntimeEnvironmentStatus) => void -}): (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics -}) => void { - return (event) => - publishRuntimeEnvironmentDiagnostics({ - ...event, - getCurrent: () => args.getCurrent(event.environmentId), - updateState: (status) => - applyRuntimeEnvironmentStatusOverlay({ - environmentId: event.environmentId, - status, - setState: args.setState - }), - afterPublish: (status) => args.afterPublish(event.environmentId, status) - }) -} - -export function createRuntimeEnvironmentDiagnosticsSlicePublisher(args: { - getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined - setState: ( - updater: (state: Map) => Map - ) => void - getStore: () => AppState - getConnectionGeneration: (environmentId: string) => number -}): (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics -}) => void { - return createRuntimeEnvironmentDiagnosticsPublisher({ - getCurrent: args.getCurrent, - setState: args.setState, - afterPublish: (environmentId, status) => - runtimeStatusRecheck.reconcileRuntimeStatusForSlice( - environmentId, - status.status, - args.getStore, - () => args.getConnectionGeneration(environmentId) - ) - }) -} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts b/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts deleted file mode 100644 index 9faecdcabf1..00000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { create } from 'zustand' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' - -function makeStatus(overrides: Partial = {}): RuntimeStatus { - return { - runtimeId: 'runtime-a', - rendererGraphEpoch: 0, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 3, - liveLeafCount: 0, - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 3, - capabilities: ['browser.screencast.v1'], - ...overrides - } as RuntimeStatus -} - -function createSliceStore() { - return create()((...a) => ({ - ...createRuntimeStatusSlice(...(a as unknown as Parameters)) - })) -} - -describe('runtime-status diagnostics', () => { - it('merges transport diagnostics into the complete status and fences stale pushes', () => { - const store = createSliceStore() - const status = makeStatus({ - capabilities: ['browser.screencast.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) - const closed = { - state: 'closed' as const, - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 1, - lastClose: { code: 1006, reason: 'network' }, - lastError: 'connection lost' - } - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 3, - diagnostics: closed - }) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toMatchObject({ - runtimeId: 'runtime-a', - capabilities: expect.arrayContaining([ - 'browser.screencast.v1', - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY - ]), - liveTabCount: 3, - remoteControl: closed - }) - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 2, - diagnostics: { ...closed, state: 'ready' } - }) - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl?.state - ).toBe('closed') - }) - - it('ignores diagnostics after the latest status drops shared-control support', () => { - const store = createSliceStore() - const status = makeStatus({ capabilities: [] }) - store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) - - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 3, - diagnostics: { - state: 'reconnecting', - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 1, - lastClose: { code: 1006, reason: 'network' }, - lastError: 'connection lost' - } - }) - - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(status) - }) -}) diff --git a/src/renderer/src/store/slices/runtime-status-recheck.test.ts b/src/renderer/src/store/slices/runtime-status-recheck.test.ts deleted file mode 100644 index 9f09580c390..00000000000 --- a/src/renderer/src/store/slices/runtime-status-recheck.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { create } from 'zustand' -import { toast } from 'sonner' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { - clearRuntimeEnvironmentConnectionGenerationsForTests, - createRuntimeStatusSlice, - setRuntimeEnvironmentConnectionGenerationForTests, - type RuntimeStatusSlice -} from './runtime-status' -import { clearRuntimeStatusRechecksForTests } from './runtime-status-recheck' - -vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) - -beforeEach(() => { - vi.useFakeTimers() - clearRuntimeStatusRechecksForTests() - clearRuntimeEnvironmentConnectionGenerationsForTests() - vi.mocked(toast.warning).mockReset() -}) - -afterEach(() => { - clearRuntimeStatusRechecksForTests() - vi.useRealTimers() - vi.unstubAllGlobals() -}) - -describe('runtime status recheck', () => { - it('publishes an observe-only ready result through the setter', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('ready'))) - const store = createStore(getStatus) - - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).toHaveBeenCalledWith({ - selector: 'env-a', - timeoutMs: 10_000, - observeOnly: true - }) - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl - ).toMatchObject({ - state: 'ready' - }) - await vi.advanceTimersByTimeAsync(120_000) - expect(getStatus).toHaveBeenCalledOnce() - }) - - it('continues indefinitely on the capped ladder, including unchanged publishes', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('reconnecting'))) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('reconnecting'), - checkedAt: 1 - }) - - await vi.advanceTimersByTimeAsync(3_000 + 6_000 + 12_000 + 30_000 + 60_000 + 60_000) - - expect(getStatus).toHaveBeenCalledTimes(6) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.checkedAt).toBe(1) - }) - - it('cancels on removal, capability loss, and null without probing again', async () => { - const getStatus = vi.fn() - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_authenticated'), - checkedAt: 1 - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: { ...status('awaiting_authenticated'), capabilities: [] }, - checkedAt: 2 - }) - await vi.advanceTimersByTimeAsync(60_000) - expect(getStatus).not.toHaveBeenCalled() - - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 3 - }) - store.getState().setRuntimeEnvironments([]) - await vi.advanceTimersByTimeAsync(60_000) - expect(getStatus).not.toHaveBeenCalled() - }) - - it('cancels an armed probe when the connection generation changes', async () => { - const getStatus = vi.fn() - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - - setRuntimeEnvironmentConnectionGenerationForTests('env-a', 2) - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).not.toHaveBeenCalled() - }) - - it('restarts the ladder for a newly published connection generation', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('ready', 'rt-next'))) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready', 'rt-next'), - checkedAt: 2 - }) - - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).toHaveBeenCalledOnce() - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( - 'rt-next' - ) - }) - - it('discards an in-flight result after a ready publish bumps the epoch', async () => { - const pending = deferred>() - const getStatus = vi.fn().mockReturnValue(pending.promise) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - await vi.advanceTimersByTimeAsync(3_000) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('ready'), - checkedAt: 2 - }) - - pending.resolve(response(status('reconnecting'))) - await Promise.resolve() - await Promise.resolve() - - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl - ).toMatchObject({ - state: 'ready' - }) - }) - - it('keeps setter side effects when a recheck discovers disconnection', async () => { - const getStatus = vi.fn().mockResolvedValue({ - id: 'status.get', - ok: false, - error: { - code: 'runtime_unavailable', - message: 'offline', - data: { remoteControl: status('reconnecting').remoteControl } - }, - _meta: { runtimeId: 'rt' } - }) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - - await vi.advanceTimersByTimeAsync(3_000) - - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBeNull() - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.remoteControl).toMatchObject( - { - state: 'reconnecting' - } - ) - expect(toast.warning).toHaveBeenCalledOnce() - }) -}) - -function createStore(getStatus: ReturnType) { - vi.stubGlobal('window', { - api: { runtimeEnvironments: { getStatus, list: vi.fn() } } - }) - const store = create()((...args) => ({ - ...createRuntimeStatusSlice(...(args as unknown as Parameters)) - })) - store.getState().setRuntimeEnvironments([environment()]) - return store -} - -function status( - controlState: NonNullable['state'], - runtimeId = 'rt' -): RuntimeStatus { - return { - runtimeId, - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY], - remoteControl: { - state: controlState, - pendingRequestCount: 0, - subscriptionCount: 0, - reconnectAttempt: 1, - lastConnectedAt: null, - lastClose: null, - lastError: null - } - } as RuntimeStatus -} - -function response(result: RuntimeStatus) { - return { id: 'status.get', ok: true as const, result, _meta: { runtimeId: result.runtimeId } } -} - -function environment(): PublicKnownRuntimeEnvironment { - return { - id: 'env-a', - name: 'Dev Box', - createdAt: 1, - updatedAt: 1, - lastUsedAt: null, - runtimeId: 'rt', - endpoints: [{ id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], - preferredEndpointId: 'ws' - } -} - -function deferred() { - let resolve: (value: T) => void = () => {} - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} diff --git a/src/renderer/src/store/slices/runtime-status-recheck.ts b/src/renderer/src/store/slices/runtime-status-recheck.ts deleted file mode 100644 index 303e5b4318d..00000000000 --- a/src/renderer/src/store/slices/runtime-status-recheck.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' -import { extractRuntimeTransportDiagnostics } from '@/runtime/runtime-status-probe-diagnostics' -import type { RuntimeEnvironmentStatus } from './runtime-status' - -const RECHECK_DELAYS_MS = [3_000, 6_000, 12_000, 30_000, 60_000] - -type RecheckState = { - epoch: number - attempt: number - timer: ReturnType | null - inFlight: boolean - connectionGeneration: number - environmentExists: () => boolean - getConnectionGeneration: () => number - publish: (status: RuntimeEnvironmentStatus) => void -} - -type RuntimeStatusStore = { - runtimeEnvironments: readonly { id: string }[] - setRuntimeEnvironmentStatus: (environmentId: string, status: RuntimeEnvironmentStatus) => void -} - -const rechecks = new Map() - -export function reconcileRuntimeStatusRecheck(args: { - environmentId: string - status: RuntimeStatus | null - connectionGeneration: number - environmentExists: () => boolean - getConnectionGeneration: () => number - publish: (status: RuntimeEnvironmentStatus) => void -}): void { - if (!shouldRecheck(args.status)) { - cancelRuntimeStatusRecheck(args.environmentId) - return - } - let state = rechecks.get(args.environmentId) - if (state && state.connectionGeneration !== args.connectionGeneration) { - cancelRuntimeStatusRecheck(args.environmentId) - state = undefined - } - if (!state) { - state = { - epoch: 0, - attempt: 0, - timer: null, - inFlight: false, - connectionGeneration: args.connectionGeneration, - environmentExists: args.environmentExists, - getConnectionGeneration: args.getConnectionGeneration, - publish: args.publish - } - rechecks.set(args.environmentId, state) - } else { - state.connectionGeneration = args.connectionGeneration - state.environmentExists = args.environmentExists - state.getConnectionGeneration = args.getConnectionGeneration - state.publish = args.publish - } - armRuntimeStatusRecheck(args.environmentId, state) -} - -export function reconcileRuntimeStatusForSlice( - environmentId: string, - status: RuntimeStatus | null, - get: () => RuntimeStatusStore, - getConnectionGeneration: () => number -): void { - reconcileRuntimeStatusRecheck({ - environmentId, - status, - connectionGeneration: getConnectionGeneration(), - environmentExists: () => - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration, - publish: (nextStatus) => get().setRuntimeEnvironmentStatus(environmentId, nextStatus) - }) -} - -export function cancelRuntimeStatusRecheck(environmentId: string): void { - const state = rechecks.get(environmentId) - if (!state) { - return - } - state.epoch += 1 - if (state.timer) { - clearTimeout(state.timer) - } - rechecks.delete(environmentId) -} - -export function cancelRuntimeStatusRechecks(environmentIds: Iterable): void { - for (const environmentId of environmentIds) { - cancelRuntimeStatusRecheck(environmentId) - } -} - -export function clearRuntimeStatusRechecksForTests(): void { - cancelRuntimeStatusRechecks([...rechecks.keys()]) -} - -function shouldRecheck(status: RuntimeStatus | null): boolean { - return Boolean( - status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && - status.remoteControl && - status.remoteControl.state !== 'ready' - ) -} - -function armRuntimeStatusRecheck(environmentId: string, state: RecheckState): void { - if (state.timer || state.inFlight) { - return - } - const delay = RECHECK_DELAYS_MS[Math.min(state.attempt, RECHECK_DELAYS_MS.length - 1)] - const generation = state.connectionGeneration - state.attempt += 1 - state.timer = setTimeout( - () => void fireRuntimeStatusRecheck(environmentId, state, generation), - delay - ) -} - -async function fireRuntimeStatusRecheck( - environmentId: string, - state: RecheckState, - generation: number -): Promise { - state.timer = null - const epoch = state.epoch - if ( - rechecks.get(environmentId) !== state || - !state.environmentExists() || - state.getConnectionGeneration() !== generation - ) { - cancelRuntimeStatusRecheck(environmentId) - return - } - state.inFlight = true - let nextEntry: RuntimeEnvironmentStatus - try { - const response = await window.api.runtimeEnvironments.getStatus({ - selector: environmentId, - timeoutMs: 10_000, - observeOnly: true - }) - nextEntry = { status: unwrapRuntimeRpcResult(response), checkedAt: Date.now() } - } catch (error: unknown) { - const remoteControl = extractRuntimeTransportDiagnostics(error) - nextEntry = { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - } - } - state.inFlight = false - if ( - rechecks.get(environmentId) !== state || - state.epoch !== epoch || - !state.environmentExists() || - state.getConnectionGeneration() !== generation - ) { - return - } - state.publish(nextEntry) -} diff --git a/src/renderer/src/store/slices/runtime-status-refresh.ts b/src/renderer/src/store/slices/runtime-status-refresh.ts index 638a3fae636..fb27eec6a43 100644 --- a/src/renderer/src/store/slices/runtime-status-refresh.ts +++ b/src/renderer/src/store/slices/runtime-status-refresh.ts @@ -15,6 +15,22 @@ export async function refreshRuntimeEnvironmentStatus( selector: environmentId, timeoutMs }) + if (window.api.runtimeEnvironments.getStatusSnapshots) { + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + const snapshot = snapshots.find((entry) => entry.environmentId === environmentId) + if (snapshot) { + publish({ + snapshot, + status: snapshot.verification === 'verified' ? snapshot.status : null, + checkedAt: snapshot.checkedAt + }) + } + } catch (error) { + console.error('Failed to read runtime host status snapshot:', error) + } + return response.ok + } const status = unwrapRuntimeRpcResult(response) if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) { return false diff --git a/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts b/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts index 0ba4d645026..d4b788cd49a 100644 --- a/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts +++ b/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts @@ -73,14 +73,10 @@ describe('restored client-hosted browser host attach on reachability', () => { }) }) - // The reconnect policy suppresses the *failure* publish only. A probe that answered still owes - // both recovery follow-ups, or a restored client-hosted page never comes back after the gap. - it('runs both recovery follow-ups on a success when the caller opted out of publishing failures', async () => { + it('runs both recovery follow-ups after a successful refresh', async () => { stubApi(vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a'))) - await storeWithRestoredHandles(true) - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, { publishUnreachable: false }) + await storeWithRestoredHandles(true).getState().refreshRuntimeEnvironmentStatus('env-a') expect(prepareBrowserClientHostPlacement).toHaveBeenCalledWith({ selector: 'env-a', @@ -89,24 +85,12 @@ describe('restored client-hosted browser host attach on reachability', () => { expect(replayClientHostedBrowserCloseIntents).toHaveBeenCalledWith('env-a', expect.anything()) }) - // Under either policy a failed probe owes *no* follow-ups: it verified nothing, so there is no - // recovered host to reattach restored pages to and no one to replay closes at. - it.each([ - { name: 'the default policy', options: undefined }, - { name: 'a caller that opted out of publishing', options: { publishUnreachable: false } } - ])( - 'starts no browser client host when the environment is unreachable: $name', - async (scenario) => { - stubApi(vi.fn().mockRejectedValue(new Error('unreachable'))) - - await storeWithRestoredHandles(true) - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, scenario.options) - - expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() - expect(replayClientHostedBrowserCloseIntents).not.toHaveBeenCalled() - } - ) + it('runs no recovery follow-ups when the environment is unreachable', async () => { + stubApi(vi.fn().mockRejectedValue(new Error('unreachable'))) + await storeWithRestoredHandles(true).getState().refreshRuntimeEnvironmentStatus('env-a') + expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() + expect(replayClientHostedBrowserCloseIntents).not.toHaveBeenCalled() + }) it('starts no browser client host for restored pages the server hosts', async () => { stubApi(vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a'))) diff --git a/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts b/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts new file mode 100644 index 00000000000..9804b8a6e12 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { SkillDiscoveryResult } from '../../../../shared/skills' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' + +const discoverSkillsForRuntimeTarget = vi.hoisted(() => + vi.fn<(runtimeTarget: RuntimeClientTarget) => Promise>() +) + +vi.mock('@/runtime/runtime-skills-client', () => ({ discoverSkillsForRuntimeTarget })) +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: {} } + +import { + discoverInstalledAgentSkills, + getCachedSkillDiscovery, + resetSkillDiscoveryCacheForTests +} from '@/hooks/installed-agent-skill-discovery' +import { getInstalledAgentSkillDiscoveryCacheSizeForTests } from '@/hooks/installed-agent-skill-discovery-cache' +import { createTestStore } from './store-test-helpers' + +function environment(id: string, pairingRevision = 1): PublicKnownRuntimeEnvironment { + return { + id, + name: id, + createdAt: 1, + updatedAt: 1, + pairingRevision, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: `ws-${id}`, kind: 'websocket', label: id, endpoint: `wss://${id}` }], + preferredEndpointId: `ws-${id}` + } +} + +function remote(environmentId: string): RuntimeClientTarget { + return { kind: 'environment', environmentId } +} + +function result(scannedAt: number): SkillDiscoveryResult { + return { skills: [], sources: [], scannedAt } +} + +function deferred(): { + promise: Promise + resolve: (value: SkillDiscoveryResult) => void +} { + let resolve!: (value: SkillDiscoveryResult) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +afterEach(() => { + resetSkillDiscoveryCacheForTests() + discoverSkillsForRuntimeTarget.mockReset() +}) + +describe('runtime environment skill-cache eviction', () => { + it('releases retired runtime entries while preserving local and WSL cached scans', async () => { + const store = createTestStore() + discoverSkillsForRuntimeTarget.mockResolvedValue(result(1)) + await discoverInstalledAgentSkills(false) + await discoverInstalledAgentSkills(false, { runtime: 'wsl', wslDistro: 'Ubuntu' }) + for (let index = 0; index < 512; index++) { + const id = `temporary-${index}` + store.getState().setRuntimeEnvironments([environment(id)]) + await discoverInstalledAgentSkills(false, undefined, remote(id)) + store.getState().setRuntimeEnvironments([]) + } + + expect(getInstalledAgentSkillDiscoveryCacheSizeForTests()).toBe(2) + expect(getCachedSkillDiscovery('host')).toEqual(result(1)) + expect(getCachedSkillDiscovery('wsl:Ubuntu')).toEqual(result(1)) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(514) + }) + + it('keeps a surviving runtime scan in flight when another runtime is removed', async () => { + const store = createTestStore() + const survivor = deferred() + store.getState().setRuntimeEnvironments([environment('a'), environment('b')]) + discoverSkillsForRuntimeTarget.mockReturnValue(survivor.promise) + const first = discoverInstalledAgentSkills(false, undefined, remote('b')) + + store.getState().setRuntimeEnvironments([environment('b')]) + const joined = discoverInstalledAgentSkills(false, undefined, remote('b')) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledOnce() + survivor.resolve(result(2)) + + await expect(Promise.all([first, joined])).resolves.toEqual([result(2), result(2)]) + }) + + it.each(['before', 'after'] as const)( + 'a retired scan finishing %s its replacement cannot overwrite it or detach its pending slot', + async (order) => { + const store = createTestStore() + const stale = deferred() + const current = deferred() + store.getState().setRuntimeEnvironments([environment('a')]) + discoverSkillsForRuntimeTarget + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(current.promise) + const oldRequest = discoverInstalledAgentSkills(false, undefined, remote('a')) + store.getState().setRuntimeEnvironments([environment('a', 2)]) + const newRequest = discoverInstalledAgentSkills(true, undefined, remote('a')) + if (order === 'after') { + current.resolve(result(2)) + await newRequest + } + stale.resolve(result(1)) + await oldRequest + const joined = discoverInstalledAgentSkills(false, undefined, remote('a')) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(2) + current.resolve(result(2)) + + await expect(Promise.all([newRequest, joined])).resolves.toEqual([result(2), result(2)]) + expect(getCachedSkillDiscovery('runtime:a')).toEqual(result(2)) + } + ) + + it('rescans only the removed runtime environment', async () => { + const store = createTestStore() + store.getState().setRuntimeEnvironments([environment('env-a'), environment('env-b')]) + discoverSkillsForRuntimeTarget + .mockResolvedValueOnce(result(1)) + .mockResolvedValueOnce(result(2)) + .mockResolvedValueOnce(result(3)) + + await discoverInstalledAgentSkills(false, undefined, remote('env-a')) + await discoverInstalledAgentSkills(false, undefined, remote('env-b')) + store.getState().setRuntimeEnvironments([environment('env-b')]) + + await expect(discoverInstalledAgentSkills(false, undefined, remote('env-a'))).resolves.toEqual( + result(3) + ) + await expect(discoverInstalledAgentSkills(false, undefined, remote('env-b'))).resolves.toEqual( + result(2) + ) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(3) + }) + + it('evicts a re-paired runtime without churning an unchanged runtime', async () => { + const store = createTestStore() + store.getState().setRuntimeEnvironments([environment('env-a')]) + discoverSkillsForRuntimeTarget.mockResolvedValueOnce(result(1)).mockResolvedValueOnce(result(2)) + + await discoverInstalledAgentSkills(false, undefined, remote('env-a')) + store.getState().setRuntimeEnvironments([environment('env-a')]) + await expect(discoverInstalledAgentSkills(false, undefined, remote('env-a'))).resolves.toEqual( + result(1) + ) + + store.getState().setRuntimeEnvironments([environment('env-a', 2)]) + await expect(discoverInstalledAgentSkills(false, undefined, remote('env-a'))).resolves.toEqual( + result(2) + ) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(2) + }) + + it('does not let an in-flight scan restore a removed runtime cache entry', async () => { + const store = createTestStore() + const staleScan = deferred() + const freshScan = deferred() + store.getState().setRuntimeEnvironments([environment('env-a')]) + discoverSkillsForRuntimeTarget + .mockReturnValueOnce(staleScan.promise) + .mockReturnValueOnce(freshScan.promise) + + const staleRequest = discoverInstalledAgentSkills(false, undefined, remote('env-a')) + store.getState().setRuntimeEnvironments([]) + staleScan.resolve(result(1)) + await expect(staleRequest).resolves.toEqual(result(1)) + + const freshRequest = discoverInstalledAgentSkills(false, undefined, remote('env-a')) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(2) + freshScan.resolve(result(2)) + await expect(freshRequest).resolves.toEqual(result(2)) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.test.ts b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts new file mode 100644 index 00000000000..fa437065b28 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { toast } from 'sonner' +import { + createRuntimeStatusSlice, + clearRuntimeEnvironmentConnectionGenerationsForTests, + type RuntimeStatusSlice +} from './runtime-status' +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' + +vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) +vi.mock('@/runtime/restored-client-hosted-browser-host-attach', () => ({ + ensureBrowserClientHostsForRestoredPages: vi.fn(), + ensureBrowserClientHostForRestartedRuntime: vi.fn() +})) +vi.mock('@/runtime/client-hosted-browser-close-intent-replay', () => ({ + replayClientHostedBrowserCloseIntents: vi.fn() +})) + +beforeEach(() => { + clearRuntimeEnvironmentConnectionGenerationsForTests() + vi.clearAllMocks() +}) +const environment = { + id: 'env-a', + name: 'Host', + createdAt: 1, + pairingRevision: 1, + endpoints: [], + preferredEndpointId: '' +} as unknown as PublicKnownRuntimeEnvironment +function store() { + const value = create()((...args) => + createRuntimeStatusSlice(...(args as unknown as Parameters)) + ) + value.getState().setRuntimeEnvironments([environment]) + return value +} +function snapshot( + sequence: number, + patch: Partial = {} +): RuntimeHostStatusSnapshot { + return { + environmentId: 'env-a', + pairingRevision: 1, + sequence, + checkedAt: sequence, + transport: 'ready', + verification: 'verified', + status: { runtimeId: 'rt-1' } as RuntimeStatus, + ...patch + } +} + +it('hydrates both viewers and rejects an older read after a newer publication', () => { + for (const viewer of [store(), store()]) { + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2)) + viewer + .getState() + .applyRuntimeHostStatusSnapshot(snapshot(1, { status: null, verification: 'unavailable' })) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( + 'rt-1' + ) + } +}) + +it('represents failed verification honestly without manufacturing a session restart or toast', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(1)) + const generation = viewer + .getState() + .runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2, { verification: 'unavailable' })) + expect( + runtimeHostConnectionStateForEntry(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')) + ).toBe('runtime-unavailable') + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(3)) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe( + generation + ) + expect(toast.warning).not.toHaveBeenCalled() + viewer + .getState() + .applyRuntimeHostStatusSnapshot(snapshot(4, { status: { runtimeId: 'rt-2' } as RuntimeStatus })) + expect( + viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + ).toBeGreaterThan(generation ?? 0) +}) + +it('retains disconnect ordering and rejects publications for removed or replaced pairings', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(1)) + viewer + .getState() + .applyRuntimeHostStatusSnapshot( + snapshot(3, { retired: true, verification: 'blocked', transport: 'disconnected' }) + ) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2)) + expect( + runtimeHostConnectionStateForEntry(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')) + ).toBe('disconnected') + viewer.getState().setRuntimeEnvironments([{ ...environment, pairingRevision: 2 }]) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(4)) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + viewer.getState().setRuntimeEnvironments([]) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(5, { pairingRevision: 2 })) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) +}) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.ts b/src/renderer/src/store/slices/runtime-status-snapshot.ts new file mode 100644 index 00000000000..b3543471d29 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-snapshot.ts @@ -0,0 +1,43 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import type { AppState } from '../types' +import type { RuntimeEnvironmentStatus } from './runtime-status-types' +import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' +import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' + +export function applyRuntimeHostStatusSnapshot( + snapshot: RuntimeHostStatusSnapshot, + state: AppState, + publishEvidence: (entry: RuntimeEnvironmentStatus) => void +): void { + const environment = state.runtimeEnvironments.find((entry) => entry.id === snapshot.environmentId) + if ( + !environment || + (environment.pairingRevision ?? environment.createdAt) !== snapshot.pairingRevision + ) { + return + } + const previous = state.runtimeStatusByEnvironmentId.get(snapshot.environmentId) + if (previous?.snapshot && previous.snapshot.sequence >= snapshot.sequence) { + return + } + const entry: RuntimeEnvironmentStatus = { + snapshot, + checkedAt: snapshot.checkedAt, + connectionGeneration: previous?.connectionGeneration, + status: snapshot.verification === 'verified' && !snapshot.retired ? snapshot.status : null, + remoteControl: snapshot.remoteControl + } + if (entry.status) { + if (snapshot.remoteControl) { + entry.status = { ...entry.status, remoteControl: snapshot.remoteControl } + } + state.setRuntimeEnvironmentStatus(snapshot.environmentId, entry) + if (previous?.status == null) { + void ensureBrowserClientHostsForRestoredPages(state) + void replayClientHostedBrowserCloseIntents(snapshot.environmentId, state) + } + } else { + // Lost contact or a failed method observes no runtime session ending. + publishEvidence(entry) + } +} diff --git a/src/renderer/src/store/slices/runtime-status-types.ts b/src/renderer/src/store/slices/runtime-status-types.ts index c34feaf07bf..78e47123cf5 100644 --- a/src/renderer/src/store/slices/runtime-status-types.ts +++ b/src/renderer/src/store/slices/runtime-status-types.ts @@ -1,8 +1,9 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' export type RuntimeEnvironmentStatus = { + snapshot?: RuntimeHostStatusSnapshot status: RuntimeStatus | null remoteControl?: RuntimeStatus['remoteControl'] | null appVersion?: string | null @@ -10,11 +11,9 @@ export type RuntimeEnvironmentStatus = { connectionGeneration?: number } -export type RuntimeStatusRefreshOptions = { - publishUnreachable?: boolean -} - export type RuntimeStatusSlice = { + readRuntimeHostStatusSnapshots: () => Promise + applyRuntimeHostStatusSnapshot: (snapshot: RuntimeHostStatusSnapshot) => void runtimeEnvironments: readonly PublicKnownRuntimeEnvironment[] runtimeEnvironmentCatalogHydrated: boolean runtimeEnvironmentCatalogSettled: boolean @@ -26,17 +25,8 @@ export type RuntimeStatusSlice = { status: RuntimeEnvironmentStatus, options?: { suppressDisconnectToast?: boolean } ) => void - publishRuntimeEnvironmentDiagnostics: (args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - }) => void clearRuntimeEnvironmentStatus: (environmentId: string) => void retainRuntimeEnvironmentStatuses: (environmentIds: Iterable) => void - refreshRuntimeEnvironmentStatus: ( - environmentId: string, - timeoutMs?: number, - options?: RuntimeStatusRefreshOptions - ) => Promise + refreshRuntimeEnvironmentStatus: (environmentId: string, timeoutMs?: number) => Promise hydrateRuntimeEnvironmentStatuses: () => Promise } diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 8e3260957bf..fdc843c748a 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -710,33 +710,37 @@ describe('runtime-status slice', () => { clearRuntimeCompatibilityCacheForTests() }) - // Both directions of the failure-publication policy, from one failing probe. A user-initiated - // check publishes the outage it just observed; a caller holding live transport evidence must - // not, because status.get dials its own socket and its failure is unverifiable, not exited. - it.each([ - { name: 'a user-initiated check', options: undefined, publishes: true }, - { name: 'publishUnreachable defaulted', options: {}, publishes: true }, - { - name: 'a caller that opted out of publishing', - options: { publishUnreachable: false }, - publishes: false - } - ])('records null and returns false when a runtime refresh fails: $name', async (scenario) => { + it('records null and returns false when a runtime refresh fails', async () => { const getStatus = vi.fn().mockRejectedValue(new Error('closed')) stubRuntimeEnvironmentApi({ getStatus }) const store = createSliceStore() const cached = makeStatus() store.getState().setRuntimeEnvironmentStatus('env-a', { status: cached, checkedAt: 1 }) - const reachable = await store - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, scenario.options) + const reachable = await store.getState().refreshRuntimeEnvironmentStatus('env-a') - // The dial-answered contract the bridge's bounded retry chain reads is policy-independent. expect(reachable).toBe(false) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe( - scenario.publishes ? null : cached - ) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(null) + }) + + it('preserves successful reachability when reading its snapshot fails', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + getStatus: vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a')), + getStatusSnapshots: vi.fn().mockRejectedValue(new Error('IPC read failed')) + } + } + }) + try { + const store = createSliceStore() + expect(await store.getState().refreshRuntimeEnvironmentStatus('env-a')).toBe(true) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + expect(log).toHaveBeenCalled() + } finally { + log.mockRestore() + } }) it('hydrates saved environments through the single-environment refresh path', async () => { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 10985f389d8..c1d3e88880a 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -1,11 +1,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { RuntimeStatusSlice } from './runtime-status-types' -export type { - RuntimeEnvironmentStatus, - RuntimeStatusRefreshOptions, - RuntimeStatusSlice -} from './runtime-status-types' +export type { RuntimeEnvironmentStatus, RuntimeStatusSlice } from './runtime-status-types' import { runtimeEnvironmentStatusesEqual } from './runtime-environment-status-equality' import { clearRecentRuntimeCompatibilityFailure, @@ -13,6 +9,7 @@ import { } from '@/runtime/runtime-rpc-client' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context' +import { evictInstalledAgentSkillDiscoveryForRuntimeEnvironments } from '@/hooks/installed-agent-skill-discovery' import { dismissRuntimeDisconnectedToast, showRuntimeDisconnectedToast @@ -20,21 +17,16 @@ import { import { reconcileCatalogRows } from './repo-identity-reconcile' import { createRuntimeStatusHydration } from './runtime-status-hydration' import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' -import * as runtimeStatusDiagnostics from './runtime-status-diagnostics-generation' import * as runtimeStatusConnectionGeneration from './runtime-status-connection-generation' import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' import { ensureBrowserClientHostForRestartedRuntime, ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' -import * as runtimeStatusRecheck from './runtime-status-recheck' -import * as runtimeStatusDiagnosticsPublish from './runtime-status-diagnostics-publish' +import { applyRuntimeHostStatusSnapshot } from './runtime-status-snapshot' export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => { - runtimeStatusRecheck.cancelRuntimeStatusRechecks( - runtimeStatusConnectionGeneration.clearRuntimeEnvironmentConnectionGenerations() - ) - runtimeStatusDiagnostics.clearRuntimeEnvironmentDiagnosticsGenerationsForTests() + runtimeStatusConnectionGeneration.clearRuntimeEnvironmentConnectionGenerations() } export { @@ -52,6 +44,15 @@ export const createRuntimeStatusSlice: StateCreator { + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + snapshots.forEach((snapshot) => get().applyRuntimeHostStatusSnapshot(snapshot)) + } catch (error) { + console.error('Failed to read runtime host status:', error) + } + }, + setRuntimeEnvironments: (environments) => { const previousRevisionById = new Map( get().runtimeEnvironments.map((environment) => [ @@ -76,7 +77,6 @@ export const createRuntimeStatusSlice: StateCreator environment.id) .filter((id) => !nextIds.has(id)) - runtimeStatusRecheck.cancelRuntimeStatusRechecks([...removedIds, ...replacedEnvironmentIds]) set((s) => { const keep = new Set(environments.map((environment) => environment.id)) const nextStatuses = new Map(s.runtimeStatusByEnvironmentId) @@ -150,20 +150,35 @@ export const createRuntimeStatusSlice: StateCreator 0) { + evictInstalledAgentSkillDiscoveryForRuntimeEnvironments(retiredEnvironmentIds) get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds) retiredEnvironmentIds.forEach(dismissRuntimeDisconnectedToast) } }, + applyRuntimeHostStatusSnapshot: (snapshot) => + applyRuntimeHostStatusSnapshot(snapshot, get(), (entry) => { + set((s) => ({ + runtimeStatusByEnvironmentId: new Map(s.runtimeStatusByEnvironmentId).set( + snapshot.environmentId, + entry + ) + })) + }), + setRuntimeEnvironmentStatus: (environmentId, status, options) => { const previous = get().runtimeStatusByEnvironmentId.get(environmentId) + if (previous?.snapshot && !status.snapshot) { + return + } + const previousVerifiedStatus = previous?.snapshot?.status ?? previous?.status const pairedDeviceId = status.status?.pairedDeviceId?.trim() // A new runtime id under a known previous one is a restart, not a first connect: the guests are // still ours to host, but only a fresh attach hands them back to the replacement runtime. const runtimeRestarted = Boolean( status.status !== null && - previous?.status != null && - previous.status.runtimeId !== status.status.runtimeId + previousVerifiedStatus != null && + previousVerifiedStatus.runtimeId !== status.status.runtimeId ) // Why: a non-null status proves the runtime just answered, so drop any stale // "offline" compat failure before this online transition fires the @@ -177,7 +192,8 @@ export const createRuntimeStatusSlice: StateCreator - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration: () => - runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration(environmentId), - publish: (entry) => get().setRuntimeEnvironmentStatus(environmentId, entry) - }) if (runtimeRestarted) { void ensureBrowserClientHostForRestartedRuntime(get(), environmentId) } @@ -250,18 +255,7 @@ export const createRuntimeStatusSlice: StateCreator get().runtimeStatusByEnvironmentId.get(environmentId), - setState: (updater) => - set((s) => runtimeStatusDiagnosticsPublish.updateRuntimeStatusStore(s, updater)), - getStore: get, - getConnectionGeneration: - runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration - }), - clearRuntimeEnvironmentStatus: (environmentId) => { - runtimeStatusRecheck.cancelRuntimeStatusRecheck(environmentId) dismissRuntimeDisconnectedToast(environmentId) set((s) => { runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration(environmentId) @@ -278,7 +272,6 @@ export const createRuntimeStatusSlice: StateCreator + refreshRuntimeEnvironmentStatus: (environmentId, timeoutMs = 10_000) => refreshRuntimeEnvironmentStatus(environmentId, timeoutMs, (entry) => { - if (entry.status === null && options?.publishUnreachable === false) { - // Unverifiable, not exited: leave the cached verdict for the caller's retry to settle. + if (entry.snapshot) { + get().applyRuntimeHostStatusSnapshot(entry.snapshot) return } // Why: setRuntimeEnvironmentStatus drops any stale compat failure on a non-null diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.test.ts b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts new file mode 100644 index 00000000000..91872ff245e --- /dev/null +++ b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { AppState } from '../types' +import { buildRemovedSshTargetCleanupPatch } from './ssh-target-cleanup' +import { createTestStore, makeTab } from './store-test-helpers' + +function freezeTabs(tabsByWorktree: AppState['tabsByWorktree']): AppState['tabsByWorktree'] { + for (const tabs of Object.values(tabsByWorktree)) { + tabs.forEach(Object.freeze) + Object.freeze(tabs) + } + return Object.freeze(tabsByWorktree) +} + +describe('SSH target cleanup tab map', () => { + it.each([1, 10])('preserves frozen inputs and untouched identities with stride %i', (stride) => { + const tabsByWorktree = freezeTabs( + Object.fromEntries( + Array.from({ length: 100 }, (_, index) => { + const worktreeId = `folder:${index}` + return [ + worktreeId, + [ + makeTab({ + id: `tab-${index}`, + worktreeId, + ptyId: toAppSshPtyId(index % stride === 0 ? 'removed' : 'other', 'pty'), + pendingActivationSpawn: true + }), + makeTab({ id: `untouched-${index}`, worktreeId, ptyId: 'local-pty' }) + ] + ] + }) + ) + ) + const state = Object.freeze({ ...createTestStore().getState(), tabsByWorktree }) + const patch = buildRemovedSshTargetCleanupPatch(state, 'removed')! + expect(patch.tabsByWorktree).not.toBe(tabsByWorktree) + expect(Object.keys(patch.tabsByWorktree!)).toEqual(Object.keys(tabsByWorktree)) + Object.entries(tabsByWorktree).forEach(([key, tabs], index) => { + const nextTabs = patch.tabsByWorktree![key] + expect(nextTabs[1]).toBe(tabs[1]) + expect(tabs[0].pendingActivationSpawn).toBe(true) + expect(tabs[0].ptyId).not.toBeNull() + if (index % stride === 0) { + expect(nextTabs).not.toBe(tabs) + expect(nextTabs[0]).not.toBe(tabs[0]) + const { pendingActivationSpawn: _, ...retained } = tabs[0] + expect(nextTabs[0]).toEqual({ ...retained, ptyId: null }) + } else { + expect(nextTabs).toBe(tabs) + expect(nextTabs[0]).toBe(tabs[0]) + } + }) + }) + + it('clears folder tabs matched only by split or last-known PTYs', () => { + const removedPtyId = toAppSshPtyId('removed', 'pty') + const tabsByWorktree = freezeTabs({ + 'folder:split': [makeTab({ id: 'split', worktreeId: 'folder:split' })], + 'folder:last': [makeTab({ id: 'last', worktreeId: 'folder:last' })], + 'folder:empty': [makeTab({ id: 'empty', worktreeId: 'folder:empty' })] + }) + const state = Object.freeze({ + ...createTestStore().getState(), + tabsByWorktree, + ptyIdsByTabId: Object.freeze({ split: [removedPtyId] }), + lastKnownRelayPtyIdByTabId: Object.freeze({ last: removedPtyId }), + pendingCodexPaneRestartIds: Object.freeze({ [removedPtyId]: true as const }), + codexRestartNoticeByPtyId: Object.freeze({ + [removedPtyId]: { previousAccountLabel: 'old', nextAccountLabel: 'new' } + }) + }) + const patch = buildRemovedSshTargetCleanupPatch(state, 'removed')! + expect(patch.tabsByWorktree!['folder:split']).not.toBe(tabsByWorktree['folder:split']) + expect(patch.tabsByWorktree!['folder:last']).not.toBe(tabsByWorktree['folder:last']) + expect(patch.tabsByWorktree!['folder:empty']).toBe(tabsByWorktree['folder:empty']) + expect(patch.ptyIdsByTabId).toEqual({ split: [], last: [] }) + expect(patch.lastKnownRelayPtyIdByTabId).toEqual({}) + expect(patch.pendingCodexPaneRestartIds).toEqual({}) + expect(patch.codexRestartNoticeByPtyId).toEqual({}) + }) + + it.each([false, true])('preserves own special keys with null prototype = %s', (nullPrototype) => { + const keys = ['__proto__', 'constructor', 'toString', 'folder:normal'] + const tabsByWorktree = Object.fromEntries( + keys.map((worktreeId) => [ + worktreeId, + [makeTab({ id: `tab-${worktreeId}`, worktreeId, ptyId: toAppSshPtyId('removed', 'pty') })] + ]) + ) + if (nullPrototype) { + Object.setPrototypeOf(tabsByWorktree, null) + } + freezeTabs(tabsByWorktree) + const patch = buildRemovedSshTargetCleanupPatch( + Object.freeze({ ...createTestStore().getState(), tabsByWorktree }), + 'removed' + )! + expect(Object.getPrototypeOf(patch.tabsByWorktree)).toBe(Object.prototype) + expect(Object.keys(patch.tabsByWorktree!)).toEqual(keys) + for (const key of keys) { + expect(Object.hasOwn(patch.tabsByWorktree!, key)).toBe(true) + expect(patch.tabsByWorktree![key][0].ptyId).toBeNull() + expect(tabsByWorktree[key][0].ptyId).not.toBeNull() + } + }) + + it('does not publish or replace the tab map when only target metadata changes', () => { + const store = createTestStore() + const tabsByWorktree = freezeTabs({ + 'folder:other': [ + makeTab({ + id: 'other', + worktreeId: 'folder:other', + ptyId: toAppSshPtyId('other', 'pty') + }) + ] + }) + store.setState({ tabsByWorktree }) + const before = store.getState() + store.getState().clearRemovedSshTargetState('removed') + expect(store.getState()).toBe(before) + store.setState({ deferredSshReconnectTargets: ['removed'] }) + const patch = buildRemovedSshTargetCleanupPatch(store.getState(), 'removed') + expect(patch).toEqual({ deferredSshReconnectTargets: [] }) + store.getState().clearRemovedSshTargetState('removed') + expect(store.getState().tabsByWorktree).toBe(tabsByWorktree) + }) +}) diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 5750894936b..9cac633bedd 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -182,7 +182,10 @@ function clearSshTargetTabPtyState( } } if (nextTabs !== tabs) { - nextTabsByWorktree = { ...nextTabsByWorktree, [worktreeId]: nextTabs } + if (nextTabsByWorktree === state.tabsByWorktree) { + nextTabsByWorktree = { ...nextTabsByWorktree } + } + nextTabsByWorktree[worktreeId] = nextTabs } } diff --git a/src/renderer/src/store/slices/tab-view-mode.test.ts b/src/renderer/src/store/slices/tab-view-mode.test.ts index aa892756b23..56b99c13ba4 100644 --- a/src/renderer/src/store/slices/tab-view-mode.test.ts +++ b/src/renderer/src/store/slices/tab-view-mode.test.ts @@ -81,4 +81,41 @@ describe('tab view mode', () => { store.getState().toggleTabViewMode('missing-tab') expect(store.getState().unifiedTabsByWorktree[WT]).toBe(before) }) + + // Why: terminal-pane recovery asks the terminal row who owns the surface. + // Host sync already writes viewMode there; only these local toggles skipped + // it, which is why the guard had to OR two indices to get a safe answer. + describe('mirrors onto the terminal row', () => { + function terminalRow(tabId: string) { + return store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === tabId) + } + + beforeEach(() => { + const tabId = store.getState().createTab(WT).id + store.setState({ + unifiedTabsByWorktree: { + [WT]: [ + ...store.getState().unifiedTabsByWorktree[WT].filter((tab) => tab.id !== tabId), + makeUnifiedTab({ id: tabId, entityId: tabId, worktreeId: WT, groupId: 'g-left' }) + ] + } + } as Partial) + rowTabId = tabId + }) + + let rowTabId = '' + + it('toggleTabViewMode patches the row in the same write', () => { + store.getState().toggleTabViewMode(rowTabId) + expect(terminalRow(rowTabId)?.viewMode).toBe('chat') + + store.getState().toggleTabViewMode(rowTabId) + expect(terminalRow(rowTabId)?.viewMode).toBe('terminal') + }) + + it('setTabViewMode patches the row in the same write', () => { + store.getState().setTabViewMode(rowTabId, 'chat') + expect(terminalRow(rowTabId)?.viewMode).toBe('chat') + }) + }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts index e87a34f81c2..66f09743e37 100644 --- a/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts +++ b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts @@ -2,23 +2,27 @@ import type { AppState } from '../../types' import type { TerminalTab } from '../../../../../shared/terminal-tab-types' import { findTabAndWorktree } from '../tab-group-state' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { locateTerminalTab } from '../../terminals/terminal-tab-location' -export function patchTerminalTabPinned( +/** + * Mirror a host-tracked unified-tab field onto its terminal row, in whichever + * bucket actually holds the row. Reconcile derives these fields from the + * TerminalTab, so a local toggle that only patched the unified tab would be + * recomputed away by the next host snapshot — and recovery's chat-ownership + * guard reads the row, so a lagging row lets a hidden chat surface remount. + */ +export function patchTerminalTabRow( tabsByWorktree: Record, - worktreeId: string, tabId: string, - isPinned: boolean + patch: Partial> ): Partial> { - const tabs = tabsByWorktree[worktreeId] - if (!tabs?.some((tab) => tab.id === tabId)) { + const location = locateTerminalTab(tabsByWorktree, tabId) + if (!location) { return {} } - return { - tabsByWorktree: { - ...tabsByWorktree, - [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, isPinned } : tab)) - } - } + const nextTabs = tabsByWorktree[location.worktreeId].slice() + nextTabs[location.index] = { ...location.tab, ...patch } + return { tabsByWorktree: { ...tabsByWorktree, [location.worktreeId]: nextTabs } } } // Why: pin is host-authoritative for remote-server tabs, so mirror it (like setTabColor) or it's lost on reconnect/other clients. diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 8acb925bfae..42b8e3d7163 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -6,7 +6,7 @@ import { applyTabOrderSortValues, partitionPinnedTabOrder } from './tabs-tab-ord import { mirrorTabPinnedToHost, mirrorTabViewModeToHost, - patchTerminalTabPinned + patchTerminalTabRow } from './tabs-host-mirroring' export function createTabsLabelActions( @@ -62,7 +62,13 @@ export function createTabsLabelActions( }, setTabViewMode: (tabId, mode) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) ?? {}) + set((state) => ({ + ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }), + // Why the row too: viewMode is declared on both types and host-sync + // already writes it to the row. Only these local toggles skipped it, so + // readers had to OR the two indices to find out who owns the surface. + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) + })) mirrorTabViewModeToHost(get(), tabId, mode) }, @@ -86,7 +92,10 @@ export function createTabsLabelActions( (terminal) => terminal.id === found.tab.entityId )?.launchAgent ?? null toggled = { from: fromMode, to: nextMode, agent } - return patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }) ?? {} + return { + ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }), + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: nextMode }) + } }) // Why: emit after the state write so the event reflects the committed mode. const committed = toggled as { @@ -141,7 +150,7 @@ export function createTabsLabelActions( [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) }, // Why: reconcile derives pin from the TerminalTab, so mirror it there too or a host snapshot recomputes isPinned:false and un-pins during the echo window. - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, true), + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { isPinned: true }), groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: updateGroup(groups, { ...group, tabOrder }) @@ -178,7 +187,7 @@ export function createTabsLabelActions( ...state.unifiedTabsByWorktree, [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) }, - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, false), + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { isPinned: false }), groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: updateGroup(groups, { ...group, tabOrder }) diff --git a/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts b/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts index d3b96433e75..b1db16ae413 100644 --- a/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { isTerminalTabPresent } from './terminal-tab-retirement' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' const WORKTREE_ID = 'repo1::/path/wt1' @@ -21,7 +23,7 @@ describe('remountTerminalTabForRecovery', () => { const remounted = store.getState().remountTerminalTabForRecovery(tabId) - expect(remounted).toBe(true) + expect(remounted.remounted).toBe(true) const after = store.getState().tabsByWorktree[WORKTREE_ID].find((tab) => tab.id === tabId) expect(after?.generation ?? 0).toBe((before?.generation ?? 0) + 1) // Recovery is not user interaction — the remount's PTY updates must not @@ -36,7 +38,7 @@ describe('remountTerminalTabForRecovery', () => { store.getState().queueTabStartupCommand(tabId, startup) const before = store.getState().pendingStartupByTabId[tabId] - expect(store.getState().remountTerminalTabForRecovery(tabId)).toBe(true) + expect(store.getState().remountTerminalTabForRecovery(tabId).remounted).toBe(true) const after = store.getState().pendingStartupByTabId[tabId] expect(after).toEqual(before) @@ -59,7 +61,10 @@ describe('remountTerminalTabForRecovery', () => { const store = createTestStore() seedWorktreeWithTab(store) - expect(store.getState().remountTerminalTabForRecovery('missing-tab')).toBe(false) + expect(store.getState().remountTerminalTabForRecovery('missing-tab')).toEqual({ + remounted: false, + declinedBy: 'tab-missing' + }) }) }) @@ -72,7 +77,7 @@ describe('isTerminalTabPresent as the recovery existence check', () => { const tabId = seedWorktreeWithTab(store) expect(isTerminalTabPresent(store.getState(), tabId)).toBe(true) - expect(store.getState().remountTerminalTabForRecovery(tabId)).toBe(true) + expect(store.getState().remountTerminalTabForRecovery(tabId).remounted).toBe(true) }) it('stays true when the tab is missing from the unified tab index', () => { @@ -90,7 +95,7 @@ describe('isTerminalTabPresent as the recovery existence check', () => { store.setState({ tabsByWorktree: { [WORKTREE_ID]: [] } }) expect(isTerminalTabPresent(store.getState(), tabId)).toBe(false) - expect(store.getState().remountTerminalTabForRecovery(tabId)).toBe(false) + expect(store.getState().remountTerminalTabForRecovery(tabId).remounted).toBe(false) }) // The budget release still has to fire for a real close, or a closed tab's @@ -104,3 +109,71 @@ describe('isTerminalTabPresent as the recovery existence check', () => { expect(isTerminalTabPresent(store.getState(), tabId)).toBe(false) }) }) + +// Not every workspace is a repository checkout. The ledger is keyed to the tab +// ROW and resolved through locateTerminalTab, which scans every bucket in +// tabsByWorktree — so a folder workspace and the floating-terminal bucket must +// behave identically without a single branch for them. The predecessor kept the +// budget in a module map keyed by tabId, and its row patcher made the caller +// name the bucket, which is where a non-worktree key could go wrong. +describe.each([ + ['a repository worktree', WORKTREE_ID], + ['a folder workspace', folderWorkspaceKey('fw-1')], + ['the floating terminal bucket', FLOATING_TERMINAL_WORKTREE_ID] +])('the recovery ledger on %s', (_label, bucketId) => { + function seedBucket(store: ReturnType): string { + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + } + }) + return store.getState().createTab(bucketId).id + } + + const AUTOMATIC = { reason: 'write-stalled', trigger: 'automatic', now: 0 } as const + + it('admits, observes and then refuses the same reason until a new trigger', () => { + const store = createTestStore() + const tabId = seedBucket(store) + const row = (): { recovery?: unknown } | undefined => + store.getState().tabsByWorktree[bucketId]?.find((tab) => tab.id === tabId) + + const first = store.getState().remountTerminalTabForRecovery(tabId, AUTOMATIC) + expect(first.remounted).toBe(true) + // The ledger landed on the row in this bucket, not in a worktree-keyed map. + expect(row()?.recovery).toMatchObject({ outcome: 'pending', reason: 'write-stalled' }) + + // Unsettled blocks the next automatic ask, even past the cooldown. + expect( + store.getState().remountTerminalTabForRecovery(tabId, { ...AUTOMATIC, now: 16_000 }) + ).toEqual({ remounted: false, declinedBy: 'unsettled', retryInMs: 15_000 }) + + if (!first.remounted) { + throw new Error('unreachable: the first remount was admitted') + } + store.getState().settleTerminalTabRecovery(tabId, first.generation, 'failed') + expect(row()?.recovery).toMatchObject({ outcome: 'failed' }) + expect( + store.getState().remountTerminalTabForRecovery(tabId, { ...AUTOMATIC, now: 600_000 }) + ).toEqual({ remounted: false, declinedBy: 'settled-failure' }) + + // The user asking again is the new trigger the refusal waits for. + expect( + store + .getState() + .remountTerminalTabForRecovery(tabId, { ...AUTOMATIC, trigger: 'user', now: 600_000 }) + .remounted + ).toBe(true) + }) + + it('drops the ledger with the row when the tab closes', () => { + const store = createTestStore() + const tabId = seedBucket(store) + store.getState().remountTerminalTabForRecovery(tabId, AUTOMATIC) + + store.getState().closeTab(tabId) + + expect(isTerminalTabPresent(store.getState(), tabId)).toBe(false) + expect(store.getState().tabsByWorktree[bucketId]?.some((tab) => tab.id === tabId)).toBeFalsy() + }) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index d913f998d2c..9cbdcdf4d3e 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -182,7 +182,7 @@ export type UISlicePersistence = { /** Dev-only channel override; null follows the running build's own channel. */ releaseChannelOverride: ReleaseChannel | null setReleaseChannelOverride: (channel: ReleaseChannel | null) => void - // Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus). + // Ephemeral disclosure state; setUpdateStatus initializes it when the phase or error actionability changes. updateCardCollapsed: boolean setUpdateCardCollapsed: (collapsed: boolean) => void updateReassuranceSeen: boolean diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts index 942ff9610a5..c80e08157cf 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts @@ -8,7 +8,7 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial return { updateStatus: { state: 'idle' }, setUpdateStatus: (status) => { - const prevState = get().updateStatus.state + const { updateStatus: previousStatus, updateUserInitiatedCycle } = get() const update: Partial< Pick< UISlice, @@ -34,9 +34,22 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial update.updateChangelog = null } // 'downloading'/'downloaded'/'error': leave updateChangelog untouched to keep the original 'available' content. - if (status.state !== prevState) { - // Why: re-surface the card on each phase transition so a collapsed `downloading` doesn't bury `downloaded`/`error`. - update.updateCardCollapsed = false + const errorBecameActionable = + status.state === 'error' && + previousStatus.state === 'error' && + ((status.userInitiated === true && !previousStatus.userInitiated) || + (status.version !== undefined && previousStatus.version === undefined) || + (status.recovery?.kind === 'linux-package-install' && + previousStatus.recovery?.kind !== 'linux-package-install')) + if (status.state !== previousStatus.state || errorBecameActionable) { + // Quiet check failures start collapsed so the status bar can still disclose them. + update.updateCardCollapsed = + status.state === 'error' && + !status.userInitiated && + !updateUserInitiatedCycle && + status.version === undefined && + status.recovery?.kind !== 'linux-package-install' && + !('version' in previousStatus && previousStatus.version !== undefined) } set(update) }, diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 3fa3219fa32..d6d9e9de74f 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -25,6 +25,11 @@ import type { import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { TerminalPaneRecoveryOutcome } from '../../../../shared/terminal-tab-types' +import type { + TerminalRecoveryRemountRequest, + TerminalRecoveryRemountResult +} from '../terminals/terminal-tab-recovery-ledger' import type { RemoveWorktreeOptions } from './worktree-removal-options' import type { HostQualifiedDetectedWorktreeResult, @@ -310,9 +315,24 @@ export type WorktreeSlice = { * TerminalPane unmounts, detaches (preserving a live PTY), and remounts with * a fresh xterm that reattaches and replays. Used by terminal-pane-recovery * when a pane's write pipeline is certified dead or its input is - * undeliverable while the PTY is alive. Returns false when the tab is gone. + * undeliverable while the PTY is alive. + * + * The generation bump and the tab's recovery ledger are written together, so + * the budget cannot outlive — or be released independently of — the row it + * belongs to. Omitting the request marks an external lifecycle remount: it + * skips admission and writes no ledger. */ - remountTerminalTabForRecovery: (tabId: string) => boolean + remountTerminalTabForRecovery: ( + tabId: string, + request?: TerminalRecoveryRemountRequest + ) => TerminalRecoveryRemountResult + /** Record what a mounted pane observed for its recovery attempt. Ignored + * unless `generation` is the row's current, still-pending ledger epoch. */ + settleTerminalTabRecovery: ( + tabId: string, + generation: number, + outcome: Exclude + ) => void setActiveFolderWorkspace: (folderWorkspaceId: string, executionHostId?: ExecutionHostId) => void setRenamingWorktreeId: (request: string | WorktreeRenameRequest | null) => void allWorktrees: () => Worktree[] diff --git a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts new file mode 100644 index 00000000000..8b3c73e2ff1 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as intent from '@/lib/worktree-sleep-intent' +import { buildWorktreePurgeState } from './worktrees/teardown/worktree-purge-state' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createStoreCascadesMockApi } from './store-cascades-test-harness' + +const { clearWorktreeSleepIntent, hasWorktreeSleepIntent, markWorktreeSleepIntent } = intent +const WORKTREE_ID = 'repo1::/path/wt1' +const FOLDER_KEY = 'folder:folder-1' + +createStoreCascadesMockApi() + +function seedWorktree(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + refreshGitHubForWorktree: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn() + }) +} + +// Why this suite exists: the sleep marker outlives teardown so mounted panes stay cold +// (#10205). Every route that makes a workspace awake again must release it, or the +// workspace is stuck cold and its PTY exits stop counting as activity. +describe('worktree sleep intent lifecycle', () => { + beforeEach(() => { + clearWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(FOLDER_KEY) + }) + + it('is released by activating the worktree', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().setActiveWorktree(WORKTREE_ID) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('survives the sleep flow clearing the active selection', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().setActiveWorktree(null) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true) + }) + + it('is released by activating a folder workspace', () => { + const store = createTestStore() + store.setState({ + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Folder', + folderPath: '/folder', + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ] + }) + markWorktreeSleepIntent(FOLDER_KEY) + + store.getState().setActiveFolderWorkspace('folder-1') + + expect(hasWorktreeSleepIntent(FOLDER_KEY)).toBe(false) + }) + + it('is released when any PTY binds to a tab in the worktree', () => { + const store = createTestStore() + seedWorktree(store) + const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false }) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().updateTabPtyId(tab.id, 'pty-cli-created') + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('is released when a tab is created with a live PTY', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().createTab(WORKTREE_ID, undefined, undefined, { + activate: false, + initialPtyId: 'pty-cli-created' + }) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('notifies wake listeners once and only on a real clear', () => { + const { onWorktreeSleepIntentCleared } = intent + const woke = vi.fn() + markWorktreeSleepIntent(WORKTREE_ID) + const unsubscribe = onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + clearWorktreeSleepIntent('repo1::/path/other') + expect(woke).not.toHaveBeenCalled() + clearWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(WORKTREE_ID) + expect(woke).toHaveBeenCalledTimes(1) + + markWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(WORKTREE_ID) + expect(woke).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('ignores a PTY bind that lands while the sleep teardown is in flight', async () => { + const store = createTestStore() + seedWorktree(store) + const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false }) + markWorktreeSleepIntent(WORKTREE_ID) + const woke = vi.fn() + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + await intent.withWorktreeSleepTeardown(WORKTREE_ID, async () => { + store.getState().updateTabPtyId(tab.id, 'pty-late-spawn') + }) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true) + expect(woke).not.toHaveBeenCalled() + store.getState().updateTabPtyId(tab.id, 'pty-after-teardown') + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('keeps notifying siblings when one wake listener throws', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const woke = vi.fn() + markWorktreeSleepIntent(WORKTREE_ID) + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, () => { + throw new Error('boom') + }) + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + expect(() => clearWorktreeSleepIntent(WORKTREE_ID)).not.toThrow() + expect(woke).toHaveBeenCalledTimes(1) + errorSpy.mockRestore() + }) + + it('is forgotten without waking panes when the worktree is purged', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + const woke = vi.fn() + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + store.setState(buildWorktreePurgeState(store.getState(), [WORKTREE_ID])) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + expect(woke).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 606a5a26857..330d27e7bb2 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -52,6 +52,7 @@ import { createGetKnownWorktreeById, createPurgeWorktreeTerminalState, createRemountTerminalTabForRecovery, + createSettleTerminalTabRecovery, createSetRenamingWorktreeId } from './worktrees/session/worktree-slice-lookups' import { createPurgeStaleRuntimeHostState } from './worktrees/teardown/purge-stale-runtime-host-state' @@ -108,6 +109,7 @@ export const createWorktreeSlice: StateCreator seedActiveWorktreeLastVisitedIfMissing: createSeedActiveWorktreeLastVisitedIfMissing(set, get), setRenamingWorktreeId: createSetRenamingWorktreeId(set, get), remountTerminalTabForRecovery: createRemountTerminalTabForRecovery(set, get), + settleTerminalTabRecovery: createSettleTerminalTabRecovery(set, get), setActiveWorktree: createSetActiveWorktree(set, get), setActiveFolderWorkspace: createSetActiveFolderWorkspace(set, get), allWorktrees: createAllWorktrees(set, get), diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts index d2b5af79114..b89da1ace44 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts @@ -9,6 +9,7 @@ import { } from '../listing/detected-worktree-meta' import { shouldDeferActivationTerminalPrep } from './activation-terminal-prep' import { deriveActiveSurfaceForWorktree } from '../../tabs/tabs-surface' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' export function createSetActiveFolderWorkspace( set: WorktreeSliceSet, @@ -62,6 +63,8 @@ export function createSetActiveFolderWorkspace( : s.folderWorkspaces } }) + // Why: cleared after the set() so a waiting pane connects against the activated state. + clearWorktreeSleepIntent(workspaceKey) if (workspace.isUnread) { void get().updateFolderWorkspace( folderWorkspaceId, diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index b4ec0b0f99a..31c7e8bbb37 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -24,6 +24,7 @@ import { } from '../listing/detected-worktree-meta' import { persistPassiveWorktreeMetaForOwner } from '../listing/worktree-owner-settings' import { resolveActivatedWorktreeSurface } from './active-worktree-surface' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { pendingActivationTerminalPrepCancels, shouldDeferActivationTerminalPrep @@ -206,6 +207,11 @@ export function createSetActiveWorktree( } }) + // Why: any activation is an explicit wake (null is the sleep flow clearing selection). + // Cleared after the set() above so a pane still waiting on the marker connects once, + // in the remounted generation, instead of connecting and then being remounted. + clearWorktreeSleepIntent(worktreeId) + if (worktreeId && shouldPrepareTerminalTabs) { const prepareTerminalTabs = (): void => { pendingActivationTerminalPrepCancels.delete(worktreeId) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 055171c7cfd..78e0e397bca 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -5,6 +5,15 @@ import { getTerminalActivationSpawnSuppression } from '../../terminal-activation import { findKnownWorktreeById } from '../listing/detected-worktree-meta' import { buildWorktreePurgeState } from '../teardown/worktree-purge-state' import { locateTerminalTab } from '../../../terminals/terminal-tab-location' +import { + admitTerminalRecoveryRemount, + nextTerminalRecoveryLedger, + settledTerminalRecoveryLedger +} from '../../../terminals/terminal-tab-recovery-ledger' +import type { + TerminalRecoveryRemountRequest, + TerminalRecoveryRemountResult +} from '../../../terminals/terminal-tab-recovery-ledger' export function createSetRenamingWorktreeId( set: WorktreeSliceSet, @@ -21,26 +30,57 @@ export function createRemountTerminalTabForRecovery( set: WorktreeSliceSet, _get: WorktreeSliceGet ): WorktreeSlice['remountTerminalTabForRecovery'] { - return (tabId) => { - let remounted = false + return (tabId, request) => { + const remountRequest: TerminalRecoveryRemountRequest = request ?? { + // The lifetime bridge's host-hydration remount is an external trigger: it + // is not a heal attempt, so it neither consumes nor consults the ledger. + reason: 'reattach-unverifiable', + trigger: 'external', + now: Date.now() + } + let result: TerminalRecoveryRemountResult = { + remounted: false, + declinedBy: 'tab-missing' + } set((s) => { const location = locateTerminalTab(s.tabsByWorktree, tabId) - if (!location) { + // Why re-admit inside the write: the caller's read happened before an + // async liveness probe, and a concurrent detector may have consumed the + // budget across it. Locating the row and spending its budget is one step. + const admission = admitTerminalRecoveryRemount(location?.tab, remountRequest) + if (!location || !admission.admitted) { + if (admission.admitted) { + result = { remounted: false, declinedBy: 'tab-missing' } + } else { + const { admitted: _admitted, ...decline } = admission + result = { remounted: false, ...decline } + } return {} } const { worktreeId, index, tab } = location const nextTabs = s.tabsByWorktree[worktreeId].slice() const pendingStartup = s.pendingStartupByTabId[tabId] + // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. + const nextTabGeneration = (tab.generation ?? 0) + 1 + // An external remount is not a heal attempt, so it writes no ledger. The + // generation bump alone supersedes any ledger already on the row, which + // is exactly right: an external remount IS a new trigger. + const recovery = + remountRequest.trigger === 'external' + ? tab.recovery + : nextTerminalRecoveryLedger(tab, remountRequest, nextTabGeneration) nextTabs[index] = { ...tab, - // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. - generation: (tab.generation ?? 0) + 1, + generation: nextTabGeneration, // Why: recovery isn't a user interaction — suppress its PTY updates from reshuffling Recent, like activation remounts. pendingActivationSpawn: getTerminalActivationSpawnSuppression( s.terminalLayoutsByTabId[tab.id] - ) + ), + // The remount and the budget it spends are one write, so no disposal, + // release path or index drift can undo half of it (crash b5cfc6ca). + ...(recovery ? { recovery } : {}) } - remounted = true + result = { remounted: true, generation: recovery?.generation ?? 0 } return { tabsByWorktree: { ...s.tabsByWorktree, @@ -58,7 +98,29 @@ export function createRemountTerminalTabForRecovery( : {}) } }) - return remounted + return result + } +} + +export function createSettleTerminalTabRecovery( + set: WorktreeSliceSet, + _get: WorktreeSliceGet +): WorktreeSlice['settleTerminalTabRecovery'] { + return (tabId, generation, outcome) => { + set((s) => { + const location = locateTerminalTab(s.tabsByWorktree, tabId) + if (!location) { + return {} + } + const { worktreeId, index, tab } = location + const recovery = settledTerminalRecoveryLedger(tab, generation, outcome) + if (!recovery) { + return {} + } + const nextTabs = s.tabsByWorktree[worktreeId].slice() + nextTabs[index] = { ...tab, recovery } + return { tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: nextTabs } } + }) } } diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 075172ddce1..a6a9c87d838 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -6,6 +6,7 @@ import { parseExecutionHostId } from '../../../../../../shared/execution-host' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' +import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { resolveWorktreeOperationRouteResult, @@ -222,6 +223,7 @@ export function createRemoveWorktree( // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) + forgetWorktreeSleepIntent(worktreeId) // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it. if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') { diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 78a57da7955..e2f6e5945fe 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -8,6 +8,7 @@ import { createWorktreePurgeOmitters } from './worktree-purge-omitters' import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings' +import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' export function buildWorktreePurgeState( s: AppState, @@ -18,6 +19,10 @@ export function buildWorktreePurgeState( ) const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id)) pruneHostedReviewLinkMutationGenerations(worktreeIdSet) + // Why: ids are repo::path, so a worktree recreated at the same path must not inherit a stale sleep. + for (const id of worktreeIdSet) { + forgetWorktreeSleepIntent(id) + } // Why: every authoritative and explicit purge converges here, so a deleted path can't inherit stale UI state. forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet) forgetAmbiguousOwnerWarnings(worktreeIdSet) diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index 2e0397aff45..499ea63e8de 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -9,6 +9,7 @@ import { isRemoteRuntimePtyId } from './terminal-pty-identities' import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { omitDisownedPtyIds } from './terminal-disowned-pty-sources' export function createTerminalPtyBindingActions( @@ -275,6 +276,8 @@ export function createTerminalPtyBindingActions( ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } }) + // Why: a bound PTY means the workspace is awake by any route (CLI, automation, client wake), not only activation. + clearWorktreeSleepIntent(worktreeId) // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { get().bumpWorktreeActivity(worktreeId) diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 11f9d1d2a59..83310850475 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -1,3 +1,4 @@ +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from '../slices/terminal-helpers' @@ -271,6 +272,10 @@ export function createTerminalTabCreationActions( } } }) + if (options?.initialPtyId) { + // Why: a tab born with a live PTY (CLI/runtime create) wakes the workspace like any other bind. + clearWorktreeSleepIntent(worktreeId) + } const shouldRecordInteraction = options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) if (shouldRecordInteraction) { diff --git a/src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts b/src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts new file mode 100644 index 00000000000..b06b6de5d30 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts @@ -0,0 +1,218 @@ +import { DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS } from '@/components/terminal-pane/pty-connection/pty-connect-limits' +import type { + TerminalPaneRecoveryOutcome, + TerminalPaneRecoveryReason, + TerminalTab, + TerminalTabRecoveryLedger +} from '../../../../shared/terminal-tab-types' + +// Why this module exists: recovery's budget used to live in module-level Maps +// keyed by tabId. Anything keyed outside the row needs a release path, and the +// release fired on every remount-driven pane disposal — so each remount erased +// the budget it had just consumed and the cap never held (crash b5cfc6ca). +// The ledger now lives on the row, so "the budget released itself" has no +// expression: reading the budget IS reading the tab. +// +// The control is not the count. A remount that mounts a pane which fails the +// same way is not evidence that anything changed, so recovery gates on an +// OBSERVED outcome, borrowing the direct-SSH pane retry vocabulary +// (DirectSshPaneRetryResult): an attempt that has not settled blocks the next +// one, and a settled failure refuses the same reason until a new trigger. + +// Backstop only — a breadcrumb-emitting ceiling for a loop the outcome gate +// somehow failed to catch. The outcome gate is what stops a storm. +export const MAX_RECOVERIES_PER_WINDOW = 3 +export const RECOVERY_WINDOW_MS = 5 * 60_000 +// Why a cooldown exists: one incident can trip several detectors (stall watch, +// replay guard, input path) within seconds; the first remount fixes all of +// them, the rest must coalesce instead of re-remounting mid-reattach. +export const RECOVERY_COOLDOWN_MS = 15_000 +// Why reuse the direct-SSH settlement timeout: the same 31s bound already +// decides when a pane's attach attempt has stopped being in flight. A 'pending' +// ledger older than that describes a pane that never reported, not one still +// working, so it must stop blocking rather than wedge recovery forever. +export const RECOVERY_SETTLEMENT_TIMEOUT_MS = DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS + +/** Why a request exists at all. Only 'automatic' is subject to the + * settled-failure refusal: a user pressing Retry, or an external lifecycle + * remount, IS the new trigger the refusal is waiting for. */ +export type TerminalRecoveryTrigger = 'automatic' | 'user' | 'external' + +export type TerminalRecoveryRemountRequest = { + reason: TerminalPaneRecoveryReason + trigger: TerminalRecoveryTrigger + /** The recovery epoch the requesting pane captured, when it has one. */ + generation?: number + now: number +} + +export type TerminalRecoveryDecline = + | { declinedBy: 'tab-missing' } + | { declinedBy: 'stale-generation' } + | { declinedBy: 'settled-failure' } + | { declinedBy: 'window-cap'; retryInMs: number } + | { declinedBy: 'unsettled'; retryInMs: number } + | { declinedBy: 'cooldown'; retryInMs: number } + +export type TerminalRecoveryAdmission = + | { admitted: true } + | ({ admitted: false } & TerminalRecoveryDecline) + +export type TerminalRecoveryRemountResult = + /** `generation` is the ledger epoch the remounted pane will capture. */ + { remounted: true; generation: number } | ({ remounted: false } & TerminalRecoveryDecline) + +const ADMITTED: TerminalRecoveryAdmission = { admitted: true } + +function recentAttempts(ledger: TerminalTabRecoveryLedger, now: number): number[] { + return ledger.attemptedAt.filter((at) => now - at < RECOVERY_WINDOW_MS) +} + +/** True once the ledger describes an attempt nothing can still settle: the row + * moved to a generation this ledger never saw (authority change, SSH pane + * retry, activation respawn, external remount). Derived, so no writer can + * forget to mark it — and none can mark it wrongly either. */ +function isSupersededLedger(tab: TerminalTab, ledger: TerminalTabRecoveryLedger): boolean { + // Strictly forward: generation only ever increments, so a row that reads + // LOWER is a host-snapshot rebuild that dropped the field, not a new trigger. + // Treating that as one would hand the tab a fresh allowance per snapshot. + return (tab.generation ?? 0) > ledger.tabGeneration +} + +export function readTerminalRecoveryOutcome( + tab: TerminalTab, + now: number +): TerminalPaneRecoveryOutcome | null { + const ledger = tab.recovery + if (!ledger) { + return null + } + if (isSupersededLedger(tab, ledger)) { + return 'superseded' + } + if (ledger.outcome === 'pending' && now - ledger.startedAt >= RECOVERY_SETTLEMENT_TIMEOUT_MS) { + return 'timed-out' + } + return ledger.outcome +} + +/** Narrowed to the one field it reads, so the connect path can pass the row it + * already resolved rather than looking the full TerminalTab up a second time. */ +export function captureTabRecoveryGeneration( + tab: Pick | null | undefined +): number { + return tab?.recovery?.generation ?? 0 +} + +/** + * The single admission decision. Runs read-only to fail a request fast, and + * again inside the store write so a probe's await cannot open a window for two + * panes to both consume the budget. + */ +export function admitTerminalRecoveryRemount( + tab: TerminalTab | null | undefined, + request: TerminalRecoveryRemountRequest +): TerminalRecoveryAdmission { + if (!tab) { + return { admitted: false, declinedBy: 'tab-missing' } + } + const ledger = tab.recovery + if ( + request.generation !== undefined && + request.generation !== captureTabRecoveryGeneration(tab) + ) { + return { admitted: false, declinedBy: 'stale-generation' } + } + if (request.trigger === 'external' || !ledger) { + return ADMITTED + } + const recent = recentAttempts(ledger, request.now) + if (recent.length >= MAX_RECOVERIES_PER_WINDOW) { + // Unconditional: the backstop must survive supersession, or anything that + // bumps tab.generation each cycle would lift the ceiling along with it. + return { + admitted: false, + declinedBy: 'window-cap', + retryInMs: recent[0] + RECOVERY_WINDOW_MS - request.now + } + } + if (request.trigger === 'user') { + // The user asking again IS the new evidence. Only the window cap — the + // backstop against a loop neither side can see — survives it. + return ADMITTED + } + const outcome = readTerminalRecoveryOutcome(tab, request.now) + if (outcome !== 'superseded') { + if (ledger.outcome === 'pending') { + if (outcome === 'pending') { + // Re-requesting under an unsettled attempt is the storm: the remounted + // pane fails the same way and asks again with a freshly captured epoch, + // so an epoch check can never refuse it. Nothing has been observed yet. + return { + admitted: false, + declinedBy: 'unsettled', + retryInMs: ledger.startedAt + RECOVERY_SETTLEMENT_TIMEOUT_MS - request.now + } + } + // Aged past the settlement bound with nobody reporting. Deliberately NOT + // read as an observed failure: a pane kind with no settle path would + // otherwise wedge its tab's recovery forever. The cooldown and the window + // cap bound it instead. + } else if ( + (ledger.outcome === 'failed' || ledger.outcome === 'timed-out') && + ledger.reason === request.reason + ) { + // A pane OBSERVED this reason fail after the last remount. Repeating it + // re-requests exactly the action that just failed with no evidence + // anything changed — wait for a real trigger (generation move, or user). + return { admitted: false, declinedBy: 'settled-failure' } + } + } + const last = recent.at(-1) + if (last !== undefined && request.now - last < RECOVERY_COOLDOWN_MS) { + return { + admitted: false, + declinedBy: 'cooldown', + retryInMs: last + RECOVERY_COOLDOWN_MS - request.now + } + } + return ADMITTED +} + +/** The ledger a remount writes, in the same object as the generation bump. */ +export function nextTerminalRecoveryLedger( + tab: TerminalTab, + request: TerminalRecoveryRemountRequest, + nextTabGeneration: number +): TerminalTabRecoveryLedger { + const previous = tab.recovery + // Carried across supersession on purpose — see the window-cap note above. + const carriedAttempts = previous ? recentAttempts(previous, request.now) : [] + return { + attemptedAt: [...carriedAttempts, request.now], + generation: captureTabRecoveryGeneration(tab) + 1, + outcome: 'pending', + startedAt: request.now, + reason: request.reason, + tabGeneration: nextTabGeneration + } +} + +/** Record what the mounted pane observed. Returns null when this settlement is + * not the current attempt's, so the caller can leave the store untouched. */ +export function settledTerminalRecoveryLedger( + tab: TerminalTab, + generation: number, + outcome: Exclude +): TerminalTabRecoveryLedger | null { + const ledger = tab.recovery + if ( + !ledger || + ledger.generation !== generation || + ledger.outcome !== 'pending' || + isSupersededLedger(tab, ledger) + ) { + return null + } + return { ...ledger, outcome } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts index ea0c0545998..c379a85774c 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -51,10 +51,11 @@ export function createWorkspaceTerminalReconnectActions( for (const worktreeId of ids) { const tabs = tabsByWorktree[worktreeId] ?? [] const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? [] + const tabById = targetTabIds.length > 1 ? buildByIdIndex(tabs) : null const tabsToReconnect: TerminalTab[] = targetTabIds.length > 0 ? targetTabIds - .map((id) => tabs.find((t) => t.id === id)) + .map((id) => (tabById ? tabById.get(id) : tabs.find((t) => t.id === id))) .filter((t): t is TerminalTab => t != null) : tabs.slice(0, 1) if (tabsToReconnect.length === 0) { diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts index e6ce2fd5a5d..8e9b0ecc245 100644 --- a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -16,6 +16,9 @@ import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' import { callEnvironmentEnvelope } from './web-runtime-calls' import { closeActiveRuntimeClients, + subscribeWebRuntimeStatus, + readWebRuntimeStatusSnapshots, + observeWebRuntimeStatus, disconnectActiveRuntimeEnvironment, getClientForEnvironment, manuallyDisconnectedEnvironmentIds, @@ -29,6 +32,8 @@ export function createRuntimeEnvironmentsApi(): NonNullable< Partial['runtimeEnvironments'] > { return { + onStatusChanged: subscribeWebRuntimeStatus, + getStatusSnapshots: async () => readWebRuntimeStatusSnapshots(), list: async () => { const environment = requireActiveEnvironmentOrNull() return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] @@ -146,6 +151,12 @@ export function createRuntimeEnvironmentsApi(): NonNullable< manuallyDisconnectedEnvironmentIds.clear() closeActiveRuntimeClients() webRuntimeState.activeEnvironment = nextEnvironment + getClientForEnvironment(nextEnvironment).statusOwner?.acceptVerified({ + id: 'status.get', + ok: true, + result: runtimeStatus, + _meta: { runtimeId: runtimeStatus.runtimeId } + }) return { ok: true, environment: redactStoredWebRuntimeEnvironment(nextEnvironment), @@ -173,6 +184,7 @@ export function createRuntimeEnvironmentsApi(): NonNullable< connect: ({ selector, timeoutMs }) => { const environment = resolveEnvironment(selector) manuallyDisconnectedEnvironmentIds.delete(environment.id) + closeActiveRuntimeClients() return callEnvironmentEnvelope( environment.id, 'status.get', @@ -180,8 +192,10 @@ export function createRuntimeEnvironmentsApi(): NonNullable< timeoutMs ) }, - getStatus: ({ selector, timeoutMs }) => - callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), + getStatus: ({ selector, timeoutMs, observeOnly }) => + observeOnly + ? observeWebRuntimeStatus(selector, timeoutMs) + : callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), retryControlConnection: () => Promise.resolve(), prepareBrowserClientHostPlacement: async () => ({ kind: 'server' }), call: ({ selector, method, params, timeoutMs }) => diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts index 17a19136002..15b6cb63ee7 100644 --- a/src/renderer/src/web/preload-api/web-runtime-session.ts +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -1,3 +1,7 @@ +import type { + RuntimeHostStatusSnapshot, + RuntimeHostStatusResponse +} from '../../../../shared/runtime-host-status' import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue' import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' @@ -30,6 +34,43 @@ export const webRuntimeState: { cachedDetectedWorktrees: null } +const statusListeners = new Set<(snapshot: RuntimeHostStatusSnapshot) => void>() +export function subscribeWebRuntimeStatus( + callback: (snapshot: RuntimeHostStatusSnapshot) => void +): () => void { + statusListeners.add(callback) + return () => { + statusListeners.delete(callback) + } +} +export function readWebRuntimeStatusSnapshots(): RuntimeHostStatusSnapshot[] { + const snapshot = webRuntimeState.activeClient?.statusOwner?.read() + return snapshot ? [snapshot] : [] +} +export async function observeWebRuntimeStatus( + selector: string, + timeoutMs?: number +): Promise { + const environment = resolveEnvironment(selector) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const existing = webRuntimeState.activeClient?.statusOwner + if (existing) { + return existing.refresh({ timeoutMs, observeOnly: true }) + } + const transient = new WebRuntimeClient(getPreferredWebPairingOffer(environment), { + reconnect: false + }) + try { + return (await transient.call('status.get', undefined, { + timeoutMs + })) as RuntimeHostStatusResponse + } finally { + transient.close() + } +} + export const manuallyDisconnectedEnvironmentIds = new Set() export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() @@ -50,7 +91,18 @@ export function getClientForEnvironment( webRuntimeState.activeClientEnvironmentId !== environment.id ) { webRuntimeState.activeClient?.close() - webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) + webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment), { + status: { + environmentId: environment.id, + pairingRevision: environment.pairingRevision ?? environment.createdAt, + publish: (snapshot) => { + for (const listener of statusListeners) { + listener(snapshot) + } + }, + verified: (response) => updateEnvironmentFromResponse(environment, response) + } + }) webRuntimeState.activeClientEnvironmentId = environment.id } return webRuntimeState.activeClient diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts index b1199332d95..f7091cac9bc 100644 --- a/src/renderer/src/web/web-preload-api-composition.test.ts +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -79,7 +79,6 @@ describe('web preload API composition', () => { ]) expect(Object.keys(globals.window.api.projects)).toEqual([]) expect(Reflect.get(globals.window.api.projects, 'then')).toBeUndefined() - expect(Object.keys(globals.window.electron)).toEqual([]) }) it('snapshots E2E config before runtime storage initialization', async () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a53b5e0ca06..a10ecb0d478 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -12,7 +12,7 @@ import { createWebAppApi } from './preload-api/web-app-api' import { createBrowserApi, createEmulatorApi } from './preload-api/web-browser-api' import { createCliApi } from './preload-api/web-cli-api' import { createWebDiagnosticsApi } from './preload-api/web-diagnostics-api' -import { createFallbackProxy, withFallback } from './preload-api/web-fallback-api' +import { withFallback } from './preload-api/web-fallback-api' import { createFileApi } from './preload-api/web-filesystem-api' import { createGitApi } from './preload-api/web-git-api' import { createWebGithubCacheApi } from './preload-api/web-github-cache-api' @@ -56,7 +56,6 @@ export function installWebPreloadApi(): void { webRuntimeState.activeEnvironment = readStoredWebRuntimeEnvironment() const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean } webWindow.__ORCA_WEB_CLIENT__ = true - window.electron = createFallbackProxy(['electron']) as Window['electron'] window.api = withFallback(createWebPreloadApi(), []) as PreloadApi } diff --git a/src/renderer/src/web/web-runtime-client-export-parity.test.ts b/src/renderer/src/web/web-runtime-client-export-parity.test.ts index 27dce452549..6ba87eaad36 100644 --- a/src/renderer/src/web/web-runtime-client-export-parity.test.ts +++ b/src/renderer/src/web/web-runtime-client-export-parity.test.ts @@ -6,8 +6,13 @@ it('keeps the paired-web client public export surface exact', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf>().toEqualTypeOf< - [pairing: WebPairingOffer] + [ + pairing: WebPairingOffer, + options?: ConstructorParameters[1] + ] + >() + expectTypeOf().toEqualTypeOf< + 'call' | 'close' | 'subscribe' | 'statusOwner' >() - expectTypeOf().toEqualTypeOf<'call' | 'close' | 'subscribe'>() expect(Object.keys(WebClient)).toEqual(['WebRuntimeClient']) }) diff --git a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts index 71568adc4da..95417748271 100644 --- a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts +++ b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts @@ -70,7 +70,7 @@ describe('WebRuntimeClient timeout budget', () => { await vi.advanceTimersByTimeAsync(60_000) expect(settled).toBe(false) - expect(waitForConnected).toHaveBeenCalledWith(25) + expect(waitForConnected).toHaveBeenCalledWith(25, undefined) resolveConnection() await Promise.resolve() diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 7bc756ca0c0..8a3b944f701 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -1,3 +1,8 @@ +import { RuntimeHostStatusOwner } from '../../../shared/runtime-host-status-owner' +import type { + RuntimeHostStatusSnapshot, + RuntimeHostStatusResponse +} from '../../../shared/runtime-host-status' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { WebRuntimeConnectionTransport } from './web-runtime-connection-transport' import { subscribeWebRuntimeFileWatch } from './web-runtime-file-watch-subscription' @@ -24,11 +29,62 @@ export class WebRuntimeClient { private readonly fileWatchTeardownRetries = new Map Promise>>() private readonly childClients = new Set() - constructor(private readonly pairing: WebPairingOffer) { - this.transport = new WebRuntimeConnectionTransport(pairing, { - now: () => this.now(), - isDocumentVisible: () => this.isDocumentVisible() - }) + readonly statusOwner?: RuntimeHostStatusOwner + + constructor( + private readonly pairing: WebPairingOffer, + options: { + reconnect?: boolean + status?: { + environmentId: string + pairingRevision: number + publish: (snapshot: RuntimeHostStatusSnapshot) => void + verified: (response: RuntimeHostStatusResponse) => void + } + } = {} + ) { + this.transport = new WebRuntimeConnectionTransport( + pairing, + { + now: () => this.now(), + isDocumentVisible: () => this.isDocumentVisible() + }, + { + reconnect: options.reconnect, + onStateChanged: (state) => { + if (state === 'auth-failed') { + this.statusOwner?.authenticationRejected() + } + this.statusOwner?.connectionChanged( + state === 'connected' + ? 'ready' + : state === 'disconnected' || state === 'auth-failed' + ? 'disconnected' + : 'connecting' + ) + } + } + ) + if (options.status) { + const status = options.status + this.statusOwner = new RuntimeHostStatusOwner({ + ...status, + persistent: true, + request: (signal) => + this.transport.call('status.get', undefined, { + timeoutMs: 15_000, + signal + }) as Promise, + verified: (response) => { + status.verified(response) + return true + } + }) + this.statusOwner.connectionChanged( + this.transport.state === 'connected' ? 'ready' : 'connecting' + ) + this.statusOwner.activate() + } } call( @@ -36,7 +92,9 @@ export class WebRuntimeClient { params?: unknown, options?: { timeoutMs?: number } ): Promise> { - return this.transport.call(method, params, options) + return method === 'status.get' && this.statusOwner + ? this.statusOwner.refresh(options) + : this.transport.call(method, params, options) } async subscribe( @@ -94,6 +152,7 @@ export class WebRuntimeClient { } close(options: { notifySubscriptions?: boolean } = {}): void { + this.statusOwner?.dispose() const shouldNotifySubscriptions = options.notifySubscriptions ?? true for (const child of Array.from(this.childClients)) { child.close({ notifySubscriptions: shouldNotifySubscriptions }) diff --git a/src/renderer/src/web/web-runtime-connection-transport.ts b/src/renderer/src/web/web-runtime-connection-transport.ts index d62cf3b48ba..1ead257bbc5 100644 --- a/src/renderer/src/web/web-runtime-connection-transport.ts +++ b/src/renderer/src/web/web-runtime-connection-transport.ts @@ -43,7 +43,11 @@ export class WebRuntimeConnectionTransport { constructor( private readonly pairing: WebPairingOffer, - clock: { now: () => number; isDocumentVisible: () => boolean } + clock: { now: () => number; isDocumentVisible: () => boolean }, + private readonly lifecycle: { + onStateChanged?: (state: WebRuntimeConnectionState) => void + reconnect?: boolean + } = {} ) { this.serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) this.connectionWaiters = new WebRuntimeConnectionWaiters({ @@ -60,7 +64,7 @@ export class WebRuntimeConnectionTransport { this.requestRegistry = new WebRuntimeRequestRegistry({ deviceToken: pairing.deviceToken, nextId: () => this.nextId(), - waitForConnected: (timeoutMs) => this.connectionWaiters.wait(timeoutMs), + waitForConnected: (timeoutMs, signal) => this.connectionWaiters.wait(timeoutMs, signal), sendEncrypted: (message) => this.sendEncrypted(message) }) this.heartbeat = new WebRuntimeConnectionHeartbeat({ @@ -82,7 +86,7 @@ export class WebRuntimeConnectionTransport { async call( method: string, params?: unknown, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; signal?: AbortSignal } ): Promise> { return this.requestRegistry.call(method, params, options) } @@ -153,6 +157,7 @@ export class WebRuntimeConnectionTransport { } else if (next === 'auth-failed') { this.connectionWaiters.rejectAll(createWebRuntimeUnauthorizedError()) } + this.lifecycle.onStateChanged?.(next) } private openConnection(): void { @@ -232,7 +237,7 @@ export class WebRuntimeConnectionTransport { } private scheduleReconnect(): void { - if (this.reconnectTimer || this.intentionallyClosed) { + if (this.reconnectTimer || this.intentionallyClosed || this.lifecycle.reconnect === false) { return } const delay = withReconnectJitter( diff --git a/src/renderer/src/web/web-runtime-connection-waiters.ts b/src/renderer/src/web/web-runtime-connection-waiters.ts index c16e30cd779..a8f3d081e86 100644 --- a/src/renderer/src/web/web-runtime-connection-waiters.ts +++ b/src/renderer/src/web/web-runtime-connection-waiters.ts @@ -13,7 +13,10 @@ export class WebRuntimeConnectionWaiters { constructor(private readonly options: WebRuntimeConnectionWaiterOptions) {} - wait(timeoutMs = 30_000): Promise { + wait(timeoutMs = 30_000, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(signal.reason) + } if (this.options.getState() === 'connected') { return Promise.resolve() } @@ -24,11 +27,20 @@ export class WebRuntimeConnectionWaiters { return Promise.reject(new Error('Remote Orca runtime connection closed.')) } return new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve) + const cleanup = (): void => { + window.clearTimeout(timeout) + signal?.removeEventListener('abort', abort) + const index = this.waiters.indexOf(waiter) if (index !== -1) { this.waiters.splice(index, 1) } + } + const abort = (): void => { + cleanup() + reject(signal?.reason) + } + const timeout = window.setTimeout(() => { + cleanup() reject( new Error( withRemoteRuntimeTailscaleHint( @@ -38,16 +50,18 @@ export class WebRuntimeConnectionWaiters { ) ) }, timeoutMs) - this.waiters.push({ + const waiter = { resolve: () => { - window.clearTimeout(timeout) + cleanup() resolve() }, - reject: (error) => { - window.clearTimeout(timeout) + reject: (error: Error) => { + cleanup() reject(error) } - }) + } + this.waiters.push(waiter) + signal?.addEventListener('abort', abort, { once: true }) }) } diff --git a/src/renderer/src/web/web-runtime-request-registry.ts b/src/renderer/src/web/web-runtime-request-registry.ts index 1347d580f00..0329e208ba3 100644 --- a/src/renderer/src/web/web-runtime-request-registry.ts +++ b/src/renderer/src/web/web-runtime-request-registry.ts @@ -6,7 +6,7 @@ const REQUEST_TIMEOUT_MS = 30_000 type WebRuntimeRequestRegistryOptions = { deviceToken: string nextId: () => string - waitForConnected: (timeoutMs?: number) => Promise + waitForConnected: (timeoutMs?: number, signal?: AbortSignal) => Promise sendEncrypted: (message: unknown) => boolean } @@ -18,17 +18,41 @@ export class WebRuntimeRequestRegistry { async call( method: string, params?: unknown, - callOptions?: { timeoutMs?: number } + callOptions?: { timeoutMs?: number; signal?: AbortSignal } ): Promise> { - await this.options.waitForConnected(callOptions?.timeoutMs) + const signal = callOptions?.signal + await this.options.waitForConnected(callOptions?.timeoutMs, signal) + signal?.throwIfAborted() return new Promise((resolve, reject) => { const id = this.options.nextId() const timeoutMs = callOptions?.timeoutMs ?? REQUEST_TIMEOUT_MS const timeout = window.setTimeout(() => { this.pending.delete(id) + cleanup() reject(new Error(`Request timed out: ${method}`)) }, timeoutMs) - this.pending.set(id, { method, resolve, reject, timeout }) + const cleanup = (): void => { + signal?.removeEventListener('abort', abort) + } + const abort = (): void => { + this.pending.delete(id) + window.clearTimeout(timeout) + cleanup() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) + this.pending.set(id, { + method, + resolve: (value) => { + cleanup() + resolve(value) + }, + reject: (error) => { + cleanup() + reject(error) + }, + timeout + }) if ( !this.options.sendEncrypted({ id, @@ -39,6 +63,7 @@ export class WebRuntimeRequestRegistry { ) { this.pending.delete(id) window.clearTimeout(timeout) + cleanup() reject(new Error('Remote Orca runtime is not connected.')) } }) diff --git a/src/renderer/src/web/web-runtime-status-owner.test.ts b/src/renderer/src/web/web-runtime-status-owner.test.ts new file mode 100644 index 00000000000..7abfe6f19f4 --- /dev/null +++ b/src/renderer/src/web/web-runtime-status-owner.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { + createSharedControlTestServer, + closeSharedControlTestServers +} from '../../../shared/remote-runtime-shared-control-test-server' +import { WebRuntimeClient } from './web-runtime-client' + +const clients: WebRuntimeClient[] = [] +beforeEach(() => { + vi.stubGlobal('WebSocket', WebSocket) + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') + }) +}) +afterEach(async () => { + clients.splice(0).forEach((client) => client.close()) + await closeSharedControlTestServers() + vi.unstubAllGlobals() +}) + +it('primary browser status follows the authenticated socket and closing it retires the owner', async () => { + let runtimeId = 'before' + const server = await createSharedControlTestServer({ + resultForRequest: () => ({ runtimeId, capabilities: [] }) + }) + const publish = vi.fn() + const client = new WebRuntimeClient(server.pairing, { + status: { environmentId: 'browser', pairingRevision: 1, publish, verified: vi.fn() } + }) + clients.push(client) + await expect + .poll(() => client.statusOwner?.read().verification, { timeout: 3_000 }) + .toBe('verified') + expect(client.statusOwner?.read().status?.runtimeId).toBe('before') + runtimeId = 'after' + server.closeClients() + await expect + .poll(() => client.statusOwner?.read().status?.runtimeId, { timeout: 3_000 }) + .toBe('after') + expect(client.statusOwner?.read().transport).toBe('ready') + client.close() + expect(publish.mock.lastCall?.[0]).toMatchObject({ retired: true, verification: 'blocked' }) +}) diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 9bca14cc857..e31222d0bb6 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -44,6 +44,10 @@ export type AgentHookEventPayload = { /** Row projected from a structured session the host holds: `owned` while its provider child * runs here, `held` once the child is gone but the session is still open. Never persisted. */ structuredHost?: StructuredHostStatus + /** Runtime terminal handle the pane resolved to when main parsed this status off the PTY. + * Lets a reader rejoin the row to its terminal after the pane key moved. Never persisted: + * a handle belongs to the runtime that issued it. */ + terminalHandle?: string payload: ParsedAgentStatusPayload } diff --git a/src/shared/agent-interrupt-intent.ts b/src/shared/agent-interrupt-intent.ts index d058afa8049..2aaf61ec1b8 100644 --- a/src/shared/agent-interrupt-intent.ts +++ b/src/shared/agent-interrupt-intent.ts @@ -17,3 +17,26 @@ export type AgentInterruptInferenceRequest = { export function isAgentInterruptInputIntent(intent: unknown): intent is AgentInterruptInputIntent { return intent === 'plain-escape' || intent === 'ctrl-c' } + +// Why: these TUIs also close an overlay on a bare Escape (Claude's /btw composer, OMP/Pi's +// focused-child and settings views). The keypress is ambiguous at the source and nothing outside +// the TUI can disambiguate it, so it is never evidence a turn ended — only the provider's own +// hook may retire the row (#13547, #9208). Ctrl+C is unaffected; it has no navigation meaning. +const ESCAPE_ALSO_NAVIGATES_AGENT_TYPES: ReadonlySet = new Set([ + 'claude', + 'omp', + 'pi', + 'prime-agent' +]) + +/** True when this keypress is one of those TUIs' navigation Escape, and so proves nothing. */ +export function isNavigationEscapeIntent( + agentType: AgentType | undefined, + intent: AgentInterruptInputIntent +): boolean { + return ( + intent === 'plain-escape' && + agentType !== undefined && + ESCAPE_ALSO_NAVIGATES_AGENT_TYPES.has(agentType) + ) +} diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index e0fa3568c68..c2755993906 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -289,7 +289,7 @@ export function recognizeAgentProcessFromCommandLine( const keep = options?.includeHeadlessOneShot === true const tokens = tokenizeCommandLine(commandLine) const firstNormalized = normalizeProcessName(tokens[0]) - let direct = recognizeAgentProcess(tokens[0]) + let direct = recognizedAgentForProcess(firstNormalized) // Why: the generic Orca CLI is not an agent; only this subcommand launches its TUI mode. if (direct?.agent === 'claude-agent-teams' && tokens[1]?.toLowerCase() !== 'claude-teams') { direct = null diff --git a/src/shared/agent-title-evidence.ts b/src/shared/agent-title-evidence.ts index 8f212b5c75d..01b8c82f014 100644 --- a/src/shared/agent-title-evidence.ts +++ b/src/shared/agent-title-evidence.ts @@ -165,7 +165,8 @@ function agentForBareName(text: string): TuiAgent | null { const stripped = stripBareNameDecoration(trimmed) // Why labels too: an agent may write its own display name as the entire title (`⠐ Claude Code`). // That is the same claim as a bare token, just spelled the way the vendor spells it. - const label = DISPLAY_LABELS.find(([text]) => text === stripped.toLowerCase()) + const normalized = stripped.toLowerCase() + const label = DISPLAY_LABELS.find(([text]) => text === normalized) if (label) { return label[1] } @@ -182,7 +183,8 @@ function agentForWholeTitle(text: string): TuiAgent | null { return null } const stripped = stripBareNameDecoration(trimmed) - const label = DISPLAY_LABELS.find(([text]) => text === stripped.toLowerCase()) + const normalized = stripped.toLowerCase() + const label = DISPLAY_LABELS.find(([text]) => text === normalized) if (label) { return label[1] } @@ -255,9 +257,8 @@ function namesConsumedByAnchoredLabels( ): Set { const consumed = new Set() for (const segment of segments) { - const label = DISPLAY_LABELS.find( - ([text]) => text === stripBareNameDecoration(segment).toLowerCase() - ) + const normalized = stripBareNameDecoration(segment).toLowerCase() + const label = DISPLAY_LABELS.find(([text]) => text === normalized) if (label && anchoredNames.has(label[1])) { for (const name of namesIn(label[0])) { consumed.add(name) diff --git a/src/shared/ai-vault-search-query-operators.test.ts b/src/shared/ai-vault-search-query-operators.test.ts new file mode 100644 index 00000000000..0bb5cbc463b --- /dev/null +++ b/src/shared/ai-vault-search-query-operators.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { parseVaultQuery } from './ai-vault-session-filters' +import { + hasAiVaultSearchQueryOperators, + splitAiVaultSearchQuery +} from './ai-vault-search-query-operators' + +describe('what counts as an operator', () => { + it('splits repo: and path: out of the free text', () => { + const split = splitAiVaultSearchQuery('relay capacity repo:orca path:/work/app') + expect(split.text).toBe('relay capacity') + expect(split.terms).toEqual(['relay', 'capacity']) + expect(split.repoTerms).toEqual(['orca']) + expect(split.pathTerms).toEqual(['/work/app']) + expect(hasAiVaultSearchQueryOperators(split)).toBe(true) + }) + + it('keeps a value that only looks like an operator as ordinary text', () => { + const split = splitAiVaultSearchQuery('myrepo:x https://host/path:y') + expect(split.repoTerms).toEqual([]) + expect(split.pathTerms).toEqual([]) + expect(split.text).toBe('myrepo:x https://host/path:y') + }) + + it('reads a quoted operator value whole, including its spaces', () => { + expect(splitAiVaultSearchQuery('path:"/Users/ada/My Project" needle').pathTerms).toEqual([ + '/Users/ada/My Project' + ]) + }) + + it('does not let an apostrophe in prose swallow the operator between quotes', () => { + const split = splitAiVaultSearchQuery("it's a repo:orca thing's") + expect(split.repoTerms).toEqual(['orca']) + }) + + it('preserves operator case, which the panel folds and the index must not', () => { + // cwd_key keeps execution-host case, so folding here would lose a POSIX + // directory whose name differs only in case. + expect(splitAiVaultSearchQuery('path:/Work/App').pathTerms).toEqual(['/Work/App']) + expect(parseVaultQuery('path:/Work/App').pathTerms).toEqual(['/work/app']) + }) + + it('has no operators when the query is plain text', () => { + expect(hasAiVaultSearchQueryOperators(splitAiVaultSearchQuery('relay capacity'))).toBe(false) + }) +}) + +// The panel parses through this module now, so the two cannot disagree by +// construction. What is worth pinning is the handful of shapes where the +// panel's old hand-rolled tokenizer answered differently, so the change of +// behaviour is a decision on the record rather than a surprise. +describe('the shapes where the panel parser used to answer differently', () => { + it.each([ + ['repo:"" x', 'repoTerms'], + ['path:"" x', 'pathTerms'] + ] as const)('drops the empty operator value in %s instead of filtering on `""`', (query, key) => { + // The old tokenizer kept the quote characters as the value, so `repo:""` + // filtered on a label no session has and silently emptied the list. An + // operator with nothing in it is not a narrowing. + expect(splitAiVaultSearchQuery(query)[key]).toEqual([]) + expect(parseVaultQuery(query)[key]).toEqual([]) + }) + + it.each([ + ['repo:" " x', 'repoTerms'], + ['path:" " x', 'pathTerms'] + ] as const)('drops the whitespace-only operator value in %s too', (query, key) => { + // Same defect as `repo:""` wearing a different hat: an untrimmed `" "` + // survives as a term, matches no label, and empties the list. + expect(splitAiVaultSearchQuery(query)[key]).toEqual([]) + expect(parseVaultQuery(query)[key]).toEqual([]) + }) + + it('trims a quoted operator value rather than searching for the spaces', () => { + expect(splitAiVaultSearchQuery('repo:" session-search "').repoTerms).toEqual(['session-search']) + }) + + it.each(['"" empty', "'' empty", '" " empty'])( + 'reads the empty quotes in %s as an empty term', + (query) => { + // Same reason one level up: the old parser searched for the two characters + // and found nothing, where an empty term matches everything and leaves the + // rest of the query to do the work. + expect(parseVaultQuery(query).terms).toEqual(['', 'empty']) + } + ) + + it.each([ + ['"foo"bar', { terms: ['foo', 'bar'], repoTerms: [], pathTerms: [] }], + ['"a b"c', { terms: ['a b', 'c'], repoTerms: [], pathTerms: [] }], + ['repo:"a"b', { terms: ['b'], repoTerms: ['a'], pathTerms: [] }], + ['path:"a"b', { terms: ['b'], repoTerms: [], pathTerms: ['a'] }], + ['repo:"a b"c d', { terms: ['c', 'd'], repoTerms: ['a b'], pathTerms: [] }] + ])('reads %s exactly as the panel always has', (query, expected) => { + // A closing quote does not have to end a word. Requiring it turned each of + // these into one term carrying its own quote characters, which matches + // nothing; the apostrophe case below is protected by the token start, not + // by that rule. + expect(parseVaultQuery(query)).toEqual(expected) + }) +}) + +describe('agrees with the sessions panel parser on operator recognition', () => { + it.each([ + 'relay capacity', + 'repo:orca needle', + 'path:/work/app needle', + 'myrepo:x', + 'needle repo:orca path:/work/app', + 'path:"/Users/ada/My Project"', + 'https://host/path:y' + ])('reads the same operators out of %s', (query) => { + const split = splitAiVaultSearchQuery(query) + const parsed = parseVaultQuery(query) + const fold = (values: readonly string[]): string[] => values.map((v) => v.toLowerCase()).sort() + expect(fold(split.repoTerms)).toEqual(fold(parsed.repoTerms)) + expect(fold(split.pathTerms)).toEqual(fold(parsed.pathTerms)) + }) +}) diff --git a/src/shared/ai-vault-search-query-operators.ts b/src/shared/ai-vault-search-query-operators.ts new file mode 100644 index 00000000000..a75da8c769e --- /dev/null +++ b/src/shared/ai-vault-search-query-operators.ts @@ -0,0 +1,90 @@ +/** Anchored at a token start only, so `myrepo:x` and `https://h/path:x` stay literal. */ +const OPERATOR = /(repo|path):/iy + +export type AiVaultSearchQuerySplit = { + /** Query minus the operator tokens, quoting intact; what FTS sees. */ + text: string + /** The same free text as tokens with quotes stripped; what a substring matcher wants. */ + terms: readonly string[] + /** Operator values as typed apart from surrounding space: the panel folds case, the index does not. */ + repoTerms: readonly string[] + pathTerms: readonly string[] +} + +/** + * The one reading of `repo:` / `path:` in the product: the sessions panel and the + * search index must agree on what is an operator and what is ordinary text. + */ +export function splitAiVaultSearchQuery(query: string): AiVaultSearchQuerySplit { + const spans: string[] = [] + const terms: string[] = [] + const repoTerms: string[] = [] + const pathTerms: string[] = [] + let index = 0 + while (index < query.length) { + if (isBoundary(query[index])) { + index += 1 + continue + } + OPERATOR.lastIndex = index + const operator = OPERATOR.exec(query) + if (operator) { + const at = index + operator[0].length + const quoted = readQuoted(query, at) + const value = quoted?.value ?? readBare(query, at) + index = quoted ? quoted.end : at + value.length + // Trimmed for the same reason an empty value is dropped: `repo:" "` is + // not a narrowing anyone typed on purpose, and an untrimmed one matches + // no label at all, which silently empties the list. + const operand = value.trim() + if (operand) { + ;(operator[1]!.toLowerCase() === 'repo' ? repoTerms : pathTerms).push(operand) + } + continue + } + const quoted = readQuoted(query, index) + const value = quoted?.value ?? readBare(query, index) + const end = quoted ? quoted.end : index + value.length + spans.push(query.slice(index, end)) + // The span keeps the query verbatim for FTS; only the substring matcher's + // copy is trimmed, so `" "` reads as the empty term `""` already does + // rather than as a term no session's text contains. + terms.push(value.trim()) + index = end + } + return { text: spans.join(' '), terms, repoTerms, pathTerms } +} + +export function hasAiVaultSearchQueryOperators(split: AiVaultSearchQuerySplit): boolean { + return split.repoTerms.length > 0 || split.pathTerms.length > 0 +} + +function isBoundary(char: string | undefined): boolean { + return char === undefined || /\s/.test(char) +} + +/** + * A quoted span, or null when this is not one. + * + * What keeps the apostrophes in `it's a repo:orca thing's` from opening a span + * that swallows the operator is the caller: this only ever runs at a token + * start, and the quote in `it's` is not at one. The closing quote is then just + * the next one, wherever it falls, so `"a b"c` reads as the panel has always + * read it — the span, then the rest as its own token. + */ +function readQuoted(query: string, at: number): { value: string; end: number } | null { + const quote = query[at] + if (quote !== '"' && quote !== "'") { + return null + } + const close = query.indexOf(quote, at + 1) + return close === -1 ? null : { value: query.slice(at + 1, close), end: close + 1 } +} + +function readBare(query: string, at: number): string { + let end = at + while (end < query.length && !isBoundary(query[end])) { + end += 1 + } + return query.slice(at, end) +} diff --git a/src/shared/ai-vault-session-filters.ts b/src/shared/ai-vault-session-filters.ts index 7a0708151ed..39aedaf4626 100644 --- a/src/shared/ai-vault-session-filters.ts +++ b/src/shared/ai-vault-session-filters.ts @@ -8,6 +8,7 @@ import { normalizeRuntimePathSeparators } from './cross-platform-path' import { isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { splitAiVaultSearchQuery } from './ai-vault-search-query-operators' import { parseWslUncPath } from './wsl-paths' import type { AiVaultAgent, @@ -179,31 +180,61 @@ export function agentLabel(agent: AiVaultAgent): string { return aiVaultAgentLabel(agent) } +/** + * One reading of `repo:` / `path:` for the whole product. + * + * Delegates to `splitAiVaultSearchQuery`, which the search index also plans + * from, so a query cannot mean one thing in this list and another in the index. + * The values come back folded because everything this file compares is folded; + * the index keeps the unfolded form, which is why the split itself does not. + */ export function parseVaultQuery(query: string): ParsedQuery { - const terms: string[] = [] - const repoTerms: string[] = [] - const pathTerms: string[] = [] - - for (const rawToken of tokenizeQuery(query)) { - const token = rawToken.toLowerCase() - if (token.startsWith('repo:')) { - const value = token.slice('repo:'.length) - if (value) { - repoTerms.push(value) - } - continue - } - if (token.startsWith('path:')) { - const value = token.slice('path:'.length) - if (value) { - pathTerms.push(value) - } - continue - } - terms.push(token) + const split = splitAiVaultSearchQuery(query) + const fold = (values: readonly string[]): string[] => values.map((value) => value.toLowerCase()) + return { + terms: fold(split.terms), + repoTerms: fold(split.repoTerms), + pathTerms: fold(split.pathTerms) } +} - return { terms, repoTerms, pathTerms } +/** What `repo:` and `path:` are compared against for one session. */ +export type AiVaultQueryOperatorTarget = { + cwd: string | null + filePath: string + /** + * What `repo:` matches. The panel passes a resolved project label when it has + * one; everything else falls back to the last two path segments. + */ + repoLabel?: string +} + +/** + * Whether one session satisfies every `repo:` and `path:` term. + * + * The single definition of what those operators mean. The search index applies + * this over its retrieved rows rather than expressing it in SQL, because SQL + * cannot: LIKE folds ASCII and nothing else, and `path:` searches the transcript + * path as well as the working directory. Both keys are conjunctive, matching + * the qualifier semantics the panel has always had. + */ +export function matchesAiVaultQueryOperators( + target: AiVaultQueryOperatorTarget, + operators: { repoTerms: readonly string[]; pathTerms: readonly string[] } +): boolean { + if (operators.repoTerms.length > 0) { + const repoLabel = (target.repoLabel ?? folderLabel(target.cwd)).toLowerCase() + if (operators.repoTerms.some((term) => !repoLabel.includes(term.toLowerCase()))) { + return false + } + } + if (operators.pathTerms.length > 0) { + const pathSearch = `${target.cwd ?? ''} ${target.filePath}`.toLowerCase() + if (operators.pathTerms.some((term) => !pathSearch.includes(term.toLowerCase()))) { + return false + } + } + return true } function matchesQuery( @@ -229,25 +260,18 @@ function matchesQuery( return false } } - if (parsed.repoTerms.length > 0) { - const sessionProject = filters.sessionProjectById?.get(session.id) - const repoLabel = ( - sessionProject?.kind === 'repo' - ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) - : folderLabel(session.cwd) - ).toLowerCase() - if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { - return false - } - } - if (parsed.pathTerms.length > 0) { - const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase() - if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) { - return false - } - } - - return true + const sessionProject = filters.sessionProjectById?.get(session.id) + return matchesAiVaultQueryOperators( + { + cwd: session.cwd, + filePath: session.filePath, + repoLabel: + sessionProject?.kind === 'repo' + ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) + : undefined + }, + parsed + ) } function sessionSortTime(session: AiVaultSession, sort: AiVaultSort): number { @@ -291,25 +315,3 @@ function createAiVaultWorkspaceMatcher(workspacePath: string): (normalizedCwd: s const matchesLinux = createNormalizedPathInsideOrEqualMatcher(workspaceWslPath.linuxPath) return (cwd) => matches(cwd) || matchesLinux(cwd) } - -function tokenizeQuery(query: string): string[] { - const tokens: string[] = [] - // Why: keep quoted operator values (repo:/path:) intact so labels and paths - // containing spaces still match — e.g. path:"/Users/ada/My Project". - const pattern = /(repo|path):"([^"]+)"|(repo|path):'([^']+)'|"([^"]+)"|'([^']+)'|(\S+)/gi - let match: RegExpExecArray | null - while ((match = pattern.exec(query)) !== null) { - const operator = match[1] ?? match[3] - const operatorValue = match[2] ?? match[4] - if (operator && operatorValue?.trim()) { - tokens.push(`${operator.toLowerCase()}:${operatorValue.trim()}`) - continue - } - - const token = match[5] ?? match[6] ?? match[7] - if (token?.trim()) { - tokens.push(token.trim()) - } - } - return tokens -} diff --git a/src/shared/automation-cron-field-parsing.ts b/src/shared/automation-cron-field-parsing.ts new file mode 100644 index 00000000000..b43e4d4fe5a --- /dev/null +++ b/src/shared/automation-cron-field-parsing.ts @@ -0,0 +1,133 @@ +// Cron field parsing for Orca's automation schedules. +// A field step is bounded by the count of distinct values the field holds: a step of 90 on +// minutes is one value at :00, never "every 90 minutes", so it is refused as input (#15895). +export type CronParseOptions = { + /** Input-time gate: reject a step wider than the field's domain instead of silently + * degenerating to a single value. Off for persisted rows, which must keep running the + * cadence they were saved with rather than start throwing mid-tick. */ + rejectOversizedStep?: boolean +} + +export const MONTH_NAMES: Record = { + JAN: 1, + FEB: 2, + MAR: 3, + APR: 4, + MAY: 5, + JUN: 6, + JUL: 7, + AUG: 8, + SEP: 9, + OCT: 10, + NOV: 11, + DEC: 12 +} + +export const DAY_NAMES: Record = { + SU: 0, + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, + SUN: 0, + MON: 1, + TUE: 2, + WED: 3, + THU: 4, + FRI: 5, + SAT: 6 +} + +function parseCronNumber( + value: string, + names: Record | null, + field: string +): number { + const normalized = value.toUpperCase() + const named = names?.[normalized] + const parsed = named ?? Number(normalized) + if (!Number.isInteger(parsed)) { + throw new Error(`Invalid cron ${field}.`) + } + return parsed +} + +export function parseCronField(args: { + value: string + min: number + max: number + field: string + names?: Record + normalize?: (value: number) => number + // Distinct values the field holds, when `normalize` aliases some away — day of week + // spans 0-7 but holds seven days, so `*/8` is oversized even though 8 <= 7-0+1. + distinctValueCount?: number + rejectOversizedStep?: boolean +}): Set { + const result = new Set() + for (const rawPart of args.value.split(',')) { + const part = rawPart.trim() + if (!part) { + throw new Error(`Invalid cron ${args.field}.`) + } + const stepParts = part.split('/') + if (stepParts.length > 2) { + throw new Error(`Invalid cron ${args.field}.`) + } + const [rangePart, stepPart] = stepParts + if (!rangePart) { + throw new Error(`Invalid cron ${args.field}.`) + } + const step = stepPart === undefined ? 1 : Number(stepPart) + if (!Number.isInteger(step) || step < 1) { + throw new Error(`Invalid cron ${args.field}.`) + } + const domainSize = args.distinctValueCount ?? args.max - args.min + 1 + if (args.rejectOversizedStep && step > domainSize) { + throw new Error(`Cron ${args.field} step must be between 1 and ${domainSize}.`) + } + + let start: number + let end: number + if (rangePart === '*') { + start = args.min + end = args.max + } else if (rangePart.includes('-')) { + const rangeParts = rangePart.split('-') + if (rangeParts.length !== 2 || !rangeParts[0] || !rangeParts[1]) { + throw new Error(`Invalid cron ${args.field}.`) + } + const [startPart, endPart] = rangeParts + start = parseCronNumber(startPart, args.names ?? null, args.field) + end = parseCronNumber(endPart, args.names ?? null, args.field) + } else { + start = parseCronNumber(rangePart, args.names ?? null, args.field) + end = start + } + + const normalizedStart = args.normalize?.(start) ?? start + const normalizedEnd = args.normalize?.(end) ?? end + if ( + start < args.min || + start > args.max || + end < args.min || + end > args.max || + normalizedStart < args.min || + normalizedStart > args.max || + normalizedEnd < args.min || + normalizedEnd > args.max || + start > end + ) { + throw new Error(`Invalid cron ${args.field}.`) + } + for (let value = start; value <= end; value += step) { + result.add(args.normalize?.(value) ?? value) + } + } + if (result.size === 0) { + throw new Error(`Invalid cron ${args.field}.`) + } + return result +} diff --git a/src/shared/automation-cron-input-validation.test.ts b/src/shared/automation-cron-input-validation.test.ts new file mode 100644 index 00000000000..0c50dd0445b --- /dev/null +++ b/src/shared/automation-cron-input-validation.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextAutomationOccurrenceAfter } from './automation-schedule-occurrences' +import { + isValidAutomationCronSchedule, + isValidAutomationSchedule, + isRunnableAutomationSchedule, + parseCronExpression +} from './automation-schedule-parsing' + +const ascending = (values: Set): number[] => [...values].sort((left, right) => left - right) + +describe('cron oversized steps (#15895)', () => { + it('refuses a step wider than the field domain at input time', () => { + expect(isValidAutomationSchedule('*/90 * * * *')).toBe(false) + expect(isValidAutomationCronSchedule('*/90 * * * *')).toBe(false) + expect(isValidAutomationSchedule('0 */25 * * *')).toBe(false) + expect(isValidAutomationSchedule('0 9 */32 * *')).toBe(false) + expect(isValidAutomationSchedule('0 9 * */13 *')).toBe(false) + // Day of week spans 0-7 but holds seven days, so 8 is oversized even though 8 <= 7-0+1. + expect(isValidAutomationSchedule('0 9 * * */8')).toBe(false) + expect(() => parseCronExpression('*/90 * * * *', { rejectOversizedStep: true })).toThrow( + 'Cron minute step must be between 1 and 60.' + ) + }) + + it('keeps every step that fits its field domain', () => { + expect(isValidAutomationSchedule('*/15 * * * *')).toBe(true) + expect(isValidAutomationSchedule('*/60 * * * *')).toBe(true) + expect(isValidAutomationSchedule('0 */24 * * *')).toBe(true) + expect(isValidAutomationSchedule('0 9 */31 * *')).toBe(true) + expect(isValidAutomationSchedule('0 9 * */12 *')).toBe(true) + expect(isValidAutomationSchedule('0 9 * * */7')).toBe(true) + }) + + // The gate is input-only. A row persisted before it keeps running the cadence it was saved + // with rather than throwing mid-tick, which is what keeps it editable (see the editor tests). + it('still runs a persisted oversized step, degenerating it to its single value', () => { + expect(isRunnableAutomationSchedule('*/90 * * * *')).toBe(true) + expect(ascending(parseCronExpression('*/90 * * * *').minutes)).toEqual([0]) + expect( + nextAutomationOccurrenceAfter( + '*/90 * * * *', + new Date(2026, 4, 1, 0, 0).getTime(), + new Date(2026, 4, 15, 9, 5).getTime() + ) + ).toBe(new Date(2026, 4, 15, 10, 0).getTime()) + }) +}) + +// Node reads the OS timezone on Windows and ignores a runtime process.env.TZ change, so the +// stub — and the precondition asserting it took — cannot work there. +describe.skipIf(process.platform === 'win32')('cron occurrence local-time controls', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('skips the wall-clock hour that local spring-forward removes', () => { + vi.stubEnv('TZ', 'America/New_York') + // Precondition: the stub took and 2026-03-08 really does lose an hour here. + expect(new Date(2026, 2, 8, 12).getTimezoneOffset()).toBe( + new Date(2026, 2, 8, 0).getTimezoneOffset() - 60 + ) + + expect( + nextAutomationOccurrenceAfter( + '30 2 * * *', + new Date(2026, 0, 1).getTime(), + new Date(2026, 2, 8, 0, 0).getTime() + ) + ).toBe(new Date(2026, 2, 9, 2, 30).getTime()) + }) + + it('fires both repeats of the wall-clock hour local fall-back replays', () => { + vi.stubEnv('TZ', 'America/New_York') + expect(new Date(2026, 10, 1, 12).getTimezoneOffset()).toBe( + new Date(2026, 10, 1, 0).getTimezoneOffset() + 60 + ) + + const first = nextAutomationOccurrenceAfter( + '30 1 * * *', + new Date(2026, 0, 1).getTime(), + new Date(2026, 10, 1, 0, 0).getTime() + ) + const second = nextAutomationOccurrenceAfter( + '30 1 * * *', + new Date(2026, 0, 1).getTime(), + first + ) + + expect(new Date(first).getHours()).toBe(1) + expect(new Date(second).getHours()).toBe(1) + expect(second - first).toBe(60 * 60 * 1000) + + // Pre-existing limit this change does not touch or fix (#20154): local 01:30 is ambiguous, + // so flooring it rebuilds the earlier EDT instant and the scan lands back on the EST repeat + // instead of tomorrow. Pinned so a later DST fix has to update it deliberately. + expect( + nextAutomationOccurrenceAfter('30 1 * * *', new Date(2026, 0, 1).getTime(), second) + ).toBe(second) + }) +}) diff --git a/src/shared/automation-schedule-parsing.ts b/src/shared/automation-schedule-parsing.ts index 746bb434de2..af3ce324f1a 100644 --- a/src/shared/automation-schedule-parsing.ts +++ b/src/shared/automation-schedule-parsing.ts @@ -2,6 +2,12 @@ import type { AutomationSchedulePreset } from './automations-types' import { cronHasPossibleOccurrence } from './automation-cron-occurrence' import { isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { + DAY_NAMES, + MONTH_NAMES, + parseCronField, + type CronParseOptions +} from './automation-cron-field-parsing' export const AUTOMATION_CRON_EXPRESSION_MAX_BYTES = 2 * 1024 export type ParsedRrule = { @@ -24,38 +30,10 @@ export type ParsedCron = { } export type ParsedSchedule = ParsedRrule | ParsedCron +export type { CronParseOptions } + const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const const WEEKDAY_CODES = ['MO', 'TU', 'WE', 'TH', 'FR'] as const -const MONTH_NAMES: Record = { - JAN: 1, - FEB: 2, - MAR: 3, - APR: 4, - MAY: 5, - JUN: 6, - JUL: 7, - AUG: 8, - SEP: 9, - OCT: 10, - NOV: 11, - DEC: 12 -} -const DAY_NAMES: Record = { - SU: 0, - MO: 1, - TU: 2, - WE: 3, - TH: 4, - FR: 5, - SA: 6, - SUN: 0, - MON: 1, - TUE: 2, - WED: 3, - THU: 4, - FRI: 5, - SAT: 6 -} function parseRrule(rrule: string): ParsedRrule { const entries = new Map() @@ -88,101 +66,22 @@ function parseRrule(rrule: string): ParsedRrule { return { kind: 'rrule', freq, byDay, byHour, byMinute } } -function parseCronNumber( - value: string, - names: Record | null, - field: string -): number { - const normalized = value.toUpperCase() - const named = names?.[normalized] - const parsed = named ?? Number(normalized) - if (!Number.isInteger(parsed)) { - throw new Error(`Invalid cron ${field}.`) - } - return parsed -} - -function parseCronField(args: { - value: string - min: number - max: number - field: string - names?: Record - normalize?: (value: number) => number -}): Set { - const result = new Set() - for (const rawPart of args.value.split(',')) { - const part = rawPart.trim() - if (!part) { - throw new Error(`Invalid cron ${args.field}.`) - } - const stepParts = part.split('/') - if (stepParts.length > 2) { - throw new Error(`Invalid cron ${args.field}.`) - } - const [rangePart, stepPart] = stepParts - if (!rangePart) { - throw new Error(`Invalid cron ${args.field}.`) - } - const step = stepPart === undefined ? 1 : Number(stepPart) - if (!Number.isInteger(step) || step < 1) { - throw new Error(`Invalid cron ${args.field}.`) - } - - let start: number - let end: number - if (rangePart === '*') { - start = args.min - end = args.max - } else if (rangePart.includes('-')) { - const rangeParts = rangePart.split('-') - if (rangeParts.length !== 2 || !rangeParts[0] || !rangeParts[1]) { - throw new Error(`Invalid cron ${args.field}.`) - } - const [startPart, endPart] = rangeParts - start = parseCronNumber(startPart, args.names ?? null, args.field) - end = parseCronNumber(endPart, args.names ?? null, args.field) - } else { - start = parseCronNumber(rangePart, args.names ?? null, args.field) - end = start - } - - const normalizedStart = args.normalize?.(start) ?? start - const normalizedEnd = args.normalize?.(end) ?? end - if ( - start < args.min || - start > args.max || - end < args.min || - end > args.max || - normalizedStart < args.min || - normalizedStart > args.max || - normalizedEnd < args.min || - normalizedEnd > args.max || - start > end - ) { - throw new Error(`Invalid cron ${args.field}.`) - } - for (let value = start; value <= end; value += step) { - result.add(args.normalize?.(value) ?? value) - } - } - if (result.size === 0) { - throw new Error(`Invalid cron ${args.field}.`) - } - return result -} - -export function parseCronExpression(expression: string): ParsedCron { +export function parseCronExpression( + expression: string, + options: CronParseOptions = {} +): ParsedCron { const parts = getAutomationCronExpressionFields(expression, 6) if (parts.length !== 5) { throw new Error('Cron schedule must have five fields.') } const [minute, hour, dayOfMonth, month, dayOfWeek] = parts + const rejectOversizedStep = options.rejectOversizedStep ?? false const daysOfMonth = parseCronField({ value: dayOfMonth, min: 1, max: 31, - field: 'day of month' + field: 'day of month', + rejectOversizedStep }) const daysOfWeek = parseCronField({ value: dayOfWeek, @@ -190,14 +89,29 @@ export function parseCronExpression(expression: string): ParsedCron { max: 7, field: 'day of week', names: DAY_NAMES, - normalize: (value) => (value === 7 ? 0 : value) + normalize: (value) => (value === 7 ? 0 : value), + distinctValueCount: 7, + rejectOversizedStep }) return { kind: 'cron', - minutes: parseCronField({ value: minute, min: 0, max: 59, field: 'minute' }), - hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour' }), + minutes: parseCronField({ + value: minute, + min: 0, + max: 59, + field: 'minute', + rejectOversizedStep + }), + hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour', rejectOversizedStep }), daysOfMonth, - months: parseCronField({ value: month, min: 1, max: 12, field: 'month', names: MONTH_NAMES }), + months: parseCronField({ + value: month, + min: 1, + max: 12, + field: 'month', + names: MONTH_NAMES, + rejectOversizedStep + }), daysOfWeek, dayOfMonthRestricted: daysOfMonth.size !== 31, dayOfWeekRestricted: daysOfWeek.size !== 7 @@ -245,33 +159,50 @@ function isAutomationCronFieldWhitespace(code: number): boolean { ) } -export function parseSchedule(schedule: string): ParsedSchedule { +export function parseSchedule(schedule: string, options: CronParseOptions = {}): ParsedSchedule { const trimmed = schedule.trim() if (trimmed.includes('=')) { return parseRrule(trimmed) } - return parseCronExpression(trimmed) + return parseCronExpression(trimmed, options) } -export function isValidAutomationSchedule(schedule: string): boolean { +function scheduleRuns(schedule: string, options: CronParseOptions): boolean { try { - const parsed = parseSchedule(schedule) - if (parsed.kind === 'cron' && !cronHasPossibleOccurrence(parsed, Date.now())) { - throw new Error('Cron schedule has no possible run.') - } - return true + const parsed = parseSchedule(schedule, options) + return parsed.kind !== 'cron' || cronHasPossibleOccurrence(parsed, Date.now()) } catch { return false } } +function cronScheduleRuns(schedule: string, options: CronParseOptions): boolean { + try { + return cronHasPossibleOccurrence(parseCronExpression(schedule.trim(), options), Date.now()) + } catch { + return false + } +} + +/** Accepts a schedule as new input, oversized-step refusal included (#15895). */ +export function isValidAutomationSchedule(schedule: string): boolean { + return scheduleRuns(schedule, { rejectOversizedStep: true }) +} + export function isValidAutomationCronSchedule(schedule: string): boolean { - try { - const parsed = parseCronExpression(schedule.trim()) - return cronHasPossibleOccurrence(parsed, Date.now()) - } catch { - return false - } + return cronScheduleRuns(schedule, { rejectOversizedStep: true }) +} + +// Whether Orca can still run a schedule it did not just receive. A row saved before the +// oversized-step gate, or one a provider owns, keeps running the cadence it has, so reading +// it back must not re-judge it as input — otherwise renaming an automation would demand +// re-authoring a schedule the user never touched. +export function isRunnableAutomationSchedule(schedule: string): boolean { + return scheduleRuns(schedule, {}) +} + +export function isRunnableAutomationCronSchedule(schedule: string): boolean { + return cronScheduleRuns(schedule, {}) } export function parseAutomationRrule(rrule: string): { diff --git a/src/shared/check-job-log-byte-cap.test.ts b/src/shared/check-job-log-byte-cap.test.ts new file mode 100644 index 00000000000..13785b28674 --- /dev/null +++ b/src/shared/check-job-log-byte-cap.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as byteLimits from './utf8-byte-limits' +import { + PR_CHECK_LOG_TAIL_BYTES, + PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR, + sliceCheckLogTail +} from './check-job-log-tail-slice' + +describe('check log excerpt byte-cap work', () => { + afterEach(() => vi.restoreAllMocks()) + + it.each(['recent', 'earlier'] as const)('bounds byte counting for a long %s line', (position) => { + const longLine = `error: ${'x'.repeat(2 * 1024 * 1024)}` + const input = position === 'recent' ? longLine : `${longLine}\n${'recent\n'.repeat(100)}` + const byteLength = vi.spyOn(byteLimits, 'getUtf8ByteLength') + const output = sliceCheckLogTail(input) + const countedUnits = byteLength.mock.calls.reduce((total, [text]) => total + text.length, 0) + + expect(countedUnits).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + expect(Buffer.byteLength(output)).toBe(PR_CHECK_LOG_TAIL_BYTES) + if (position === 'recent') { + expect(output).toBe('x'.repeat(PR_CHECK_LOG_TAIL_BYTES)) + } else { + expect(output.endsWith(PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR)).toBe(true) + expect(output).toContain('\nrecent\n') + } + }) + + it.each(['x', 'é', '界', '😀', '\ud83d', '\udc00'])( + 'preserves byte boundaries for %j', + (unit) => { + const width = Buffer.byteLength(unit) + for (const delta of [-1, 0, 1]) { + const count = Math.floor(PR_CHECK_LOG_TAIL_BYTES / width) + delta + const input = unit.repeat(count) + const output = sliceCheckLogTail(input) + expect(output).toBe( + unit.repeat(Math.min(count, Math.floor(PR_CHECK_LOG_TAIL_BYTES / width))) + ) + expect(Buffer.byteLength(output)).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + } + } + ) + + it('retains earlier multibyte context whose byte count exceeds its code-unit count', () => { + const failure = `error: ${'界'.repeat(6000)}` + const input = `${failure}\n${'recent\n'.repeat(103)}` + const prefix = `${failure}\nrecent\nrecent\n${PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR}` + expect(prefix.length).toBeLessThan(PR_CHECK_LOG_TAIL_BYTES) + expect(Buffer.byteLength(prefix)).toBeGreaterThan(PR_CHECK_LOG_TAIL_BYTES) + expect(sliceCheckLogTail(input)).toBe( + byteLimits.clampUtf8TextTail(prefix, PR_CHECK_LOG_TAIL_BYTES).text + ) + }) +}) diff --git a/src/shared/check-job-log-tail-slice.test.ts b/src/shared/check-job-log-tail-slice.test.ts index aad235f49ac..fc4f4aaae97 100644 --- a/src/shared/check-job-log-tail-slice.test.ts +++ b/src/shared/check-job-log-tail-slice.test.ts @@ -6,6 +6,31 @@ import { } from './check-job-log-tail-slice' describe('sliceCheckLogTail', () => { + it.each([1, 2, 3, 5, 7, 11, 31, 100])( + 'preserves the newest 30 earlier context lines with errors every %i lines', + (spacing) => { + const lines = Array.from({ length: 500 }, (_, index) => + index % spacing === 0 ? `error: failure ${index}` : `line ${index}` + ) + const selected = new Set() + for (let error = 0; error < 400; error += spacing) { + for (let offset = -2; offset <= 2; offset++) { + if (error + offset >= 0 && error + offset < 400) { + selected.add(error + offset) + } + } + } + const context = [...selected].sort((a, b) => a - b).slice(-30) + expect(sliceCheckLogTail(lines.join('\n'))).toBe( + [ + ...context.map((index) => lines[index]), + PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR, + ...lines.slice(400) + ].join('\n') + ) + } + ) + it('keeps the recent tail when no earlier error markers are present', () => { const logLines = Array.from({ length: 210 }, (_, index) => `line ${index}`) const sliced = sliceCheckLogTail(logLines.join('\n')) diff --git a/src/shared/check-job-log-tail-slice.ts b/src/shared/check-job-log-tail-slice.ts index 2c4e0a61e20..19afcdfc75d 100644 --- a/src/shared/check-job-log-tail-slice.ts +++ b/src/shared/check-job-log-tail-slice.ts @@ -1,4 +1,8 @@ -import { clampUtf8TextTail, getUtf8ByteLength } from './utf8-byte-limits' +import { + clampUtf8TextTail, + getUtf8ByteLength, + isUtf8ByteLengthWithinLimit +} from './utf8-byte-limits' export const PR_CHECK_LOG_TAIL_LINES = 200 export const PR_CHECK_LOG_TAIL_RECENT_LINES = 100 @@ -13,7 +17,7 @@ const ERROR_LINE_PATTERN = /(?:##\[error\]|::error::|::error\b|\berror:|FAILED|exit code|ENOENT|EACCES|panic:|AssertionError)/i function applyLogTailByteCap(text: string): string { - if (getUtf8ByteLength(text) <= PR_CHECK_LOG_TAIL_BYTES) { + if (isUtf8ByteLengthWithinLimit(text, PR_CHECK_LOG_TAIL_BYTES)) { return text } return clampUtf8TextTail(text, PR_CHECK_LOG_TAIL_BYTES).text @@ -21,7 +25,9 @@ function applyLogTailByteCap(text: string): string { function joinLogExcerptWithByteCap(prefixLines: string[], recentLines: string[]): string { const prefix = prefixLines.join('\n') - const prefixByteLength = getUtf8ByteLength(prefix) + // UTF-16 length is a lower bound; oversized prefixes need no exact byte count. + const prefixByteLength = + prefix.length >= PR_CHECK_LOG_TAIL_BYTES ? PR_CHECK_LOG_TAIL_BYTES : getUtf8ByteLength(prefix) if (prefixByteLength >= PR_CHECK_LOG_TAIL_BYTES) { return clampUtf8TextTail(prefix, PR_CHECK_LOG_TAIL_BYTES).text } @@ -34,14 +40,18 @@ function joinLogExcerptWithByteCap(prefixLines: string[], recentLines: string[]) function collectEarlierErrorLineIndexes(lines: string[], recentStart: number): number[] { const indexes = new Set() - for (let index = 0; index < recentStart; index += 1) { + // Newest-first errors and descending windows add the newest unique indexes first. + for (let index = recentStart - 1; index >= 0; index -= 1) { if (!ERROR_LINE_PATTERN.test(lines[index] ?? '')) { continue } const contextStart = Math.max(0, index - PR_CHECK_LOG_TAIL_ERROR_CONTEXT_LINES) const contextEnd = Math.min(recentStart - 1, index + PR_CHECK_LOG_TAIL_ERROR_CONTEXT_LINES) - for (let contextIndex = contextStart; contextIndex <= contextEnd; contextIndex += 1) { + for (let contextIndex = contextEnd; contextIndex >= contextStart; contextIndex -= 1) { indexes.add(contextIndex) + if (indexes.size === PR_CHECK_LOG_TAIL_MAX_EARLIER_LINES) { + return [...indexes].sort((left, right) => left - right) + } } } return [...indexes].sort((left, right) => left - right) diff --git a/src/shared/child-process/bounded-output-sink.test.ts b/src/shared/child-process/bounded-output-sink.test.ts new file mode 100644 index 00000000000..c94458e7211 --- /dev/null +++ b/src/shared/child-process/bounded-output-sink.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { createOutputSink } from './bounded-output-sink' + +describe('bounded process output', () => { + it('can read empty output and continue collecting', () => { + const sink = createOutputSink(10) + expect(sink.text()).toBe('') + sink.write('one') + expect(sink.text()).toBe('one') + sink.write('two') + expect(sink.text()).toBe('onetwo') + expect(sink.truncated()).toBe(false) + }) + + it('decodes UTF-8 across every chunk boundary and byte limit', () => { + const bytes = Buffer.from('a💻é\r\nb') + for (let split = 0; split <= bytes.length; split += 1) { + for (let cap = 0; cap <= bytes.length + 1; cap += 1) { + const sink = createOutputSink(cap) + sink.write(bytes.subarray(0, split)) + sink.write(bytes.subarray(split)) + expect(sink.text()).toBe(bytes.subarray(0, cap).toString('utf8')) + expect(sink.truncated()).toBe(bytes.length > cap) + } + } + }) + + it('clips a single string chunk at the byte limit', () => { + const sink = createOutputSink(3) + sink.write('a💻') + expect(sink.text()).toBe('a�') + expect(sink.truncated()).toBe(true) + }) +}) diff --git a/src/shared/child-process/bounded-output-sink.ts b/src/shared/child-process/bounded-output-sink.ts index 195e466fdf6..8e1a9309978 100644 --- a/src/shared/child-process/bounded-output-sink.ts +++ b/src/shared/child-process/bounded-output-sink.ts @@ -25,7 +25,10 @@ export function createOutputSink(maxBytes: number): { chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk) bytes += chunk.length }, - text: () => Buffer.concat(chunks).toString('utf8'), + text: () => + chunks.length === 0 + ? '' + : (chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)).toString('utf8'), // Why: callers that parse the output need to tell a short answer from a // clipped one -- truncated JSON or JSONL parses as a smaller valid result. truncated: () => bytes > maxBytes diff --git a/src/shared/child-process/windows-command-line.ts b/src/shared/child-process/windows-command-line.ts index 401141e36c7..e3402f93c19 100644 --- a/src/shared/child-process/windows-command-line.ts +++ b/src/shared/child-process/windows-command-line.ts @@ -32,6 +32,9 @@ * because that part `CommandLineToArgvW` does interpret. */ function quoteWindows(value: string, escapePercent: boolean): string { + if (!(escapePercent ? /[\\"%]/ : /[\\"]/).test(value)) { + return `"${value}"` + } let quoted = '"' let backslashes = 0 for (const char of value) { diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index c9bfb8eb789..de5cc14529b 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -573,10 +573,32 @@ describe('buildArgs (OpenCode)', () => { describe('buildArgs (Antigravity)', () => { const spec = getCommitMessageAgentSpec('antigravity')! - it('runs agy with --print, --sandbox, and --model flags', () => { - const args = spec.buildArgs({ prompt: '', model: 'Gemini 3.5 Flash (Medium)' }) - expect(args).toEqual(['--print', '--sandbox', '--model', 'Gemini 3.5 Flash (Medium)']) - expect(spec.promptDelivery).toBe('stdin') + it('runs agy with the prompt attached to --print, then --sandbox and --model flags', () => { + const args = spec.buildArgs({ + prompt: 'real commit prompt', + model: 'Gemini 3.5 Flash (Medium)' + }) + expect(args).toEqual([ + '--print=real commit prompt', + '--sandbox', + '--model', + 'Gemini 3.5 Flash (Medium)' + ]) + expect(spec.promptDelivery).toBe('argv') + }) + + it('binds a leading-dash prompt to --print instead of letting it parse as an option', () => { + const args = spec.buildArgs({ prompt: '-fix: something', model: 'Gemini 3.5 Flash (Medium)' }) + expect(args[0]).toBe('--print=-fix: something') + }) + + // Why: pins argv construction only. Real agy 1.2.1 separately rejects a --print value + // that exactly matches a registered flag name (its own heuristic, independent of this + // fix) — verified `agy --print=--sandbox` still errors there. Real prompts are never + // literally a bare flag name, so this doesn't affect actual generation. + it('still glues a prompt that collides with a flag name onto --print', () => { + const args = spec.buildArgs({ prompt: '--sandbox', model: 'Gemini 3.5 Flash (Medium)' }) + expect(args[0]).toBe('--print=--sandbox') }) it('uses dynamic model discovery via agy models', () => { diff --git a/src/shared/commit-message-agent-specs-primary.ts b/src/shared/commit-message-agent-specs-primary.ts index e6ba42f775b..3e42a4c5865 100644 --- a/src/shared/commit-message-agent-specs-primary.ts +++ b/src/shared/commit-message-agent-specs-primary.ts @@ -197,7 +197,6 @@ export function buildPrimaryCommitMessageAgentSpecs({ '--print', '--no-session', '--no-tools', - '--no-extensions', '--no-skills', '--no-context-files', '--mode', diff --git a/src/shared/commit-message-agent-specs-secondary.ts b/src/shared/commit-message-agent-specs-secondary.ts index e22fce018f8..2ad7691ac36 100644 --- a/src/shared/commit-message-agent-specs-secondary.ts +++ b/src/shared/commit-message-agent-specs-secondary.ts @@ -212,8 +212,11 @@ export function buildSecondaryCommitMessageAgentSpecs({ id: 'antigravity', label: 'Antigravity', binary: 'agy', - promptDelivery: 'stdin', - buildArgs: ({ model }) => ['--print', '--sandbox', '--model', model], + // agy's --print takes the prompt as its value (#19539, #14059). Deliver on argv + // using `--print=` so a leading-dash prompt binds to the flag instead of + // being parsed as its own option, and --sandbox/--model stay separate options. + promptDelivery: 'argv', + buildArgs: ({ prompt, model }) => [`--print=${prompt}`, '--sandbox', '--model', model], modelSource: 'dynamic', modelDiscovery: { binary: 'agy', args: ['models'], parse: parseAntigravityModels }, models: [ diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index 0b728307bf7..2d58a5cf6e7 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -2,6 +2,29 @@ import { describe, expect, it } from 'vitest' import { planCommitMessageGeneration, planAgentBinary } from './commit-message-plan' describe('planCommitMessageGeneration', () => { + it('keeps extension-provided Pi models available in generated Git text plans', () => { + const result = planCommitMessageGeneration( + { agentId: 'pi', model: 'local-extension/model' }, + 'Write a commit message' + ) + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error(result.error) + } + expect(result.plan.args).not.toContain('--no-extensions') + expect(result.plan.args).toEqual( + expect.arrayContaining([ + '--no-session', + '--no-tools', + '--no-skills', + '--no-context-files', + '--model', + 'local-extension/model' + ]) + ) + expect(result.plan.stdinPayload).toBe('Write a commit message') + }) + it('plans Claude non-interactive generation with the prompt on stdin only', () => { const result = planCommitMessageGeneration( { @@ -155,6 +178,115 @@ describe('planCommitMessageGeneration', () => { }) }) + it('plans Antigravity generation with the prompt attached to --print, not stdin (#19539, #14059)', () => { + const result = planCommitMessageGeneration( + { + agentId: 'antigravity', + model: 'Gemini 3.5 Flash (Medium)' + }, + 'real commit prompt' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'agy', + args: ['--print=real commit prompt', '--sandbox', '--model', 'Gemini 3.5 Flash (Medium)'], + stdinPayload: null, + label: 'Antigravity' + } + }) + }) + + it('keeps a leading-dash Antigravity prompt bound to --print instead of parsing as an option', () => { + const result = planCommitMessageGeneration( + { agentId: 'antigravity', model: 'Gemini 3.5 Flash (Medium)' }, + '-fix: something' + ) + + expect(result.ok).toBe(true) + expect(result.ok && result.plan.args.slice(0, 2)).toEqual([ + '--print=-fix: something', + '--sandbox' + ]) + }) + + // Why: pins argv construction only. Real agy 1.2.1 separately rejects a --print value + // that exactly matches a registered flag name (its own heuristic, independent of this + // fix) — verified `agy --print=--sandbox` still errors there. Real prompts are never + // literally a bare flag name, so this doesn't affect actual generation. + it('still glues an Antigravity prompt that collides with a flag name onto --print', () => { + const result = planCommitMessageGeneration( + { agentId: 'antigravity', model: 'Gemini 3.5 Flash (Medium)' }, + '--sandbox' + ) + + expect(result.ok).toBe(true) + expect(result.ok && result.plan.args.slice(0, 2)).toEqual(['--print=--sandbox', '--sandbox']) + }) + + // Why: agy has no documented stdin mode for --print (#19539's body: "--print ... is + // not a boolean flag that automatically reads from stdin; it expects the prompt + // string as its option argument"), so a large staged patch now rides on argv. This + // is the same unguarded argv delivery cursor/kimi/copilot already use (see the + // parity assertion below) — pinned here as a known property, not a regression. + it('puts a large Antigravity prompt on argv with no size guard, same as other argv-delivery agents', () => { + const bigPrompt = 'y'.repeat(70_000) + const result = planCommitMessageGeneration( + { agentId: 'antigravity', model: 'Gemini 3.5 Flash (Medium)' }, + bigPrompt + ) + + expect(result.ok).toBe(true) + expect(result.ok && result.plan.args[0]).toBe(`--print=${bigPrompt}`) + expect(result.ok && result.plan.stdinPayload).toBeNull() + + const cursorResult = planCommitMessageGeneration( + { agentId: 'cursor', model: 'auto' }, + bigPrompt + ) + expect(cursorResult.ok).toBe(true) + expect(cursorResult.ok && cursorResult.plan.args.at(-1)).toBe(bigPrompt) + expect(cursorResult.ok && cursorResult.plan.stdinPayload).toBeNull() + }) + + // Why: real #14059 reproduction config — CLI arguments field repeats --model and adds + // --add-dir/--effort/--dangerously-skip-permissions. Confirms none of it gets swallowed + // into the --print operand and the duplicate --model is deduped the same way every + // other spec's recipe args already are (DEFAULT_SINGLETON_OPTIONS, unaffected by + // argument order). + it('keeps #14059-style recipe CLI arguments intact and deduped around the print operand', () => { + const result = planCommitMessageGeneration( + { + agentId: 'antigravity', + model: 'Gemini 3.5 Flash (Medium)', + agentArgs: + '--add-dir . --model gemini-3.6-flash --effort low --dangerously-skip-permissions' + }, + 'Generate a concise git commit message for the currently staged changes.' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'agy', + args: [ + '--print=Generate a concise git commit message for the currently staged changes.', + '--sandbox', + '--model', + 'gemini-3.6-flash', + '--add-dir', + '.', + '--effort', + 'low', + '--dangerously-skip-permissions' + ], + stdinPayload: null, + label: 'Antigravity' + } + }) + }) + it('plans Codex exec as non-interactive read-only generation with the prompt on stdin only', () => { const result = planCommitMessageGeneration( { diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index f173914c789..f1fdf98eb80 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -165,7 +165,8 @@ export function getRuntimePathBasename(value: string): string { if (!trimmed) { return '' } - return trimmed.split(/[\\/]/).findLast(Boolean) ?? '' + const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + return trimmed.slice(separator + 1) } /** diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index e509fe05bfc..e397e8ed5bf 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -320,17 +320,15 @@ describe('execution host registry', () => { ]) }) - it('includes runtime hosts from repo ownership but marks them disconnected without live status', () => { + it('keeps runtime hosts checking before their first status result', () => { const hosts = buildExecutionHostRegistry({ repos: [{ connectionId: null, executionHostId: 'runtime:env-2' }], settings: { activeRuntimeEnvironmentId: null } }) - // No live status means no evidence the Orca server is reachable, so it must - // read 'disconnected' rather than defaulting to 'available'/"Connected". expect(hosts).toMatchObject([ { id: 'local', health: 'local' }, - { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'disconnected' } + { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'connecting' } ]) }) @@ -373,3 +371,29 @@ describe('execution host registry', () => { ]) }) }) + +it('keeps an initial unknown-transport verification connecting', () => { + const hosts = buildExecutionHostRegistry({ + repos: [], + settings: null, + runtimeEnvironments: [{ id: 'host', name: 'Host' }], + runtimeStatusByEnvironmentId: new Map([ + [ + 'host', + { + status: null, + snapshot: { + environmentId: 'host', + pairingRevision: 1, + sequence: 1, + checkedAt: 0, + status: null, + verification: 'checking', + transport: 'unknown' + } + } + ] + ]) + }) + expect(hosts.find((host) => host.id === 'runtime:host')?.health).toBe('connecting') +}) diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index a970f9da45c..bad6b40f257 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from './runtime-host-status' import { LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel, @@ -49,6 +50,7 @@ type RuntimeEnvironmentSummary = { } type RuntimeHostStatus = { + snapshot?: RuntimeHostStatusSnapshot status?: RuntimeStatus | null remoteControl?: RuntimeStatus['remoteControl'] | null appVersion?: string | null @@ -158,9 +160,24 @@ function addRuntimeHost( const hostId = toRuntimeExecutionHostId(environmentId) const runtimeStatus = statusByEnvironmentId?.get(environmentId) const status = runtimeStatus?.status - const compatibility = runtimeCompatibility(status) + const snapshot = runtimeStatus?.snapshot + const metadata = status ?? snapshot?.status + const compatibility = runtimeCompatibility(metadata) const remoteControl = runtimeStatus?.remoteControl ?? status?.remoteControl - const controlHealth = runtimeControlHealth(remoteControl) + const controlHealth = snapshot?.retired + ? 'disconnected' + : snapshot?.verification === 'blocked' + ? 'blocked' + : !runtimeStatus || + snapshot?.verification === 'checking' || + snapshot?.transport === 'disconnected' || + snapshot?.transport === 'connecting' + ? 'connecting' + : snapshot?.transport === 'ready' + ? compatibility?.kind === 'blocked' + ? 'blocked' + : 'available' + : runtimeControlHealth(remoteControl) setHost(hosts, { id: hostId, kind: 'runtime', @@ -168,12 +185,12 @@ function addRuntimeHost( detail: 'Orca server', health: controlHealth ?? runtimeHealth(status, compatibility, remoteControl), compatibility: compatibility ?? undefined, - capabilities: status?.capabilities, - appVersion: runtimeStatus?.appVersion ?? status?.appVersion ?? null, - protocolVersion: status?.runtimeProtocolVersion ?? status?.protocolVersion ?? null, + capabilities: metadata?.capabilities, + appVersion: runtimeStatus?.appVersion ?? metadata?.appVersion ?? null, + protocolVersion: metadata?.runtimeProtocolVersion ?? metadata?.protocolVersion ?? null, minCompatibleClientVersion: - status?.minCompatibleRuntimeClientVersion ?? status?.minCompatibleMobileVersion ?? null, - platform: status?.hostPlatform ?? null, + metadata?.minCompatibleRuntimeClientVersion ?? metadata?.minCompatibleMobileVersion ?? null, + platform: metadata?.hostPlatform ?? null, remoteControlState: remoteControl ?? null, ...(source ? { source } : {}) }) diff --git a/src/shared/git-tracked-pathspecs.test.ts b/src/shared/git-tracked-pathspecs.test.ts new file mode 100644 index 00000000000..5bd1611319d --- /dev/null +++ b/src/shared/git-tracked-pathspecs.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { isTrackedPathSpec, partitionTrackedPathSpecs } from './git-tracked-pathspecs' + +function previousPartition(filePaths: readonly string[], trackedPaths: readonly string[]) { + const normalize = (value: string) => value.replace(/\\/g, '/').replace(/\/+$/, '') + const isTracked = (filePath: string) => { + const normalized = normalize(filePath) + return trackedPaths.some((trackedPath) => { + const normalizedTracked = normalize(trackedPath) + return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`) + }) + } + return { + trackedPaths: filePaths.filter(isTracked), + untrackedPaths: filePaths.filter((filePath) => !isTracked(filePath)) + } +} + +function countNormalizations(run: () => unknown): number { + const replace = vi.spyOn(String.prototype, 'replace') + try { + run() + return replace.mock.calls.filter( + ([pattern]) => pattern instanceof RegExp && pattern.source === '\\\\' + ).length + } finally { + replace.mockRestore() + } +} + +describe('tracked pathspec partition', () => { + it.each([ + ['docs', ['docs/readme.md'], true], + ['doc', ['docs/readme.md'], false], + ['docs/file', ['docs/file-extra'], false], + ['docs///', ['docs\\readme.md'], true], + ['src\\file.ts\\', ['src/file.ts///'], true], + ['DOCS', ['docs/readme.md'], false], + ['./docs', ['docs/readme.md'], false], + ['docs//file', ['docs/file'], false], + ['docs/../file', ['file'], false], + ['[ab].txt', ['a.txt'], false], + ['[ab].txt', ['[ab].txt'], true], + [':(glob)*', ['a.txt'], false], + ['a b/é', ['a b/é/file\nname'], true], + ['é', ['e\u0301'], false], + ['C:\\repo\\docs', ['C:/repo/docs/file'], true], + ['\\\\host\\share', ['//host/share/file'], true], + ['', [], false], + ['', ['relative'], false], + ['', ['/absolute'], true], + ['/', [''], true] + ] as const)('keeps matching semantics for %j against %j', (request, tracked, expected) => { + expect(isTrackedPathSpec(request, tracked)).toBe(expected) + expect(partitionTrackedPathSpecs([request], tracked)).toEqual( + previousPartition([request], tracked) + ) + }) + + it('preserves original spelling, duplicates and relative order in both action lists', () => { + const requests = ['new', 'docs\\', '[ab].txt', 'docs///', 'new', 'src/file', 'docs\\'] + const tracked = ['docs/readme', 'src/file-extra', '[ab].txt', 'docs/readme'] + expect(partitionTrackedPathSpecs(requests, tracked)).toEqual({ + trackedPaths: ['docs\\', '[ab].txt', 'docs///', 'docs\\'], + untrackedPaths: ['new', 'new', 'src/file'] + }) + }) + + it('matches the previous implementation across combinations of path edges', () => { + const paths = [ + '', + '/', + '.', + './a', + 'a', + 'a/', + 'a//', + 'a/b', + 'a\\b', + 'a//b', + 'ab', + 'A', + '../a', + 'a/../b', + '[a]', + '*', + 'a b', + 'é', + 'e\u0301', + '/a', + '//host/share', + 'C:\\a' + ] + for (const tracked of [[], paths, ...paths.map((entry) => [entry])]) { + expect(partitionTrackedPathSpecs(paths, tracked)).toEqual(previousPartition(paths, tracked)) + } + }) + + it('normalizes each visited tracked entry once and each request once per operation', () => { + const requests = Array.from({ length: 64 }, (_, index) => `missing/${index}`) + const tracked = Array.from({ length: 256 }, (_, index) => `docs\\file-${index}///`) + expect(countNormalizations(() => previousPartition(requests, tracked))).toBe(32_896) + expect(countNormalizations(() => partitionTrackedPathSpecs(requests, tracked))).toBe(320) + expect(countNormalizations(() => partitionTrackedPathSpecs(requests, tracked))).toBe(320) + }) + + it('retains early exit for a selected directory with many tracked descendants', () => { + const tracked = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}`) + expect(countNormalizations(() => previousPartition(['docs'], tracked))).toBe(4) + expect(countNormalizations(() => partitionTrackedPathSpecs(['docs'], tracked))).toBe(2) + expect(countNormalizations(() => partitionTrackedPathSpecs([], tracked))).toBe(0) + }) + + it('does not reuse tracked evidence across operations', () => { + expect(partitionTrackedPathSpecs(['docs'], ['docs/file']).trackedPaths).toEqual(['docs']) + expect(partitionTrackedPathSpecs(['docs'], []).untrackedPaths).toEqual(['docs']) + }) +}) diff --git a/src/shared/git-tracked-pathspecs.ts b/src/shared/git-tracked-pathspecs.ts new file mode 100644 index 00000000000..7a717c2a374 --- /dev/null +++ b/src/shared/git-tracked-pathspecs.ts @@ -0,0 +1,37 @@ +function normalizeGitPathForCompare(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function createTrackedPathSpecMatcher( + trackedPaths: readonly string[] +): (filePath: string) => boolean { + // Normalize lazily so selecting a directory still stops at its first tracked descendant. + const normalizedTrackedPaths: string[] = [] + return (filePath) => { + const normalized = normalizeGitPathForCompare(filePath) + const descendantPrefix = `${normalized}/` + return trackedPaths.some((trackedPath, index) => { + const normalizedTracked = (normalizedTrackedPaths[index] ??= + normalizeGitPathForCompare(trackedPath)) + return normalizedTracked === normalized || normalizedTracked.startsWith(descendantPrefix) + }) + } +} + +export function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { + return createTrackedPathSpecMatcher(trackedPaths)(filePath) +} + +export function partitionTrackedPathSpecs( + filePaths: readonly string[], + trackedPathSpecs: readonly string[] +): { trackedPaths: string[]; untrackedPaths: string[] } { + const isTracked = createTrackedPathSpecMatcher(trackedPathSpecs) + const trackedPaths: string[] = [] + const untrackedPaths: string[] = [] + // Keep original spellings, duplicates and order: these arrays select restore versus clean. + for (const filePath of filePaths) { + ;(isTracked(filePath) ? trackedPaths : untrackedPaths).push(filePath) + } + return { trackedPaths, untrackedPaths } +} diff --git a/src/shared/hermes-session-run-index.test.ts b/src/shared/hermes-session-run-index.test.ts new file mode 100644 index 00000000000..260eec18630 --- /dev/null +++ b/src/shared/hermes-session-run-index.test.ts @@ -0,0 +1,72 @@ +import { expect, it, vi } from 'vitest' +import { HermesSessionRunIndex } from './hermes-session-run-index' + +const time = (key: string | null): number => + key === null || key === 'invalid' ? Number.NaN : Number(key) + +function legacyMatch( + keys: (string | null)[], + used: Set, + key: string | null +): number | null { + const exact = keys.findIndex((candidate, index) => !used.has(index) && candidate === key) + if (exact !== -1) { + return exact + } + const outputTime = time(key) + if (!Number.isFinite(outputTime)) { + return null + } + let best: number | null = null + let bestGap = Infinity + keys.forEach((candidate, index) => { + const gap = outputTime - time(candidate) + if (!used.has(index) && Number.isFinite(gap) && gap >= 0 && gap <= 24 && gap < bestGap) { + best = index + bestGap = gap + } + }) + return best +} + +it('preserves exact, null, invalid, duplicate-time, source-order, and maximum-gap matching', () => { + let seed = 70291 + const random = (max: number): number => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max + } + const pool = [null, 'invalid', '-1', '0', '00', '1', '2', '24', '25', '26', '50'] + for (let trial = 0; trial < 500; trial++) { + const keys = Array.from({ length: random(60) }, () => pool[random(pool.length)]) + const index = new HermesSessionRunIndex(keys, time, 24) + const used = new Set() + for (let query = 0; query < 80; query++) { + const key = pool[random(pool.length)] + const expected = legacyMatch(keys, used, key) + expect(index.find(key)).toBe(expected) + // Invalid source rows can decline a match; find must leave it available. + if (expected !== null && random(4) !== 0) { + used.add(expected) + index.use(expected) + } + } + expect(index.used).toEqual(used) + } +}) + +it('parses each session timestamp once across a long run history', () => { + const parse = vi.fn(time) + const count = 5000 + const index = new HermesSessionRunIndex( + Array.from({ length: count }, (_, i) => String(i * 60)), + parse, + 24 + ) + for (let i = count - 1; i >= 0; i--) { + const match = index.find(String(i * 60 + 2)) + expect(match).toBe(i) + index.use(match!) + } + expect(parse).toHaveBeenCalledTimes(count * 2) + expect(index.find('9999999')).toBeNull() +}) diff --git a/src/shared/hermes-session-run-index.ts b/src/shared/hermes-session-run-index.ts new file mode 100644 index 00000000000..7916214f20a --- /dev/null +++ b/src/shared/hermes-session-run-index.ts @@ -0,0 +1,85 @@ +/** Exact keys win; otherwise select the nearest unused earlier session, with source-order ties. */ +export class HermesSessionRunIndex { + readonly used = new Set() + private readonly exact = new Map() + private readonly timed: { time: number; index: number }[] = [] + private readonly positionByIndex = new Map() + private readonly predecessors: number[] + + constructor( + keys: (string | null)[], + private readonly parseTime: (key: string | null) => number, + private readonly maxGapMs: number + ) { + keys.forEach((key, index) => { + let group = this.exact.get(key) + if (!group) { + group = { indices: [], cursor: 0 } + this.exact.set(key, group) + } + group.indices.push(index) + const time = parseTime(key) + if (Number.isFinite(time)) { + this.timed.push({ time, index }) + } + }) + // The rightmost equal-time row must be the first row in source order. + this.timed.sort((a, b) => a.time - b.time || b.index - a.index) + this.timed.forEach((row, position) => this.positionByIndex.set(row.index, position + 1)) + // Zero is the sentinel before the first row; consumed rows link to their predecessor. + this.predecessors = Array.from({ length: this.timed.length + 1 }, (_, position) => position) + } + + find(key: string | null): number | null { + const group = this.exact.get(key) + if (group) { + while (group.cursor < group.indices.length && this.used.has(group.indices[group.cursor])) { + group.cursor++ + } + if (group.cursor < group.indices.length) { + return group.indices[group.cursor] + } + } + const time = this.parseTime(key) + if (!Number.isFinite(time)) { + return null + } + let low = 0 + let high = this.timed.length + while (low < high) { + const mid = low + Math.floor((high - low) / 2) + if (this.timed[mid].time <= time) { + low = mid + 1 + } else { + high = mid + } + } + const position = this.findPredecessor(low) + if (position === 0) { + return null + } + const candidate = this.timed[position - 1] + return time - candidate.time <= this.maxGapMs ? candidate.index : null + } + + use(index: number): void { + this.used.add(index) + const position = this.positionByIndex.get(index) + if (position !== undefined) { + this.predecessors[position] = this.findPredecessor(position - 1) + } + } + + private findPredecessor(position: number): number { + let root = position + while (this.predecessors[root] !== root) { + root = this.predecessors[root] + } + while (this.predecessors[position] !== position) { + const next = this.predecessors[position] + this.predecessors[position] = root + position = next + } + return root + } +} diff --git a/src/shared/hosted-review-refs.test.ts b/src/shared/hosted-review-refs.test.ts index b4d8b7c493b..e5225fa89ff 100644 --- a/src/shared/hosted-review-refs.test.ts +++ b/src/shared/hosted-review-refs.test.ts @@ -38,4 +38,184 @@ describe('isRemoteHeadRef', () => { expect(isRemoteHeadRef('orphan/HEAD')).toBe(true) expect(isRemoteHeadRef('orphan/feature/HEAD')).toBe(false) }) + + it('uses the longest match among overlapping slash-containing remotes', () => { + const remotes = ['foo', 'foo/bar', 'foo/bar/baz', 'foo/barbaz'] + expect(isRemoteHeadRef('foo/bar/baz/HEAD', remotes)).toBe(true) + expect(isRemoteHeadRef('foo/barbaz/HEAD', remotes)).toBe(true) + expect(isRemoteHeadRef('foo/bar/baz/qux/HEAD', remotes)).toBe(false) + expect(isRemoteHeadRef('refs/remotes/foo/bar/HEAD', remotes)).toBe(true) + }) + + it('rejects nested HEAD segments regardless of depth', () => { + expect(isRemoteHeadRef('origin/HEAD/HEAD', ['origin'])).toBe(false) + expect(isRemoteHeadRef('origin/a/b/c/HEAD', ['origin'])).toBe(false) + expect(isRemoteHeadRef('refs/remotes/origin/HEAD/nested/HEAD', ['origin'])).toBe(false) + expect(isRemoteHeadRef('HEAD', ['origin'])).toBe(false) + }) + + it('is unaffected by duplicate and reordered remote entries', () => { + expect(isRemoteHeadRef('foo/bar/HEAD', ['foo/bar', 'foo', 'foo/bar', 'foo'])).toBe(true) + expect(isRemoteHeadRef('foo/bar/feature/HEAD', ['foo', 'foo/bar', 'foo', 'foo/bar'])).toBe( + false + ) + }) + + it('does not mutate or require a mutable remotes array', () => { + const remotes = Object.freeze(['foo', 'foo/bar']) + expect(isRemoteHeadRef('foo/bar/HEAD', remotes)).toBe(true) + expect(isRemoteHeadRef('foo/bar/main', remotes)).toBe(false) + expect(remotes).toEqual(['foo', 'foo/bar']) + }) +}) + +/** Pre-change implementation, kept inline as the differential oracle for the fast path. */ +function isRemoteHeadRefOracle(ref: string, remotes: readonly string[] = []): boolean { + const shortRef = ref.startsWith('refs/remotes/') ? ref.slice('refs/remotes/'.length) : ref + const remote = [...remotes] + .sort((left, right) => right.length - left.length) + .find((candidate) => shortRef.startsWith(`${candidate}/`)) + if (remote) { + return shortRef.slice(remote.length + 1) === 'HEAD' + } + return shortRef.split('/').length === 2 && shortRef.endsWith('/HEAD') +} + +/** Counts array copies (`[...remotes]`, one per sort) and elements copied, via the spread iterator. */ +function instrumentRemotes(names: readonly string[]): { + remotes: readonly string[] + counts: { copies: number; copiedElements: number } +} { + const remotes = [...names] + const counts = { copies: 0, copiedElements: 0 } + Object.defineProperty(remotes, Symbol.iterator, { + configurable: true, + value: function* countingIterator(this: readonly string[]) { + counts.copies += 1 + for (let index = 0; index < this.length; index += 1) { + counts.copiedElements += 1 + yield this[index] as string + } + } + }) + return { remotes, counts } +} + +const REF_PREFIXES = ['', 'refs/remotes/', 'refs/heads/', 'refs/remotes/origin/'] +const REF_BODIES = [ + '', + '/HEAD', + 'HEAD', + 'HEAD/HEAD', + 'main', + 'feature/HEAD', + 'a/b/c/HEAD', + 'origin', + 'origin/HEAD', + 'origin/main', + 'origin/head', + 'origin/HEADX', + 'origin/HEAD/x', + 'origin/feature/HEAD', + 'up/HEAD', + 'upstream/HEAD', + 'foo/HEAD', + 'foo/bar/HEAD', + 'foo/bar/baz/HEAD', + 'foo/bar/main', + 'foo/barbaz/HEAD' +] +const REMOTE_POOL = [ + 'origin', + 'up', + 'upstream', + 'foo', + 'foo/bar', + 'foo/bar/baz', + 'foo/barbaz', + 'HEAD' +] + +const ALL_REFS = REF_PREFIXES.flatMap((prefix) => REF_BODIES.map((body) => `${prefix}${body}`)) +const ALL_REMOTE_SETS = Array.from({ length: 1 << REMOTE_POOL.length }, (_unused, mask) => + REMOTE_POOL.filter((_remote, bit) => (mask & (1 << bit)) !== 0) +) + +describe('isRemoteHeadRef fast path', () => { + it('matches the pre-change implementation across every ref x remote-set combination', () => { + let combinations = 0 + const mismatches: string[] = [] + for (const ref of ALL_REFS) { + for (const remoteSet of ALL_REMOTE_SETS) { + // Duplicated + reversed variant exercises the sort's tie handling too. + for (const remotes of [remoteSet, [...remoteSet, ...remoteSet].toReversed()]) { + combinations += 1 + if (isRemoteHeadRef(ref, remotes) !== isRemoteHeadRefOracle(ref, remotes)) { + mismatches.push(`${ref} | [${remotes.join(',')}]`) + } + } + } + } + expect(mismatches).toEqual([]) + expect(combinations).toBe(ALL_REFS.length * ALL_REMOTE_SETS.length * 2) + expect(combinations).toBe(43008) + }) + + it('also matches the oracle when remotes are omitted entirely', () => { + for (const ref of ALL_REFS) { + expect(isRemoteHeadRef(ref)).toBe(isRemoteHeadRefOracle(ref)) + } + }) + + it('copies and sorts nothing for ordinary refs that the oracle copies once each', () => { + const ordinaryRefs = Array.from( + { length: 80 }, + (_unused, index) => `origin/feature/branch-${index}` + ) + const remoteNames = ['origin', 'upstream', 'fork'] + + const before = instrumentRemotes(remoteNames) + for (const ref of ordinaryRefs) { + expect(isRemoteHeadRefOracle(ref, before.remotes)).toBe(false) + } + expect(before.counts).toEqual({ copies: 80, copiedElements: 240 }) + + const after = instrumentRemotes(remoteNames) + for (const ref of ordinaryRefs) { + expect(isRemoteHeadRef(ref, after.remotes)).toBe(false) + } + expect(after.counts).toEqual({ copies: 0, copiedElements: 0 }) + }) + + it('scales the skipped copies linearly with candidate count', () => { + const remoteNames = ['origin', 'upstream', 'fork'] + const candidates = Array.from({ length: 4104 }, (_unused, index) => + index % 2 === 0 ? `refs/remotes/origin/branch-${index}` : `refs/heads/branch-${index}` + ) + + const before = instrumentRemotes(remoteNames) + for (const ref of candidates) { + isRemoteHeadRefOracle(ref, before.remotes) + } + expect(before.counts.copies).toBe(4104) + + const after = instrumentRemotes(remoteNames) + for (const ref of candidates) { + isRemoteHeadRef(ref, after.remotes) + } + expect(after.counts.copies).toBe(0) + }) + + it('still copies and sorts for a /HEAD candidate, which now pays one extra suffix check', () => { + const remoteNames = ['origin', 'upstream', 'fork'] + + const before = instrumentRemotes(remoteNames) + expect(isRemoteHeadRefOracle('refs/remotes/origin/HEAD', before.remotes)).toBe(true) + + const after = instrumentRemotes(remoteNames) + expect(isRemoteHeadRef('refs/remotes/origin/HEAD', after.remotes)).toBe(true) + + expect(after.counts).toEqual(before.counts) + expect(after.counts).toEqual({ copies: 1, copiedElements: 3 }) + }) }) diff --git a/src/shared/hosted-review-refs.ts b/src/shared/hosted-review-refs.ts index 2a738413b94..82ad2294349 100644 --- a/src/shared/hosted-review-refs.ts +++ b/src/shared/hosted-review-refs.ts @@ -12,6 +12,10 @@ export function normalizeHostedReviewBaseRef(ref: string): string { /** Exclude only a remote's direct symbolic HEAD, preserving branches like feature/HEAD. */ export function isRemoteHeadRef(ref: string, remotes: readonly string[] = []): boolean { + // Every true result ends in `/HEAD`, so ordinary refs can skip the remote copy+sort. + if (!ref.endsWith('/HEAD')) { + return false + } const shortRef = ref.startsWith('refs/remotes/') ? ref.slice('refs/remotes/'.length) : ref const remote = [...remotes] .sort((left, right) => right.length - left.length) diff --git a/src/shared/json-text-structure-limit.test.ts b/src/shared/json-text-structure-limit.test.ts index 2a1112ba772..c86764a1613 100644 --- a/src/shared/json-text-structure-limit.test.ts +++ b/src/shared/json-text-structure-limit.test.ts @@ -37,4 +37,28 @@ describe('JSON text structure admission', () => { }) ).not.toThrow() }) + + it.each([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])('handles a quote preceded by %i backslashes', (count) => { + const content = `"${'\\'.repeat(count)}"[[]]` + const check = () => + assertJsonTextStructureWithinLimits(content, { + structuralTokens: 3, + nestingDepth: 2 + }) + if (count % 2 === 0) { + expect(check).toThrowError(new JsonTextStructureCapacityError('structuralTokens', 3)) + } else { + expect(check).not.toThrow() + } + }) + + it('resumes counting after escaped quotes and long string values', () => { + const content = JSON.stringify({ value: 'ordinary text [{,}] \\" '.repeat(10_000), next: [] }) + expect(() => + assertJsonTextStructureWithinLimits(content, { structuralTokens: 7, nestingDepth: 2 }) + ).not.toThrow() + expect(() => + assertJsonTextStructureWithinLimits(content, { structuralTokens: 6, nestingDepth: 2 }) + ).toThrowError(new JsonTextStructureCapacityError('structuralTokens', 6)) + }) }) diff --git a/src/shared/json-text-structure-limit.ts b/src/shared/json-text-structure-limit.ts index e0ede23db54..f33ecd1e2ca 100644 --- a/src/shared/json-text-structure-limit.ts +++ b/src/shared/json-text-structure-limit.ts @@ -25,23 +25,37 @@ export function assertJsonTextStructureWithinLimits( assertLimit(limits.nestingDepth) let structuralTokens = 0 let depth = 0 - let inString = false - let escaped = false - for (let index = 0; index < content.length; index += 1) { const character = content[index] - if (inString) { - if (escaped) { - escaped = false - } else if (character === '\\') { - escaped = true - } else if (character === '"') { - inString = false - } - continue - } if (character === '"') { - inString = true + let quote = content.indexOf('"', index + 1) + if (quote !== -1) { + // Only an odd backslash run escapes the quote. + let backslashes = 0 + for (let at = quote - 1; at > index && content[at] === '\\'; at -= 1) { + backslashes += 1 + } + if (backslashes % 2 !== 0) { + // Escape-heavy strings use the linear scan to avoid repeated native searches. + let escaped = false + for (quote += 1; quote < content.length; quote += 1) { + if (escaped) { + escaped = false + } else if (content[quote] === '\\') { + escaped = true + } else if (content[quote] === '"') { + break + } + } + if (quote === content.length) { + quote = -1 + } + } + } + if (quote === -1) { + return + } + index = quote continue } if (!isStructuralToken(character)) { diff --git a/src/shared/mobile-push-contract.ts b/src/shared/mobile-push-contract.ts index e18c53defb3..c9dbd426ea9 100644 --- a/src/shared/mobile-push-contract.ts +++ b/src/shared/mobile-push-contract.ts @@ -93,3 +93,7 @@ export function parseMobilePushRegistration(value: unknown): MobilePushRegistrat expiresAt: registration.expiresAt } } + +export type MobilePushTestResult = + | { accepted: true } + | { accepted: false; reason: 'not_registered' | 'unavailable' | 'rate_limited' | 'rejected' } diff --git a/src/shared/native-chat-activity-tail-line.test.ts b/src/shared/native-chat-activity-tail-line.test.ts new file mode 100644 index 00000000000..e441127a03a --- /dev/null +++ b/src/shared/native-chat-activity-tail-line.test.ts @@ -0,0 +1,87 @@ +import { expect, it, vi } from 'vitest' +import { normalizePromptField } from './agent-status-field-normalization' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity' + +function status(text: string): AgentJournalRenderItem { + return { + itemId: 'status', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'status', text } + } +} + +it('does not trim every preceding status line to select the final activity', () => { + const text = `${'Previous activity\n'.repeat(500)}Preparing the answer` + const trim = vi.spyOn(String.prototype, 'trim') + try { + expect(selectStructuredAgentTurnActivity([status(text)], 'turn')).toEqual({ + kind: 'description', + text: 'Preparing the answer' + }) + expect(trim.mock.calls.length).toBeLessThan(10) + } finally { + trim.mockRestore() + } +}) + +it('preserves the last nonempty LF-delimited line before prompt normalization', () => { + const texts = [ + '', + '\n', + '\n\n', + ' \r\n\t', + 'first\rsecond', + '\nfirst\r\nsecond\r\n', + 'first\n\u00a0\u2003\n', + 'first\n\u2028second\u2029', + 'a\n😀', + 'a\n\ud800', + 'a\n\udc00', + `a\n${'x'.repeat(199)}😀`, + `first\n${' '.repeat(3000)}last`, + 'first\n\0\n', + 'first\n\ufeff\n' + ] + const alphabet = [ + 'a', + ' ', + '\n', + '\r', + '\t', + '\u00a0', + '\u2003', + '\u2028', + '\ufeff', + '😀', + '\ud800', + '\udc00' + ] + let seed = 57 + const next = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed + } + for (let index = 0; index < 1000; index++) { + let text = '' + const length = next() % 300 + for (let offset = 0; offset < length; offset++) { + text += alphabet[next() % alphabet.length] + } + texts.push(text) + } + for (const text of texts) { + const line = text + .split('\n') + .map((part) => part.trim()) + .findLast((part) => part.length > 0) + const normalized = line ? normalizePromptField(line) : '' + const expected = normalized ? { kind: 'description', text: normalized } : null + expect(selectStructuredAgentTurnActivity([status(text)], 'turn')).toEqual(expected) + expect(selectStructuredAgentTurnActivity([], 'turn', { turnId: 'turn', text })).toEqual( + expected + ) + } +}) diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts index 783cf3d38fa..8096d0824a0 100644 --- a/src/shared/native-chat-diff.ts +++ b/src/shared/native-chat-diff.ts @@ -100,13 +100,15 @@ function patchTextFromToolInput(value: Record): string | null { if (!Array.isArray(value.changes)) { return null } - const sections = value.changes.flatMap((entry) => { + const sections: string[] = [] + let length = 0 + for (const entry of value.changes) { if (typeof entry !== 'object' || entry === null) { - return [] + continue } const change = entry as Record if (typeof change.diff !== 'string') { - return [] + continue } const path = typeof change.path === 'string' ? change.path : 'file' const kind = @@ -114,8 +116,14 @@ function patchTextFromToolInput(value: Record): string | null { ? (change.kind as Record) : null const nextPath = kind && typeof kind.move_path === 'string' ? kind.move_path : path - return [`--- ${path}\n+++ ${nextPath}\n${change.diff}`] - }) + const section = `--- ${path}\n+++ ${nextPath}\n${change.diff}` + length += section.length + (sections.length > 0 ? 1 : 0) + sections.push(section) + // Keep the extra character that tells toLines the diff was truncated. + if (length > MAX_DIFF_CHARS) { + break + } + } return sections.length > 0 ? sections.join('\n') : null } diff --git a/src/shared/native-chat-edit-patch-files.ts b/src/shared/native-chat-edit-patch-files.ts index 3ad996051ff..b0bd101338d 100644 --- a/src/shared/native-chat-edit-patch-files.ts +++ b/src/shared/native-chat-edit-patch-files.ts @@ -44,6 +44,10 @@ export function editFilesFromPatchText( callerPath: string | null, summaryOnly = false ): NativeChatEditFileSummary[] | null { + if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) { + // A file count cannot name a card, regardless of the patch contents. + return null + } // The body carries its own marker when the journal clipped it. Read as // content it becomes a numbered line of the file, and the rows that follow // are reported complete. @@ -52,12 +56,6 @@ export function editFilesFromPatchText( // One card per file the patch touches: run together, the later files' rows // and gutter numbers sit under the first file's name. const split = unifiedPatchSections(moved.body) - if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) { - // The producer joined several files' patches and kept a count in place of a - // path, so nothing here can name a file. Naming the card after the count - // would assert a file that does not exist. - return null - } // A patch that names one file is the file the call is reporting on, so the // call's own path wins — it is the provider's, where the header's is relative // to the patch. A patch naming several has no one path, and a rename's diff --git a/src/shared/native-chat-session-option-state.test.ts b/src/shared/native-chat-session-option-state.test.ts index f2827207bf7..0cd0af94d80 100644 --- a/src/shared/native-chat-session-option-state.test.ts +++ b/src/shared/native-chat-session-option-state.test.ts @@ -48,6 +48,15 @@ describe('applyNativeChatReportedSessionOptions', () => { }) describe('matchNativeChatCatalogModelId', () => { + it('prefers the longest contained id and keeps catalog order for ties', () => { + const catalog = { + ...CLAUDE_SESSION_OPTION_CATALOG, + models: ['a', 'abc', 'xyz'].map((id) => ({ id, label: id, options: [] })) + } + expect(matchNativeChatCatalogModelId(catalog, 'provider-xyz-abc')).toBe('abc') + expect(catalog.models.map((model) => model.id)).toEqual(['a', 'abc', 'xyz']) + }) + it('matches exact ids, labels, and provider-id containment', () => { expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'sonnet')).toBe('sonnet') expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'Sonnet 5')).toBe('sonnet') diff --git a/src/shared/native-chat-session-option-state.ts b/src/shared/native-chat-session-option-state.ts index 5d070969775..14eddfa7ff9 100644 --- a/src/shared/native-chat-session-option-state.ts +++ b/src/shared/native-chat-session-option-state.ts @@ -167,8 +167,14 @@ export function matchNativeChatCatalogModelId( if (byLabel) { return byLabel.id } - const containing = [...catalog.models] - .sort((left, right) => right.id.length - left.id.length) - .find((model) => normalized.includes(model.id.toLowerCase())) - return containing?.id ?? null + let containingId: string | null = null + for (const model of catalog.models) { + if ( + (containingId === null || model.id.length > containingId.length) && + normalized.includes(model.id.toLowerCase()) + ) { + containingId = model.id + } + } + return containingId } diff --git a/src/shared/native-chat-tool-preview-prefix.ts b/src/shared/native-chat-tool-preview-prefix.ts new file mode 100644 index 00000000000..0a3c10a37d1 --- /dev/null +++ b/src/shared/native-chat-tool-preview-prefix.ts @@ -0,0 +1,29 @@ +export const MAX_TOOL_PREVIEW_LENGTH = 80 +const SHORT_INPUT_LENGTH = 160 + +// One extra normalized code unit proves truncation and inequality with an 80-unit label. +export function collapsedToolInputPrefix(input: string): string { + if (input.length <= SHORT_INPUT_LENGTH) { + return input.replace(/\s+/g, ' ').trim() + } + let collapsed = '' + let pendingSpace = false + const whitespace = /\s+/y + for (let index = 0; index < input.length;) { + whitespace.lastIndex = index + if (whitespace.test(input)) { + index = whitespace.lastIndex + pendingSpace = collapsed.length > 0 + continue + } + if (pendingSpace) { + collapsed += ' ' + pendingSpace = false + } + collapsed += input[index++] + if (collapsed.length > MAX_TOOL_PREVIEW_LENGTH) { + return collapsed + } + } + return collapsed +} diff --git a/src/shared/native-chat-tool-preview-scan.test.ts b/src/shared/native-chat-tool-preview-scan.test.ts new file mode 100644 index 00000000000..9aaab44c073 --- /dev/null +++ b/src/shared/native-chat-tool-preview-scan.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { createToolInputDisplay, summarizeToolInput } from './native-chat-tool-summary' + +const originalSummary = (input: string): string => { + const collapsed = input.replace(/\s+/g, ' ').trim() + return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 79)}…` +} + +describe('bounded tool preview whitespace normalization', () => { + it('avoids whole-input replacement for long prose previews', () => { + const input = 'a b\n\t'.repeat(20_000) + const spy = vi.spyOn(String.prototype, 'replace') + let fullReplacements: number + let display: ReturnType + try { + display = createToolInputDisplay(input) + fullReplacements = spy.mock.instances.filter((receiver) => String(receiver) === input).length + } finally { + spy.mockRestore() + } + expect(display.label).toBe(originalSummary(input)) + expect(display.hasDetail).toBe(true) + expect(fullReplacements).toBe(0) + }) + + it('preserves exact labels and detail flags across whitespace and UTF-16 boundaries', () => { + const whitespace = '\t\n\v\f\r \u00a0\u1680\u2000\u200a\u2028\u2029\u202f\u205f\u3000\ufeff' + const inputs = [ + '', + whitespace.repeat(50), + `${'x'.repeat(79)}…`, + '😀'.repeat(41), + '\ud800'.repeat(82), + '\u0085\u200b'.repeat(50) + ] + for (const length of [0, 1, 78, 79, 80, 81, 159, 160, 161]) { + inputs.push(`${whitespace}${'x'.repeat(length)}${whitespace.repeat(30)}`) + inputs.push(`${'x'.repeat(length)}${whitespace}tail`) + } + for (const input of inputs) { + const expected = originalSummary(input) + const display = createToolInputDisplay(input) + expect(summarizeToolInput(input)).toBe(expected) + expect(display.label).toBe(expected) + expect(display.hasDetail).toBe(input.replace(/\s+/g, ' ').trim() !== expected) + expect(display.formatDetail()).toBe(input.length > 4000 ? `${input.slice(0, 4000)}…` : input) + } + }) +}) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts index 60cd4ba9d53..ac631031916 100644 --- a/src/shared/native-chat-tool-summary.ts +++ b/src/shared/native-chat-tool-summary.ts @@ -1,7 +1,10 @@ +import { + collapsedToolInputPrefix, + MAX_TOOL_PREVIEW_LENGTH +} from './native-chat-tool-preview-prefix' import type { NativeChatMcpIdentity } from './native-chat-tool-identity' import { isToolCallBlock, type NativeChatBlock } from './native-chat-types' -const MAX_PREVIEW_LENGTH = 80 const MAX_PREVIEW_STRING_INPUT = 160 const MAX_PREVIEW_COLLECTION_ITEMS = 8 const MAX_PREVIEW_DEPTH = 2 @@ -34,10 +37,10 @@ export type ToolInputDisplay = { } export function summarizeToolInput(input: unknown): string { - const collapsed = toRawPreview(input).replace(/\s+/g, ' ').trim() - return collapsed.length <= MAX_PREVIEW_LENGTH + const collapsed = collapsedToolInputPrefix(toRawPreview(input)) + return collapsed.length <= MAX_TOOL_PREVIEW_LENGTH ? collapsed - : `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…` + : `${collapsed.slice(0, MAX_TOOL_PREVIEW_LENGTH - 1)}…` } /** Build the renderer-independent row model from one normalization pass. Detail @@ -124,7 +127,7 @@ function normalizedToolInputHasDetail(input: unknown, label: string): boolean { if (isStructuredNormalizedToolInput(input)) { return true } - return typeof input === 'string' && input.replace(/\s+/g, ' ').trim() !== label + return typeof input === 'string' && collapsedToolInputPrefix(input) !== label } export function toolFilePath(input: unknown): string | null { @@ -241,10 +244,10 @@ function firstPrimaryToolArg( * absolute path drops the filename, the one part that tells two rows apart. */ function summarizeToolPath(path: string): string { const collapsed = path.replace(/\s+/g, ' ').trim() - if (collapsed.length <= MAX_PREVIEW_LENGTH) { + if (collapsed.length <= MAX_TOOL_PREVIEW_LENGTH) { return collapsed } - const tail = collapsed.slice(collapsed.length - (MAX_PREVIEW_LENGTH - 1)) + const tail = collapsed.slice(collapsed.length - (MAX_TOOL_PREVIEW_LENGTH - 1)) // Start at a segment boundary so the label doesn't open mid-name. const boundary = tail.search(/[\\/]/) return `…${boundary > 0 ? tail.slice(boundary) : tail}` @@ -298,7 +301,13 @@ export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string { } export function countToolCalls(blocks: readonly NativeChatBlock[]): number { - return blocks.filter(isToolCallBlock).length + let count = 0 + blocks.forEach((block) => { + if (isToolCallBlock(block)) { + count += 1 + } + }) + return count } function toRawPreview(input: unknown): string { diff --git a/src/renderer/src/components/native-chat/native-chat-turn-activity.test.ts b/src/shared/native-chat-turn-activity.test.ts similarity index 72% rename from src/renderer/src/components/native-chat/native-chat-turn-activity.test.ts rename to src/shared/native-chat-turn-activity.test.ts index a819e3054a2..3cc64f4e106 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-activity.test.ts +++ b/src/shared/native-chat-turn-activity.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../../../shared/agent-session-journal-types' +import type { AgentJournalItemBody, AgentJournalRenderItem } from './agent-session-journal-types' import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity' function item(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { @@ -34,6 +31,49 @@ describe('selectStructuredAgentTurnActivity', () => { expect(activity).toEqual({ kind: 'description', text: 'Preparing the answer' }) }) + it("never puts the model's reasoning on the indicator line", () => { + const reasoning = item(2, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Let me check whether the journal already records this' }] + }) + + // Reasoning is the turn's content; the row says the turn is thinking instead. + expect(selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1')).toBeNull() + // An ordinary status row is still a description of what the turn is doing. + expect( + selectStructuredAgentTurnActivity( + [turnStart, reasoning, item(3, { kind: 'status', text: 'Updating the plan' })], + 'turn-1' + ) + ).toEqual({ kind: 'description', text: 'Updating the plan' }) + // Provider-authored copy is unaffected, so Codex keeps its line. + expect( + selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1', { + turnId: 'turn-1', + text: 'Running a command' + }) + ).toEqual({ kind: 'description', text: 'Running a command' }) + }) + + it('skips reasoning behind a typed turn item too', () => { + const typedTurnStart = item(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + + expect( + selectStructuredAgentTurnActivity( + [ + typedTurnStart, + item(2, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + ], + 'turn-1' + ) + ).toBeNull() + }) + it('prefers matching ephemeral provider activity over journal-derived status', () => { const activity = selectStructuredAgentTurnActivity( [turnStart, item(2, { kind: 'status', text: 'Older journal status' })], diff --git a/src/renderer/src/components/native-chat/native-chat-turn-activity.ts b/src/shared/native-chat-turn-activity.ts similarity index 80% rename from src/renderer/src/components/native-chat/native-chat-turn-activity.ts rename to src/shared/native-chat-turn-activity.ts index e42abea2bf6..cac19796334 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-activity.ts +++ b/src/shared/native-chat-turn-activity.ts @@ -1,21 +1,25 @@ -import { readAgentJournalTurn } from '../../../../shared/agent-session-turn-record' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionTurnActivity } from '../../../../shared/agent-session-wire' -import { normalizePromptField } from '../../../../shared/agent-status-field-normalization' -import { - describeActiveToolCall, - formatActiveToolLabel -} from '../../../../shared/native-chat-tool-activity' +import { readAgentJournalTurn } from './agent-session-turn-record' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentSessionTurnActivity } from './agent-session-wire' +import { normalizePromptField } from './agent-status-field-normalization' +import { describeActiveToolCall, formatActiveToolLabel } from './native-chat-tool-activity' export type NativeChatTurnActivity = { kind: 'description'; text: string } function activityLine(text: string): string | null { - const lines = text - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - const latest = lines.at(-1) - return latest ? normalizePromptField(latest) || null : null + let end = text.length + while (end > 0) { + const start = text.lastIndexOf('\n', end - 1) + 1 + const latest = text.slice(start, end).trim() + if (latest) { + return normalizePromptField(latest) || null + } + if (start === 0) { + break + } + end = start - 1 + } + return null } function recentToolActivityLabels(items: readonly AgentJournalRenderItem[]): Set { diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 3fdcccb7bf3..c98d4f79a73 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -1,24 +1,16 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from './native-chat-types' import { + describeNativeChatActiveTurnLabel, describeNativeChatTurnStatus, + formatNativeChatActiveTurnLabel, formatNativeChatDuration, formatNativeChatTurnStatusLabel, nativeChatElapsedSeconds, - nativeChatTurnHasResponse, reduceNativeChatTurnTiming, selectNativeChatTurnStatuses, type NativeChatTurnTimingByTurn } from './native-chat-turn-status' -function message( - id: string, - role: NativeChatMessage['role'], - blocks: NativeChatMessage['blocks'] -): NativeChatMessage { - return { id, role, blocks, timestamp: null, source: 'transcript' } -} - describe('formatNativeChatDuration', () => { it.each([ [0, '0s'], @@ -59,6 +51,51 @@ describe('describeNativeChatTurnStatus', () => { }) }) +describe('describeNativeChatActiveTurnLabel', () => { + it('lets provider activity beat both fallbacks', () => { + expect( + describeNativeChatActiveTurnLabel({ + activityText: 'Reading src/main.ts', + thinking: true, + elapsedSeconds: 12 + }) + ).toEqual({ source: 'activity', text: 'Reading src/main.ts' }) + }) + + it('falls back to reasoning when the provider says nothing usable', () => { + expect( + describeNativeChatActiveTurnLabel({ activityText: ' ', thinking: true, elapsedSeconds: 12 }) + ).toEqual({ source: 'status', key: 'thinking', duration: null }) + expect( + describeNativeChatActiveTurnLabel({ activityText: null, thinking: true, elapsedSeconds: 12 }) + ).toEqual({ source: 'status', key: 'thinking', duration: null }) + }) + + it('falls back to the running clock when the turn is neither talking nor reasoning', () => { + expect(describeNativeChatActiveTurnLabel({ thinking: false, elapsedSeconds: 184 })).toEqual({ + source: 'status', + key: 'workingFor', + duration: '3m 4s' + }) + }) +}) + +describe('formatNativeChatActiveTurnLabel', () => { + it('renders the one live row in English for platforms without i18n', () => { + expect( + formatNativeChatActiveTurnLabel({ + activityText: 'Running pnpm test', + thinking: false, + elapsedSeconds: 4 + }) + ).toBe('Running pnpm test') + expect(formatNativeChatActiveTurnLabel({ thinking: true, elapsedSeconds: 4 })).toBe('Thinking') + expect(formatNativeChatActiveTurnLabel({ thinking: false, elapsedSeconds: 12 })).toBe( + 'Working for 12s' + ) + }) +}) + describe('formatNativeChatTurnStatusLabel', () => { it('renders each state in English for platforms without i18n', () => { expect( @@ -73,45 +110,6 @@ describe('formatNativeChatTurnStatusLabel', () => { }) }) -describe('nativeChatTurnHasResponse', () => { - const user = message('u1', 'user', [{ type: 'text', text: 'go' }]) - - it('is false while the turn has produced nothing', () => { - expect(nativeChatTurnHasResponse([user], 0)).toBe(false) - }) - - it('ignores a whitespace-only assistant block', () => { - const blank = message('a1', 'assistant', [{ type: 'text', text: ' \n ' }]) - expect(nativeChatTurnHasResponse([user, blank], 0)).toBe(false) - }) - - it('is true on the first real text, tool call, or tool result', () => { - expect( - nativeChatTurnHasResponse( - [user, message('a1', 'assistant', [{ type: 'text', text: 'hi' }])], - 0 - ) - ).toBe(true) - expect( - nativeChatTurnHasResponse( - [user, message('t1', 'tool', [{ type: 'tool-call', name: 'Read', input: {} }])], - 0 - ) - ).toBe(true) - expect( - nativeChatTurnHasResponse( - [user, message('t1', 'tool', [{ type: 'tool-result', output: 'ok' }])], - 0 - ) - ).toBe(true) - }) - - it('does not count output that preceded the latest user turn', () => { - const earlier = message('a0', 'assistant', [{ type: 'text', text: 'old' }]) - expect(nativeChatTurnHasResponse([earlier, user], 1)).toBe(false) - }) -}) - describe('reduceNativeChatTurnTiming', () => { const validTurnKeys = new Set(['u1']) @@ -290,18 +288,18 @@ describe('reduceNativeChatTurnTiming', () => { }) describe('selectNativeChatTurnStatuses', () => { - it('reports the working turn as thinking until it produces output', () => { + it('carries the reasoning verdict it is given onto the working turn', () => { const { active } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: null } }, - { activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: false } + { activeTurnKey: 'u1', isWorking: true, thinking: true } ) expect(active).toEqual({ startedAt: 1_000, thinking: true, workedSeconds: null }) }) - it('stops thinking once the turn has output', () => { + it('reports a working turn that is not reasoning as counting', () => { const { active } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: null } }, - { activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: true } + { activeTurnKey: 'u1', isWorking: true, thinking: false } ) expect(active?.thinking).toBe(false) }) @@ -309,7 +307,7 @@ describe('selectNativeChatTurnStatuses', () => { it('exposes settled turns and resolves the active one from them when idle', () => { const { active, completedByTurn } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: 12 } }, - { activeTurnKey: 'u1', isWorking: false, hasCurrentTurnResponse: true } + { activeTurnKey: 'u1', isWorking: false, thinking: false } ) expect(completedByTurn.u1).toEqual({ startedAt: 1_000, thinking: false, workedSeconds: 12 }) expect(active).toEqual(completedByTurn.u1) @@ -318,7 +316,7 @@ describe('selectNativeChatTurnStatuses', () => { it('omits an in-flight turn from the completed map', () => { const { completedByTurn } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: null } }, - { activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: true } + { activeTurnKey: 'u1', isWorking: true, thinking: false } ) expect(completedByTurn).toEqual({}) }) diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index aaecbc05ce5..b34b199e9d0 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -3,8 +3,6 @@ // and the mobile app (used directly — mobile ships English only) so the two // surfaces never drift. Everything here is pure; each platform owns its own clock. -import type { NativeChatMessage } from './native-chat-types' - export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', workingFor: 'Working for {{value0}}', @@ -48,6 +46,52 @@ export function describeNativeChatTurnStatus({ return { key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) } } +/** The two readings that label a live turn's one indicator row, carried together + * so a surface cannot pick up one without the other. */ +export type NativeChatLiveTurnIndicator = { + thinking: boolean + activityText: string | null +} + +export type NativeChatActiveTurnLabel = + | { source: 'activity'; text: string } + | { source: 'status'; key: 'thinking' | 'workingFor'; duration: string | null } + +/** The live turn's single indicator label. Provider activity wins because it is the + * only text that says what the turn is actually doing; reasoning is next; the + * running clock is the floor. Shared so desktop and mobile cannot disagree. */ +export function describeNativeChatActiveTurnLabel({ + activityText, + thinking, + elapsedSeconds +}: { + activityText?: string | null + thinking: boolean + elapsedSeconds: number +}): NativeChatActiveTurnLabel { + const text = activityText?.trim() + if (text) { + return { source: 'activity', text } + } + return thinking + ? { source: 'status', key: 'thinking', duration: null } + : { source: 'status', key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) } +} + +/** The live turn's label in English. For platforms without i18n (mobile). */ +export function formatNativeChatActiveTurnLabel(input: { + activityText?: string | null + thinking: boolean + elapsedSeconds: number +}): string { + const label = describeNativeChatActiveTurnLabel(input) + if (label.source === 'activity') { + return label.text + } + const copy = NATIVE_CHAT_TURN_STATUS_COPY[label.key] + return label.duration == null ? copy : copy.replaceAll('{{value0}}', label.duration) +} + /** Resolve the turn-status label in English. For platforms without i18n (mobile). */ export function formatNativeChatTurnStatusLabel(input: { thinking: boolean @@ -59,26 +103,6 @@ export function formatNativeChatTurnStatusLabel(input: { return duration == null ? copy : copy.replaceAll('{{value0}}', duration) } -/** True once the current turn has produced anything renderable — the boundary - * between the "Thinking" label and the counting "Working for N" label. */ -export function nativeChatTurnHasResponse( - messages: readonly NativeChatMessage[], - latestUserIndex: number -): boolean { - return messages - .slice(latestUserIndex + 1) - .some( - (message) => - (message.role === 'assistant' || message.role === 'tool') && - message.blocks.some( - (block) => - block.type === 'tool-call' || - block.type === 'tool-result' || - (block.type === 'text' && block.text.trim().length > 0) - ) - ) -} - export type NativeChatTurnTiming = { startedAt: number workedSeconds: number | null @@ -183,13 +207,14 @@ export function selectNativeChatTurnStatuses( activeTurnKey, isWorking, workingStartedAt, - hasCurrentTurnResponse, + thinking, settledByTurn }: { activeTurnKey: string isWorking: boolean workingStartedAt?: number | null - hasCurrentTurnResponse: boolean + /** Whether the active turn is reasoning right now, from its journal content. */ + thinking: boolean settledByTurn?: NativeChatSettledTurns } ): { active: NativeChatTurnStatus | null; completedByTurn: Record } { @@ -216,7 +241,7 @@ export function selectNativeChatTurnStatuses( active: isWorking ? { startedAt: workingStartedAt ?? timingByTurn[activeTurnKey]?.startedAt ?? null, - thinking: !hasCurrentTurnResponse, + thinking, workedSeconds: null } : (completedByTurn[activeTurnKey] ?? null), diff --git a/src/shared/native-chat-unverifiable-turn-status.test.ts b/src/shared/native-chat-unverifiable-turn-status.test.ts index 7e93f9cecc4..592ca699183 100644 --- a/src/shared/native-chat-unverifiable-turn-status.test.ts +++ b/src/shared/native-chat-unverifiable-turn-status.test.ts @@ -57,7 +57,7 @@ describe('authoritative unknown turn duration at the shared status consumer', () const options = { activeTurnKey: user.itemId, isWorking: false, - hasCurrentTurnResponse: true, + thinking: false, settledByTurn: selectStructuredAgentSettledTurns([user, recoveredTurn]) } @@ -87,7 +87,7 @@ describe('authoritative unknown turn duration at the shared status consumer', () { activeTurnKey: 'next', isWorking: true, - hasCurrentTurnResponse: true, + thinking: false, settledByTurn: selectStructuredAgentSettledTurns([userItem('turn-1'), item]) } ) @@ -113,7 +113,7 @@ describe('authoritative unknown turn duration at the shared status consumer', () { activeTurnKey: 'turn-1', isWorking: false, - hasCurrentTurnResponse: true, + thinking: false, settledByTurn: selectStructuredAgentSettledTurns([ userItem('turn-1'), userItem('old'), diff --git a/src/shared/orchestration-fleet-agent-status-evidence.ts b/src/shared/orchestration-fleet-agent-status-evidence.ts index f03b1d9cbfa..43ff2c40cf4 100644 --- a/src/shared/orchestration-fleet-agent-status-evidence.ts +++ b/src/shared/orchestration-fleet-agent-status-evidence.ts @@ -1,7 +1,8 @@ // ─── The one identity/clock contract the fleet path reads ──────────────────── // A hook row carries a pane key, a delivery timestamp and, from newer hosts, an -// observation timestamp. Terminal identity lives on the runtime, not on the row. -// The fleet matcher needs both, and every fact it needs used to be an OPTIONAL +// observation timestamp. A row may carry the runtime handle observed with OSC, but +// fleet authority still resolves terminal identity from the runtime. The matcher needs both, +// and every fact it needs used to be an OPTIONAL // field on `AgentStatusIpcPayload` — so an unenriched producer published a row the // matcher silently failed to identify (failure table L-1) and a missing observation // clock silently degraded to the delivery clock (W1-14 / RR-W-P1A). @@ -10,8 +11,8 @@ // deliberately exposes no `terminalHandle?`, no `evidenceObservedAt?` and no raw // payload, so a consumer cannot read an absent identity or clock by accident. // -// This type never crosses IPC or the wire. `AgentStatusIpcPayload` is unchanged and -// remains what `agentStatus:set` / `agentStatus:getSnapshot` publish. +// This type never crosses IPC or the wire. `AgentStatusIpcPayload` remains what +// `agentStatus:set` / `agentStatus:getSnapshot` publish. import type { AgentStatusIpcPayload } from './agent-status-ipc-payload' import type { AgentStatusState, AgentType } from './agent-status-types' diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index 7de6b14f1c7..e71cc8def32 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -150,6 +150,7 @@ const INVENTORY: readonly InventoryGroup[] = [ classification: 'identity-consumer', paths: [ ['mobile/src/session/mobile-terminal-tab-agent.ts', 2], + ['src/main/runtime/tui-idle-evidence.ts', 2], ['src/renderer/src/lib/open-tab-occupant-agent.ts', 2], ['src/renderer/src/lib/use-tab-agent.ts', 3] ] diff --git a/src/shared/plugins/plugin-panel-message-budget-traversal.test.ts b/src/shared/plugins/plugin-panel-message-budget-traversal.test.ts new file mode 100644 index 00000000000..abc4ec2e21b --- /dev/null +++ b/src/shared/plugins/plugin-panel-message-budget-traversal.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { structuredCloneMessageBytes } from './plugin-panel-message-budget' + +const factories: [string, () => Iterable][] = [ + ['array', () => Array.from({ length: 10_000 }, (_, i) => `value-${i}`)], + ['set', () => new Set(Array.from({ length: 10_000 }, (_, i) => `value-${i}`))], + ['map', () => new Map(Array.from({ length: 10_000 }, (_, i) => [`key-${i}`, i]))] +] + +describe('plugin message budget traversal', () => { + it.each(factories)('stops consuming an oversized %s', (_name, create) => { + const value = create() + const iterate = value[Symbol.iterator].bind(value) + let visits = 0 + value[Symbol.iterator] = function* () { + for (const entry of { [Symbol.iterator]: iterate }) { + visits++ + yield entry + } + } + expect(structuredCloneMessageBytes(value, 64)).toBe(65) + expect(visits).toBeLessThan(10) + }) + + it('stops reading object values after an earlier property exceeds the budget', () => { + const value: Record = { first: 'x'.repeat(1000) } + let reads = 0 + for (let i = 0; i < 1000; i++) { + Object.defineProperty(value, `tail-${i}`, { + enumerable: true, + get: () => { + reads++ + return i + } + }) + } + expect(structuredCloneMessageBytes(value, 64)).toBe(65) + expect(reads).toBe(0) + }) + + it.each([ + ['array', [1, 2], 32], + ['set', new Set([1, 2]), 32], + [ + 'map', + new Map([ + ['a', 1], + ['b', 2] + ]), + 34 + ], + ['object', { a: 1, b: 2 }, 26] + ] as const)( + 'preserves the exact %s estimate at both sides of the boundary', + (_name, value, bytes) => { + expect(structuredCloneMessageBytes(value, bytes - 1)).toBe(bytes) + expect(structuredCloneMessageBytes(value, bytes)).toBe(bytes) + expect(structuredCloneMessageBytes(value, bytes + 1)).toBe(bytes) + } + ) + + it('unwinds all enclosing collections after a nested value exceeds the cap', () => { + let reads = 0 + const value = [new Map([['nested', new Set(['x'.repeat(1000)])]])] + Object.defineProperty(value, 1, { + get: () => { + reads++ + return 1 + } + }) + expect(structuredCloneMessageBytes(value, 64)).toBe(65) + expect(reads).toBe(0) + }) +}) diff --git a/src/shared/plugins/plugin-panel-message-budget.ts b/src/shared/plugins/plugin-panel-message-budget.ts index 93f926a085c..322d08a1198 100644 --- a/src/shared/plugins/plugin-panel-message-budget.ts +++ b/src/shared/plugins/plugin-panel-message-budget.ts @@ -159,6 +159,9 @@ export function structuredCloneMessageBytes( add(4) visit(key, depth + 1) visit(entry, depth + 1) + if (total > stopAfter) { + return + } } return } @@ -167,6 +170,9 @@ export function structuredCloneMessageBytes( for (const entry of object) { add(4) visit(entry, depth + 1) + if (total > stopAfter) { + return + } } return } @@ -175,6 +181,9 @@ export function structuredCloneMessageBytes( for (const entry of object) { add(4) visit(entry, depth + 1) + if (total > stopAfter) { + return + } } return } @@ -188,6 +197,9 @@ export function structuredCloneMessageBytes( add(4) add(utf8Bytes(key, stopAfter - total)) visit((object as Record)[key], depth + 1) + if (total > stopAfter) { + return + } } } catch { total = stopAfter + 1 diff --git a/src/shared/pr-check-severity-order.ts b/src/shared/pr-check-severity-order.ts index db0230d37dd..b6a430586e2 100644 --- a/src/shared/pr-check-severity-order.ts +++ b/src/shared/pr-check-severity-order.ts @@ -25,12 +25,11 @@ export function getCheckSeverityRank(conclusion: string | null | undefined): num export function sortChecksBySeverity>( checks: readonly T[] ): T[] { + if (checks.length < 2) { + return checks.slice() + } return checks - .map((check, index) => ({ check, index })) - .sort( - (a, b) => - getCheckSeverityRank(a.check.conclusion) - getCheckSeverityRank(b.check.conclusion) || - a.index - b.index - ) + .map((check, index) => ({ check, index, rank: getCheckSeverityRank(check.conclusion) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) .map(({ check }) => check) } diff --git a/src/shared/pr-comment-audience.ts b/src/shared/pr-comment-audience.ts index 4736ab5c372..7542c17d851 100644 --- a/src/shared/pr-comment-audience.ts +++ b/src/shared/pr-comment-audience.ts @@ -55,7 +55,12 @@ export function getPRCommentAudienceCounts( comments: readonly PRComment[], botAuthorOverrides?: ReadonlySet ): Record { - const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length + let bot = 0 + comments.forEach((comment) => { + if (isBotPRComment(comment, botAuthorOverrides)) { + bot += 1 + } + }) return { all: comments.length, human: comments.length - bot, bot } } diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts index c4fe8badb47..5b37aadce8c 100644 --- a/src/shared/project-groups.ts +++ b/src/shared/project-groups.ts @@ -91,9 +91,8 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] { groups.sort( (left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name) ) - const groupIds = new Set(groups.map((group) => group.id)) for (const group of groups) { - if (group.parentGroupId === group.id || !groupIds.has(group.parentGroupId ?? '')) { + if (group.parentGroupId === group.id || !seen.has(group.parentGroupId ?? '')) { group.parentGroupId = null } } diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 794fd625b12..1c4d6f06c4b 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -251,12 +251,12 @@ export function mergeCatalogUpdatedAt(left: number, right: number): number { return Math.max(known, other) } -function createProjectFromRepo(repo: Repo): Project { +function createProjectFromRepo(repo: Repo, projectId: string): Project { const identity = getProjectProviderIdentity(repo) const gitRemoteIdentity = getProjectGitRemoteIdentity(repo) const addedAt = catalogTimestampFromAddedAt(repo.addedAt) return { - id: getProjectId(repo), + id: projectId, displayName: repo.displayName, badgeColor: repo.badgeColor, ...(repo.repoIcon !== undefined ? { repoIcon: repo.repoIcon } : {}), @@ -319,7 +319,7 @@ export function projectHostSetupProjectionFromRepos( if (existing) { mergeProjectRepo(existing, repo) } else { - const project = createProjectFromRepo(repo) + const project = createProjectFromRepo(repo, projectId) projectById.set(projectId, { project, sourceRepoIds: new Set(project.sourceRepoIds) }) } // Why normalize here: a repo row is untrusted persisted/wire data too, and these diff --git a/src/shared/pty-session-id-format.ts b/src/shared/pty-session-id-format.ts index b414e15e2fc..acfebe13188 100644 --- a/src/shared/pty-session-id-format.ts +++ b/src/shared/pty-session-id-format.ts @@ -11,6 +11,8 @@ * can import. */ +import { parseWorkspaceKey } from './workspace-scope' + export const PTY_SESSION_ID_SEPARATOR = '@@' export const WORKTREE_ID_SEPARATOR = '::' @@ -20,7 +22,7 @@ export const WORKTREE_ID_SEPARATOR = '::' * Why stricter than `lastIndexOf('@@')`: callers that drive memory * attribution must not synthesize a worktreeId for a sessionId that was * not minted by us — e.g. a bare UUID. Requiring both the `@@` separator - * AND the `${repoId}::${path}` shape rejects those imposters cleanly. + * AND a Git worktree or folder workspace identity rejects those imposters cleanly. * Returns `{ worktreeId: null }` when the id does not match the minted * format. */ @@ -30,6 +32,9 @@ export function parsePtySessionId(sessionId: string): { worktreeId: string | nul return { worktreeId: null } } const candidate = sessionId.slice(0, idx) + if (parseWorkspaceKey(candidate)?.type === 'folder') { + return { worktreeId: candidate } + } // Why: require non-empty halves on both sides of `::` so degenerate // ids like `::@@…`, `repo::@@…`, or `::path@@…` don't synthesize a // phantom worktreeId for memory attribution. diff --git a/src/shared/quick-open-fuzzy-scan.test.ts b/src/shared/quick-open-fuzzy-scan.test.ts new file mode 100644 index 00000000000..db209bfe471 --- /dev/null +++ b/src/shared/quick-open-fuzzy-scan.test.ts @@ -0,0 +1,118 @@ +import { expect, it } from 'vitest' +import { compareFileNames } from './file-name-sort' +import { + prepareQuickOpenFiles, + QuickOpenPathRanker, + rankQuickOpenFiles, + type QuickOpenIndexedFile +} from './quick-open-path-search' + +function referenceScore(query: string, file: QuickOpenIndexedFile): number { + let qi = 0 + let score = 0 + let lastMatch = -1 + for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { + if (file.lowerPath[ti] !== query[qi]) { + continue + } + score += lastMatch === -1 ? 0 : ti - lastMatch - 1 + if (ti > 0 && '/.-'.includes(file.lowerPath[ti - 1])) { + score -= 5 + } + lastMatch = ti + qi++ + } + if (qi < query.length) { + return -1 + } + return score - (file.lowerFilename.includes(query) ? 100 : 0) +} + +it.each(['az', 'aq'])('skips nonmatching path spans for %s', (query) => { + const [prepared] = prepareQuickOpenFiles([`a/${'x'.repeat(4000)}/z.ts`]) + let pathReads = 0 + const measured = { + ...prepared, + get lowerPath() { + pathReads++ + return prepared.lowerPath + } + } + expect(rankQuickOpenFiles(query, [measured])).toEqual(rankQuickOpenFiles(query, [prepared])) + expect(pathReads).toBeLessThanOrEqual(20) +}) + +it('preserves code-unit scores and ordering for generated Unicode paths and queries', () => { + const alphabet = [ + 'a', + 'b', + 'c', + '/', + '\\', + '.', + '-', + '2', + '0', + 'é', + 'e\u0301', + 'İ', + '😀', + '\ud800', + '\udc00' + ] + let seed = 43 + const next = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed + } + const paths = ['a.../b', 'a/x/z', 'a/z', '😀.ts', 'a'.repeat(110), 'aa', 'same', 'same', ''] + for (let index = 0; index < 1000; index++) { + const length = next() % 80 + let path = '' + for (let offset = 0; offset < length; offset++) { + path += alphabet[next() % alphabet.length] + } + paths.push(path) + } + const files = prepareQuickOpenFiles(paths) + const queries = [ + '', + ' ', + 'az', + 'ab', + 'a', + 'aa', + 'q', + '😀', + '\ud800', + '\udc00', + 'é', + 'e\u0301', + 'İ', + 'a\\b', + ' A ' + ] + for (let index = 0; index < 100; index++) { + queries.push(`${alphabet[next() % alphabet.length]}${alphabet[next() % alphabet.length]}`) + } + for (const query of queries) { + const normalized = query.trim().replace(/\\/g, '/').toLowerCase() + const expected = files + .map((file) => ({ ...file, score: normalized ? referenceScore(normalized, file) : 0 })) + .filter((file) => file.score !== -1) + .sort( + (a, b) => + a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex + ) + for (const limit of [1, 16, 50]) { + const selected = expected.slice(0, limit).map(({ path, score }) => ({ path, score })) + expect(rankQuickOpenFiles(query, files, limit)).toEqual(selected) + const streamed = new QuickOpenPathRanker(query, limit) + paths.forEach((path) => streamed.consider(path)) + expect(streamed.result()).toEqual({ + paths: selected.map((entry) => entry.path), + totalCount: expected.length + }) + } + } +}) diff --git a/src/shared/quick-open-path-search.ts b/src/shared/quick-open-path-search.ts index 5680abf8286..2e2dbff4d84 100644 --- a/src/shared/quick-open-path-search.ts +++ b/src/shared/quick-open-path-search.ts @@ -126,9 +126,12 @@ function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): numbe let score = 0 let lastMatchIdx = -1 - for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { - if (file.lowerPath[ti] !== query[qi]) { - continue + while (qi < query.length) { + const next = lastMatchIdx + 1 + const ti = + file.lowerPath[next] === query[qi] ? next : file.lowerPath.indexOf(query[qi], next + 1) + if (ti === -1) { + return -1 } const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1 score += gap diff --git a/src/shared/relay-frame-buffer.ts b/src/shared/relay-frame-buffer.ts index a851426c6d8..77933fcf53b 100644 --- a/src/shared/relay-frame-buffer.ts +++ b/src/shared/relay-frame-buffer.ts @@ -7,6 +7,10 @@ export class RelayFrameBuffer { return this.bytes } + get chunkCount(): number { + return this.chunks.length - this.head + } + append(chunk: Buffer): void { this.chunks.push(chunk) this.bytes += chunk.length diff --git a/src/shared/remote-runtime-shared-control-test-server.ts b/src/shared/remote-runtime-shared-control-test-server.ts index 0e4adb1a69c..701ea0e5893 100644 --- a/src/shared/remote-runtime-shared-control-test-server.ts +++ b/src/shared/remote-runtime-shared-control-test-server.ts @@ -20,6 +20,7 @@ export type SharedControlTestServer = { } type ServerOptions = { + resultForRequest?: (method: string) => unknown delaySubscriptionReady?: boolean sendKeepaliveBeforeResponse?: boolean keepaliveDelayMs?: number @@ -174,7 +175,7 @@ function handleRequest( const streaming = isStreamingMethod(request.method) const result = streaming ? { type: 'ready', subscriptionId: `${request.method}:subscription` } - : { method: request.method } + : (options.resultForRequest?.(request.method) ?? { method: request.method }) const sendResponse = (): void => { if (options.sendUnknownResponseBeforeResponse) { sendEncrypted(ws, sharedKey, { diff --git a/src/shared/remote-workspace-session-projection.test.ts b/src/shared/remote-workspace-session-projection.test.ts index fdccd75b8e9..a11026e3581 100644 --- a/src/shared/remote-workspace-session-projection.test.ts +++ b/src/shared/remote-workspace-session-projection.test.ts @@ -6,6 +6,52 @@ import { import { getDefaultWorkspaceSession } from './constants' describe('remote workspace session projection', () => { + // The transient set this boundary mirrors. `recovery` is the tab's in-flight + // heal, timestamped with THIS machine's clock, and `pendingActivationSpawn` is + // a one-shot mount handoff — neither means anything on another client's row, + // and a foreign `startedAt` would be compared against the reader's Date.now(). + it('strips client-local transient tab fields on the way out', () => { + const session = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-a', + activeWorktreeId: 'repo-a::/srv/app', + activeTabId: 'tab-1', + tabsByWorktree: { + 'repo-a::/srv/app': [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'repo-a::/srv/app', + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + pendingActivationSpawn: true, + recovery: { + attemptedAt: [1_000], + generation: 1, + outcome: 'failed' as const, + startedAt: 1_000, + reason: 'reattach-unverifiable' as const, + tabGeneration: 1 + } + } + ] + }, + terminalLayoutsByTabId: {} + } + + const projected = exportRemoteWorkspaceSession(session, { + isTargetWorktree: (worktreeId) => worktreeId.startsWith('repo-a::') + }) + + const exported = projected.tabsByWorktreePath['/srv/app'][0] as Record + expect(exported.recovery).toBeUndefined() + expect(exported.pendingActivationSpawn).toBeUndefined() + expect(exported.id).toBe('tab-1') + }) + it('exports terminal state using remote worktree paths instead of local repo ids', () => { const session = { ...getDefaultWorkspaceSession(), diff --git a/src/shared/remote-workspace-session-projection.ts b/src/shared/remote-workspace-session-projection.ts index 7f923050d36..29b3e15cd1d 100644 --- a/src/shared/remote-workspace-session-projection.ts +++ b/src/shared/remote-workspace-session-projection.ts @@ -32,9 +32,20 @@ function worktreePathFromId(worktreeId: string): string | null { } function tabToRemote(tab: TerminalTab, worktreePath: string): RemoteWorkspaceTerminalTab { - const { worktreeId: _worktreeId, pendingActivationSpawn: _pendingActivationSpawn, ...rest } = tab + // `recovery` joins the transient set for the same reason as + // pendingActivationSpawn: it describes THIS client's in-flight heal, and its + // timestamps are this machine's clock. On another client's row they would be + // compared against a foreign `Date.now()`. Nothing hands an unsanitized + // session to this boundary today; stripping here keeps that from mattering. + const { + worktreeId: _worktreeId, + pendingActivationSpawn: _pendingActivationSpawn, + recovery: _recovery, + ...rest + } = tab void _worktreeId void _pendingActivationSpawn + void _recovery return { ...rest, worktreePath } } diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index 4d6eec6021e..9544b709f9c 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -1,6 +1,12 @@ import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' -export type RemoteWorkspaceTerminalTab = Omit & { +// Transient client-local fields are omitted, not merely unset: `recovery` is +// this client's in-flight heal, stamped with this machine's clock, so the type +// must not let a future producer put one on the wire. +export type RemoteWorkspaceTerminalTab = Omit< + TerminalTab, + 'worktreeId' | 'pendingActivationSpawn' | 'recovery' +> & { worktreePath: string } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 15d04a5b655..57ed4a5b5a5 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -943,6 +943,7 @@ export const RPC_PARAMS_BY_METHOD = { 'notifications.getMissedSince': NotificationGetMissedSinceParams, 'notifications.registerPush': NotificationRegisterPushParams, 'notifications.subscribe': NotificationsSubscribeParams, + 'notifications.testPush': null, 'notifications.unregisterPush': null, 'notifications.unsubscribe': NotificationUnsubscribeParams, 'orchestration.ask': AskParams, @@ -1158,9 +1159,10 @@ export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD -// Why: z.output is the post-parse shape the handler receives. z.input is not a -// send-side type here — requiredString is z.unknown().transform(...), so its input -// admits any value and loses optional/default semantics. +// Why: z.output is the post-parse shape the handler receives, which is not what a +// client may send — a .default() field reads as required. z.input is not the answer +// either: requiredString is z.unknown().transform(...), so its input admits any value. +// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map. export type RpcParams = (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> diff --git a/src/shared/rpc-contract/rpc-send-params.ts b/src/shared/rpc-contract/rpc-send-params.ts new file mode 100644 index 00000000000..fcb6d66a359 --- /dev/null +++ b/src/shared/rpc-contract/rpc-send-params.ts @@ -0,0 +1,69 @@ +import type { z } from 'zod' +import type { RPC_PARAMS_BY_METHOD, RpcMethodName } from './rpc-params-catalog.generated' + +// Why this exists: neither of zod's two inferred types describes an outgoing request. +// z.output is what the handler receives *after* parsing, so a `.default(x)` field reads as +// required and a sender that legitimately omits it fails to typecheck. z.input is worse here +// — the params builders parse with z.unknown() so a hostile client cannot crash the +// dispatcher, which collapses every requiredString/OptionalString field to `unknown`. +// +// So take each channel where it is honest: key optionality from zod's own `optin` marker +// (the z.input rule, which is the one that understands .default and .optional), and value +// types from z.output (the post-coercion contract the builders declare in their pipe target). +// Derived from the generated catalog, so it cannot drift from the dispatcher. +// +// Type-level only. Never import the schema *values* into a client: requiredString is +// z.unknown().transform(...), so a client-side parse coerces a non-string to '' instead of +// rejecting it, silently changing the bytes on the wire. + +type Prettify = { [K in keyof T]: T[K] } & {} + +/** zod's own input-side key-optionality rule, copied from $InferObjectInput. */ +type SendOptionalSchema = { _zod: { optin: 'optional' | 'defaulted' } } + +type SendShape = Prettify< + { + -readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? never : K]: RpcSendInput< + Shape[K] + > + } & { + -readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? K : never]?: RpcSendInput< + Shape[K] + > + } +> + +/** + * The value a sender may put on the wire for one schema. Wrappers not listed here (record, + * tuple, lazy, intersection) fall through to z.output, which is what shipped before. + */ +export type RpcSendInput = + Schema extends z.ZodOptional + ? RpcSendInput | undefined + : Schema extends z.ZodDefault + ? RpcSendInput | undefined + : Schema extends z.ZodPrefault + ? RpcSendInput | undefined + : Schema extends z.ZodNullable + ? RpcSendInput | null + : Schema extends z.ZodArray + ? RpcSendInput[] + : // ZodObject is the only schema carrying a `shape`, and matching on it keeps + // .strict()/.extend()/.superRefine() results in this branch. + Schema extends { shape: infer Shape } + ? keyof Shape extends never + ? // Mirrors $InferObjectOutput: a no-field object admits no properties. + Record + : SendShape + : // ZodDiscriminatedUnion extends ZodUnion, so both land here. + Schema extends z.ZodUnion + ? RpcSendInput + : Schema extends z.ZodType + ? z.output + : never + +/** The params a client may send for `Method`; `void` for the methods that take none. */ +export type RpcSendParams = + (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType + ? RpcSendInput<(typeof RPC_PARAMS_BY_METHOD)[Method]> + : void diff --git a/src/shared/runtime-host-status-owner.test.ts b/src/shared/runtime-host-status-owner.test.ts new file mode 100644 index 00000000000..32331991988 --- /dev/null +++ b/src/shared/runtime-host-status-owner.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { RuntimeHostStatusOwner } from './runtime-host-status-owner' +import { runtimeHostStatusFailure, type RuntimeHostStatusResponse } from './runtime-host-status' +import type { RuntimeStatus } from './runtime-types' + +const owners: RuntimeHostStatusOwner[] = [] +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + owners.splice(0).forEach((owner) => owner.dispose()) + vi.useRealTimers() +}) +function success(runtimeId = 'host-1'): RuntimeHostStatusResponse & { ok: true } { + return { + id: 'status', + ok: true, + result: { runtimeId, capabilities: [] } as unknown as RuntimeStatus, + _meta: { runtimeId } + } +} +function deferred() { + let resolve!: (response: RuntimeHostStatusResponse) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function createOwner(persistent = false) { + const request = vi + .fn<(signal: AbortSignal) => Promise>() + .mockResolvedValue(success()) + const publish = vi.fn() + const verified = vi.fn((_response: RuntimeHostStatusResponse, _active: boolean) => persistent) + const owner = new RuntimeHostStatusOwner({ + environmentId: 'env-a', + pairingRevision: 1, + persistent, + request, + publish, + verified + }) + owners.push(owner) + return { owner, request, publish, verified } +} + +it('shares one verification between viewers with independent deadlines', async () => { + const { owner, request } = createOwner() + const pending = deferred() + request.mockReturnValue(pending.promise) + const impatient = owner.refresh({ timeoutMs: 100 }) + const patient = owner.refresh({ timeoutMs: 1_000 }) + await vi.advanceTimersByTimeAsync(100) + expect((await impatient).ok).toBe(false) + expect(request).toHaveBeenCalledOnce() + expect(request.mock.calls[0][0].aborted).toBe(false) + pending.resolve(success()) + expect((await patient).ok).toBe(true) +}) + +it('uses ready transitions, not diagnostic updates or a healthy polling timer', async () => { + const { owner, request } = createOwner(true) + await owner.refresh() + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) + owner.connectionChanged('disconnected') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) +}) + +it('retries a failed status operation while retaining healthy transport and last good metadata', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + await owner.refresh() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'status timed out')) + await owner.refresh() + expect(owner.read()).toMatchObject({ + transport: 'ready', + verification: 'unavailable', + status: { runtimeId: 'host-1' } + }) + await vi.advanceTimersByTimeAsync(3_000) + expect(owner.read().verification).toBe('verified') + expect(request).toHaveBeenCalledTimes(3) +}) + +it('retires a lost-socket request before explicit fallback and rejects its late result', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + const old = deferred() + request.mockReturnValueOnce(old.promise) + const waiting = owner.refresh() + owner.connectionChanged('disconnected') + expect(request.mock.calls[0][0].aborted).toBe(true) + request.mockResolvedValueOnce(success('fallback-host')) + expect((await owner.refresh()).ok).toBe(true) + expect((await waiting).ok).toBe(true) + old.resolve(success('obsolete-host')) + await vi.advanceTimersByTimeAsync(0) + expect(owner.read().status?.runtimeId).toBe('fallback-host') + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) +}) + +it('a reconnect transfers waiting readers to a fresh verification', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + const old = deferred() + request.mockReturnValueOnce(old.promise) + const waiting = owner.refresh() + owner.connectionChanged('disconnected') + owner.connectionChanged('ready') + expect((await waiting).ok).toBe(true) + old.resolve(success('old')) + await vi.advanceTimersByTimeAsync(0) + expect(owner.read().status?.runtimeId).toBe('host-1') +}) + +it('disconnect settles readers and prevents late results and retry resurrection', async () => { + const { owner, request, publish } = createOwner() + const old = deferred() + request.mockReturnValue(old.promise) + const waiting = owner.refresh() + owner.dispose() + expect((await waiting).ok).toBe(false) + const sequence = owner.read().sequence + old.resolve(success()) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(owner.read()).toMatchObject({ retired: true, sequence }) + expect(publish.mock.lastCall?.[0].retired).toBe(true) + expect(request).toHaveBeenCalledOnce() +}) + +it('passive reads create neither standing retries nor connection intent', async () => { + const { owner, request, verified } = createOwner() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'offline')) + await owner.refresh({ observeOnly: true }) + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() + await owner.refresh({ observeOnly: true }) + expect(verified.mock.lastCall?.[1]).toBe(false) +}) + +it('authentication rejection blocks automatic verification until explicit reconnect', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + request.mockResolvedValueOnce(runtimeHostStatusFailure('unauthorized', 're-pair')) + await owner.refresh() + owner.connectionChanged('disconnected') + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() + expect((await owner.refresh({ reconnect: true })).ok).toBe(true) +}) + +it('blocks a rejected reconnect even without an outstanding status request', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + await owner.refresh() + owner.connectionChanged('disconnected') + owner.authenticationRejected() + expect(owner.read()).toMatchObject({ verification: 'blocked', status: { runtimeId: 'host-1' } }) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() +}) + +it('cancelling one reader leaves the shared request available to other readers', async () => { + const { owner, request } = createOwner() + const pending = deferred() + request.mockReturnValue(pending.promise) + const controller = new AbortController() + const cancelled = owner.refresh({ signal: controller.signal }) + const remaining = owner.refresh() + const rejection = expect(cancelled).rejects.toThrow('cancelled') + controller.abort(new Error('cancelled')) + await rejection + expect(request.mock.calls[0][0].aborted).toBe(false) + pending.resolve(success()) + expect((await remaining).ok).toBe(true) +}) + +it.each(['unknown', 'ready'] as const)( + 'distinguishes the caller deadline with %s transport', + async (transport) => { + const { owner, request } = createOwner() + owner.connectionChanged(transport) + request.mockReturnValue(deferred().promise) + const response = owner.refresh({ timeoutMs: 100 }) + await vi.advanceTimersByTimeAsync(100) + expect(await response).toMatchObject({ + ok: false, + error: { + message: + transport === 'ready' + ? 'Status request timed out.' + : 'Timed out waiting for the remote Orca runtime.' + } + }) + } +) diff --git a/src/shared/runtime-host-status-owner.ts b/src/shared/runtime-host-status-owner.ts new file mode 100644 index 00000000000..d9c52395c89 --- /dev/null +++ b/src/shared/runtime-host-status-owner.ts @@ -0,0 +1,274 @@ +import { + isRuntimeHostStatusBlocked, + runtimeHostStatusError, + runtimeHostStatusFailure, + type RuntimeHostStatusResponse, + type RuntimeHostStatusSnapshot +} from './runtime-host-status' + +const RETRY_DELAYS_MS = [3_000, 6_000, 12_000, 30_000, 60_000] +const REQUEST_TIMEOUT_MS = 15_000 +let publicationSequence = 0 + +type Waiter = { + resolve: (response: RuntimeHostStatusResponse) => void + cleanup: () => void +} + +type StatusOwnerOptions = { + environmentId: string + pairingRevision: number + persistent?: boolean + request: (signal: AbortSignal) => Promise + verified: (response: Extract, active: boolean) => boolean + publish: (snapshot: RuntimeHostStatusSnapshot) => void +} + +/** One verification and one retry slot, shared by all readers of this connection. */ +export class RuntimeHostStatusOwner { + private active = false + private disposed = false + private persistent: boolean + private attempt = 0 + private retry: ReturnType | null = null + private request: AbortController | null = null + private readonly waiters = new Set() + private response: RuntimeHostStatusResponse = runtimeHostStatusFailure( + 'runtime_unavailable', + 'Status has not been checked.' + ) + private snapshot: RuntimeHostStatusSnapshot + + constructor(private readonly options: StatusOwnerOptions) { + this.persistent = options.persistent ?? false + this.snapshot = { + environmentId: options.environmentId, + pairingRevision: options.pairingRevision, + sequence: ++publicationSequence, + checkedAt: 0, + status: null, + verification: 'checking', + transport: 'unknown' + } + } + + read(): RuntimeHostStatusSnapshot { + return this.snapshot + } + + activate(): void { + if (this.active || this.disposed) { + return + } + this.active = true + this.startRequest() + } + + acceptVerified(response: Extract): void { + if (this.disposed) { + return + } + this.active = true + this.retireRequest() + this.clearRetry() + this.complete(response) + } + + refresh( + options: { timeoutMs?: number; observeOnly?: true; reconnect?: true; signal?: AbortSignal } = {} + ): Promise { + if (options.signal?.aborted) { + return Promise.reject(options.signal.reason) + } + if (this.disposed) { + return Promise.resolve(this.response) + } + if (!options.observeOnly) { + this.active = true + } + if (options.reconnect) { + this.attempt = 0 + this.update({ verification: 'checking' }) + } + if (this.snapshot.verification === 'blocked') { + return Promise.resolve(this.response) + } + const result = new Promise((resolve, reject) => { + const release = (): void => { + waiter.cleanup() + this.waiters.delete(waiter) + if (!this.active && this.waiters.size === 0) { + this.retireRequest() + } + } + const abort = (): void => { + release() + reject(options.signal?.reason) + } + const timer = setTimeout(() => { + release() + resolve( + runtimeHostStatusFailure( + 'runtime_unavailable', + this.snapshot.transport === 'ready' + ? 'Status request timed out.' + : 'Timed out waiting for the remote Orca runtime.' + ) + ) + }, options.timeoutMs ?? REQUEST_TIMEOUT_MS) + const waiter: Waiter = { + resolve, + cleanup: () => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', abort) + } + } + this.waiters.add(waiter) + options.signal?.addEventListener('abort', abort, { once: true }) + }) + this.startRequest() + return result + } + + connectionChanged( + transport: RuntimeHostStatusSnapshot['transport'], + remoteControl?: RuntimeHostStatusSnapshot['remoteControl'] + ): void { + if (this.disposed) { + return + } + const previous = this.snapshot.transport + this.update({ transport, ...(remoteControl !== undefined ? { remoteControl } : {}) }) + if (transport === previous) { + return + } + if (previous === 'ready') { + this.retireRequest() + this.clearRetry() + if (this.snapshot.verification !== 'blocked') { + this.update({ verification: 'unavailable' }) + } + } + if (transport === 'ready' && this.snapshot.verification !== 'blocked') { + // A pre-reconnect answer cannot verify the new socket's runtime. + this.retireRequest() + if (this.active || this.waiters.size > 0) { + this.startRequest() + } + } + } + + authenticationRejected(): void { + if (this.disposed) { + return + } + this.retireRequest() + this.clearRetry() + this.complete(runtimeHostStatusFailure('unauthorized', 'Pair this client again.')) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.active = false + this.retireRequest() + this.clearRetry() + this.response = runtimeHostStatusFailure( + 'runtime_manually_disconnected', + 'Runtime environment was disconnected or replaced.' + ) + this.update({ retired: true, transport: 'disconnected', verification: 'blocked' }) + this.settleWaiters() + } + + private startRequest(): void { + if (this.disposed || this.request || this.snapshot.verification === 'blocked') { + return + } + this.clearRetry() + const controller = new AbortController() + this.request = controller + if (this.snapshot.verification !== 'verified') { + this.update({ verification: 'checking' }) + } + void this.verify(controller) + } + + private async verify(controller: AbortController): Promise { + let response: RuntimeHostStatusResponse + try { + response = await this.options.request(controller.signal) + } catch (error) { + response = runtimeHostStatusError(error) + if (error instanceof TypeError || error instanceof SyntaxError) { + console.error('Runtime status verification failed:', error) + response = runtimeHostStatusFailure('invalid_runtime_response', error.message) + } + } + if (this.request !== controller || this.disposed) { + return + } + this.request = null + this.complete(response) + } + + private complete(response: RuntimeHostStatusResponse): void { + this.response = response + if (response.ok) { + this.attempt = 0 + this.update({ status: response.result, checkedAt: Date.now(), verification: 'verified' }) + this.persistent = this.options.verified(response, this.active) + } else { + this.update({ + checkedAt: Date.now(), + verification: isRuntimeHostStatusBlocked(response) ? 'blocked' : 'unavailable' + }) + this.scheduleRetry() + } + this.settleWaiters() + } + + private scheduleRetry(): void { + if ( + !this.active || + this.disposed || + this.snapshot.verification === 'blocked' || + (this.persistent && this.snapshot.transport !== 'ready') + ) { + return + } + const delay = RETRY_DELAYS_MS[Math.min(this.attempt++, RETRY_DELAYS_MS.length - 1)] + this.retry = setTimeout(() => { + this.retry = null + this.startRequest() + }, delay) + } + + private settleWaiters(): void { + for (const waiter of this.waiters) { + waiter.cleanup() + waiter.resolve(this.response) + } + this.waiters.clear() + } + + private retireRequest(): void { + const request = this.request + this.request = null + request?.abort() + } + + private clearRetry(): void { + if (this.retry) { + clearTimeout(this.retry) + } + this.retry = null + } + + private update(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch, sequence: ++publicationSequence } + this.options.publish(this.snapshot) + } +} diff --git a/src/shared/runtime-host-status.ts b/src/shared/runtime-host-status.ts new file mode 100644 index 00000000000..4dd7ff7cc22 --- /dev/null +++ b/src/shared/runtime-host-status.ts @@ -0,0 +1,44 @@ +import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' +import type { RuntimeRpcFailure, RuntimeRpcResponse } from './runtime-rpc-envelope' +import type { RuntimeStatus } from './runtime-types' + +export const RUNTIME_HOST_STATUS_CHANNEL = 'runtimeEnvironments:statusChanged' + +/** Local client state; never exchanged with the paired host. */ +export type RuntimeHostStatusSnapshot = { + environmentId: string + pairingRevision: number + sequence: number + checkedAt: number + status: RuntimeStatus | null + verification: 'checking' | 'verified' | 'unavailable' | 'blocked' + transport: 'unknown' | 'connecting' | 'ready' | 'disconnected' + remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null + retired?: true +} + +export type RuntimeHostStatusResponse = RuntimeRpcResponse + +export function runtimeHostStatusFailure(code: string, message: string): RuntimeRpcFailure { + return { id: 'status.get', ok: false, error: { code, message } } +} + +export function runtimeHostStatusError(error: unknown): RuntimeRpcFailure { + const code = + error instanceof Error && 'code' in error && typeof error.code === 'string' + ? error.code + : 'runtime_unavailable' + return runtimeHostStatusFailure(code, error instanceof Error ? error.message : String(error)) +} + +export function isRuntimeHostStatusBlocked(response: RuntimeRpcFailure): boolean { + return [ + 'unauthorized', + 'forbidden', + 'invalid_argument', + 'invalid_runtime_response', + 'protocol_version_mismatch', + 'method_not_found', + 'unsupported_method' + ].includes(response.error.code) +} diff --git a/src/shared/sha256.test.ts b/src/shared/sha256.test.ts new file mode 100644 index 00000000000..7e06e32b777 --- /dev/null +++ b/src/shared/sha256.test.ts @@ -0,0 +1,16 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { sha256 } from './sha256' + +describe('shared sha256', () => { + it.each([0, 1, 55, 56, 63, 64, 65, 119, 120, 127, 128, 1024, 65_536])( + 'matches Node crypto for a %i-byte offset view', + (length) => { + const backing = Uint8Array.from({ length: length + 7 }, (_, index) => index % 251) + const bytes = backing.subarray(7) + expect(Buffer.from(sha256(bytes)).toString('hex')).toBe( + createHash('sha256').update(bytes).digest('hex') + ) + } + ) +}) diff --git a/src/shared/sha256.ts b/src/shared/sha256.ts index 10350ddf7c8..aa628091e69 100644 --- a/src/shared/sha256.ts +++ b/src/shared/sha256.ts @@ -43,7 +43,14 @@ export function sha256(message: Uint8Array): Uint8Array { words[index] = (words[index - 16] + s0 + words[index - 7] + s1) | 0 } - let [a, b, c, d, e, f, g, h] = hash + let a = hash[0] + let b = hash[1] + let c = hash[2] + let d = hash[3] + let e = hash[4] + let f = hash[5] + let g = hash[6] + let h = hash[7] for (let index = 0; index < 64; index += 1) { const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25) const choice = (e & f) ^ (~e & g) diff --git a/src/shared/source-scan/source-tree-scan.ts b/src/shared/source-scan/source-tree-scan.ts index 937a2fc39db..dcd6050799b 100644 --- a/src/shared/source-scan/source-tree-scan.ts +++ b/src/shared/source-scan/source-tree-scan.ts @@ -29,6 +29,22 @@ export function isTestFile(relativePath: string): boolean { export type ScannedFile = { path: string; relativePath: string; source: string } +/** The three readdir type predicates the walk consults. */ +type DirentTypeProbe = { + isSymbolicLink(): boolean + isFile(): boolean + isDirectory(): boolean +} + +/** + * Links need a stat to follow them, and so does DT_UNKNOWN (every predicate + * false) -- filesystems that do not report d_type would otherwise have a real + * directory silently dropped from the scan. + */ +export function directoryEntryNeedsStat(entry: DirentTypeProbe): boolean { + return entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory()) +} + /** * Every `.ts`/`.tsx` file under `root`, with its text. * @@ -46,16 +62,18 @@ export function scanSourceTree( const extensions = options.extensions ?? /\.tsx?$/ const found: ScannedFile[] = [] const visit = (directory: string): void => { - for (const entry of readdirSync(directory)) { - if (IGNORED_DIRECTORIES.has(entry) || entry.startsWith('.') || entry === '__fixtures__') { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const name = entry.name + if (IGNORED_DIRECTORIES.has(name) || name.startsWith('.') || name === '__fixtures__') { continue } - const path = join(directory, entry) - if (statSync(path).isDirectory()) { + const path = join(directory, name) + // Ordinary entries carry their type from readdir. + if (directoryEntryNeedsStat(entry) ? statSync(path).isDirectory() : entry.isDirectory()) { visit(path) continue } - if (!extensions.test(entry)) { + if (!extensions.test(name)) { continue } const relativePath = relative(root, path).replace(/\\/g, '/') diff --git a/src/shared/source-scan/source-tree-walk.test.ts b/src/shared/source-scan/source-tree-walk.test.ts new file mode 100644 index 00000000000..afc7c8dba88 --- /dev/null +++ b/src/shared/source-scan/source-tree-walk.test.ts @@ -0,0 +1,144 @@ +import { + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import type * as Fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { directoryEntryNeedsStat, scanSourceTree } from './source-tree-scan' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readdirSync: vi.fn(actual.readdirSync), + statSync: vi.fn(actual.statSync) + } +}) + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-source-tree-walk-')) + vi.mocked(readdirSync).mockReset() + vi.mocked(statSync).mockClear() +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function file(relativePath: string, contents = relativePath): void { + writeFileSync(join(root, relativePath), contents) +} + +describe('scanSourceTree filesystem traversal', () => { + it('reads nested source files without stat calls for ordinary directory entries', () => { + mkdirSync(join(root, 'nested')) + file('first.ts') + file(join('nested', 'second.tsx'), 'nested/second.tsx') + file('ignored.txt') + file('first.test.ts') + + const files = scanSourceTree(root) + + // readdir order is filesystem-dependent (tmpfs differs from APFS/ext4). + expect(files).toHaveLength(2) + expect(files).toEqual( + expect.arrayContaining([ + { path: join(root, 'first.ts'), relativePath: 'first.ts', source: 'first.ts' }, + { + path: join(root, 'nested', 'second.tsx'), + relativePath: 'nested/second.tsx', + source: 'nested/second.tsx' + } + ]) + ) + expect(statSync).not.toHaveBeenCalled() + }) + + it('keeps ignored directories, dotfiles, and test exclusions out of the inventory', () => { + for (const directory of ['node_modules', 'dist', 'out', 'build', '.cache', '__fixtures__']) { + mkdirSync(join(root, directory)) + file(join(directory, 'hidden.ts')) + } + file('.hidden.ts') + file('sample.test.ts') + file('sample.spec.tsx') + file('test-harness.ts') + file('production.ts') + + expect(scanSourceTree(root).map((entry) => entry.relativePath)).toEqual(['production.ts']) + }) + + it('keeps extension and test-inclusion options', () => { + file('module.mjs') + file('module.ts') + file('module.test.ts') + + expect( + scanSourceTree(root, { includeTests: true }) + .map((entry) => entry.relativePath) + .sort() + ).toEqual(['module.test.ts', 'module.ts']) + expect( + scanSourceTree(root, { extensions: /\.mjs$/ }).map((entry) => entry.relativePath) + ).toEqual(['module.mjs']) + }) + + it('follows directory links with stat while preserving the lexical path', () => { + const target = join(root, '.target') + mkdirSync(target) + writeFileSync(join(target, 'linked.ts'), 'linked source') + symlinkSync(target, join(root, 'alias'), 'junction') + + expect(scanSourceTree(root)).toEqual([ + { + path: join(root, 'alias', 'linked.ts'), + relativePath: 'alias/linked.ts', + source: 'linked source' + } + ]) + expect(statSync).toHaveBeenCalledExactlyOnceWith(join(root, 'alias')) + }) + + + it('still reports a broken link instead of silently dropping it', () => { + const target = join(root, '.target') + mkdirSync(target) + symlinkSync(target, join(root, 'alias'), 'junction') + rmSync(target, { recursive: true }) + + expect(() => scanSourceTree(root)).toThrow(/ENOENT/) + expect(statSync).toHaveBeenCalledExactlyOnceWith(join(root, 'alias')) + }) +}) + +describe('directoryEntryNeedsStat', () => { + const probe = (kind: 'file' | 'dir' | 'link' | 'unknown') => ({ + isFile: () => kind === 'file', + isDirectory: () => kind === 'dir', + isSymbolicLink: () => kind === 'link' + }) + + it('skips the stat for entries readdir already typed', () => { + expect(directoryEntryNeedsStat(probe('file'))).toBe(false) + expect(directoryEntryNeedsStat(probe('dir'))).toBe(false) + }) + + it('stats links so they are followed', () => { + expect(directoryEntryNeedsStat(probe('link'))).toBe(true) + }) + + // Filesystems without d_type report DT_UNKNOWN: every predicate is false, and + // without the stat a real directory's whole subtree is silently dropped. + it('stats an entry whose type readdir could not report', () => { + expect(directoryEntryNeedsStat(probe('unknown'))).toBe(true) + }) +}) diff --git a/src/shared/structured-agent-session-live-turn.test.ts b/src/shared/structured-agent-session-live-turn.test.ts new file mode 100644 index 00000000000..b9ce5f2c022 --- /dev/null +++ b/src/shared/structured-agent-session-live-turn.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { isStructuredAgentSessionThinking } from './structured-agent-session-live-turn' + +function item( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId, sequence, revision: 1, observedAt: sequence, body } +} + +describe('isStructuredAgentSessionThinking', () => { + const turnStart = item('turn-start', 1, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }) + const reasoning = (sequence: number): AgentJournalRenderItem => + item(`reasoning-${sequence}`, sequence, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + + it('is true while reasoning is the newest thing the turn produced', () => { + expect(isStructuredAgentSessionThinking([turnStart, reasoning(2)])).toBe(true) + }) + + it('is false once a tool call, a message or a diff lands after the reasoning', () => { + const after = (body: AgentJournalRenderItem['body']): boolean => + isStructuredAgentSessionThinking([turnStart, reasoning(2), item('after', 3, body)]) + expect(after({ kind: 'tool-call', name: 'shell', input: null, state: 'running' })).toBe(false) + expect( + after({ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Here you go' }] }) + ).toBe(false) + expect( + after({ + kind: 'diff', + path: 'src/a.ts', + patch: { head: '@@', byteLength: 2, digest: 'd', truncated: false } + }) + ).toBe(false) + }) + + it('is false when a turn produced no reasoning at all', () => { + expect(isStructuredAgentSessionThinking([turnStart])).toBe(false) + expect(isStructuredAgentSessionThinking([])).toBe(false) + }) + + it('does not read an earlier turn as this one reasoning', () => { + // The scan stops at this turn's own record, so the previous turn's reasoning + // cannot leak forward into a turn that has produced nothing yet. + const newTurn = item('turn-2-start', 2, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-2', state: 'running' } + }) + expect(isStructuredAgentSessionThinking([reasoning(1), newTurn])).toBe(false) + }) + + it('does not read a completed turn as reasoning during the next pending dispatch', () => { + const completedTurn = item('turn-1', 1, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed' + }) + expect(isStructuredAgentSessionThinking([completedTurn, reasoning(2)])).toBe(false) + }) + + it('stops at a typed turn item, the carrier this host writes', () => { + const typedTurn = (sequence: number, turnId: string): AgentJournalRenderItem => + item(`turn-${turnId}`, sequence, { kind: 'turn', turnId, state: 'running' }) + expect(isStructuredAgentSessionThinking([typedTurn(1, 'turn-1'), reasoning(2)])).toBe(true) + expect(isStructuredAgentSessionThinking([reasoning(1), typedTurn(2, 'turn-2')])).toBe(false) + }) + + it('lets an unmarked status stay transparent to the latest reasoning state', () => { + const plan = item('plan', 3, { kind: 'status', text: 'Step 1. Read the file' }) + expect(isStructuredAgentSessionThinking([turnStart, plan])).toBe(false) + expect(isStructuredAgentSessionThinking([turnStart, reasoning(2), plan])).toBe(true) + }) + + it.each([ + { + kind: 'approval' as const, + title: 'Run the command?', + detail: null, + options: [], + resolution: { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { + kind: 'question' as const, + question: 'Which path?', + options: [], + resolution: { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + ])('stops thinking when the turn is waiting on a $kind', (prompt) => { + expect( + isStructuredAgentSessionThinking([turnStart, reasoning(2), item('prompt', 3, prompt)]) + ).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts new file mode 100644 index 00000000000..319a6d7d579 --- /dev/null +++ b/src/shared/structured-agent-session-live-turn.ts @@ -0,0 +1,76 @@ +// What the newest turn in a structured journal is doing right now, read off the +// tail of the item list. Every scan here stops at the turn's own record — the +// typed `turn` item, or the legacy status row that carries one — because state +// from an earlier turn is never this turn's state. + +import type { + AgentJournalRenderItem, + AgentJournalToolCallItem +} from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' + +export function activeStructuredAgentSessionTurnId( + items: readonly AgentJournalRenderItem[] +): string | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const turn = readAgentJournalTurn(items[index]?.body) + if (turn) { + return turn.state === 'running' ? turn.turnId : null + } + } + return null +} + +/** + * Whether the newest thing the active turn produced is the model's own reasoning. + * + * This is what "thinking" has to mean for the indicator to be honest: the turn is reasoning + * *right now*. The older rule — "the turn has produced no renderable output yet" — reports + * thinking while the request is merely in flight, and stops reporting it the moment a tool call + * lands, which is usually when reasoning actually starts. + */ +export function isStructuredAgentSessionThinking( + items: readonly AgentJournalRenderItem[] +): boolean { + let newestContentIsReasoning: boolean | null = null + for (let index = items.length - 1; index >= 0; index -= 1) { + const body = items[index]?.body + const turn = readAgentJournalTurn(body) + if (turn) { + return turn.state === 'running' && newestContentIsReasoning === true + } + if (newestContentIsReasoning !== null) { + continue + } + if (body?.kind === 'message') { + newestContentIsReasoning = body.role === 'reasoning' + } else if ( + body?.kind === 'tool-call' || + body?.kind === 'diff' || + body?.kind === 'approval' || + body?.kind === 'question' + ) { + newestContentIsReasoning = false + } + // Plain status copy is activity chrome, not newer transcript content. + } + return false +} + +/** The tool call the newest turn is still inside, or null when nothing is running. + * An abandoned `running` call from an earlier crashed turn can never be reported + * as live work. */ +export function activeStructuredAgentSessionToolCall( + items: readonly AgentJournalRenderItem[] +): AgentJournalToolCallItem | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const body = items[index]?.body + if (readAgentJournalTurn(body)) { + return null + } + if (body?.kind === 'tool-call' && body.state === 'running') { + return body + } + } + return null +} diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index bd13d600a34..68e8caf22eb 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -3,20 +3,26 @@ import { normalizeOptionalField, normalizePromptField } from './agent-status-field-normalization' -import type { - AgentJournalRenderItem, - AgentJournalSubmission, - AgentJournalToolCallItem -} from './agent-session-journal-types' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH } from './agent-status-types' import { describeToolInput } from './native-chat-tool-summary' -import { readAgentJournalTurn } from './agent-session-turn-record' +import { + activeStructuredAgentSessionToolCall, + activeStructuredAgentSessionTurnId +} from './structured-agent-session-live-turn' + import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' import { sha256 } from './sha256' +// Re-exported so the live-turn readers' existing consumers keep one import site. +export { + activeStructuredAgentSessionToolCall, + activeStructuredAgentSessionTurnId +} from './structured-agent-session-live-turn' + function boundedText(payload: { head: string; truncated: boolean; byteLength: number }): string { return payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head } @@ -131,10 +137,14 @@ const projectedItems = new WeakMap { + const messages: NativeChatMessage[] = [] + items.forEach((item) => { const projected = projectStructuredItemToNativeChat(item) - return projected ? [projected] : [] + if (projected) { + messages.push(projected) + } }) + return messages } export function projectStructuredItemToNativeChat( @@ -159,18 +169,6 @@ export function projectStructuredItemToNativeChat( return message } -export function activeStructuredAgentSessionTurnId( - items: readonly AgentJournalRenderItem[] -): string | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const turn = readAgentJournalTurn(items[index]?.body) - if (turn) { - return turn.state === 'running' ? turn.turnId : null - } - } - return null -} - export function hasPersistedStructuredAgentSessionTurn( items: readonly AgentJournalRenderItem[] ): boolean { @@ -270,24 +268,6 @@ export function latestStructuredAgentSessionAssistantMessage( return '' } -/** The tool call the newest turn is still inside, or null when nothing is running. - * Scanning stops at the turn's own lifecycle row so an abandoned `running` call - * from an earlier crashed turn can never be reported as live work. */ -export function activeStructuredAgentSessionToolCall( - items: readonly AgentJournalRenderItem[] -): AgentJournalToolCallItem | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const body = items[index]?.body - if (readAgentJournalTurn(body)) { - return null - } - if (body?.kind === 'tool-call' && body.state === 'running') { - return body - } - } - return null -} - /** The activity fields a sidebar row shows beside the prompt, named as the agent-status * entry names them so the client can hand them straight to a row. */ export type StructuredAgentSessionStatusProjection = { diff --git a/src/shared/structured-agent-session-turn-timing.test.ts b/src/shared/structured-agent-session-turn-timing.test.ts index f1487f0ef42..769d30fab10 100644 --- a/src/shared/structured-agent-session-turn-timing.test.ts +++ b/src/shared/structured-agent-session-turn-timing.test.ts @@ -158,7 +158,7 @@ describe('host-settled turns override local observation', () => { { activeTurnKey: 'u1', isWorking: false, - hasCurrentTurnResponse: true, + thinking: false, settledByTurn: settled } ) diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index 1c455333ea9..d99472e2fde 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -1,6 +1,58 @@ import type { AiVaultSessionTitle } from './ai-vault-session-title' import type { TuiAgent } from './tui-agent' +/** Why recovery reasons live in the shared row type: the tab row carries the + * recovery ledger, and the ledger records which reason it last acted on. */ +export type TerminalPaneRecoveryReason = + | 'write-stalled' + | 'replay-wedged' + | 'input-undeliverable' + // The paired runtime that owns the PTY refused this write and said so on the + // wire. Distinct from 'input-undeliverable' because it skips the liveness + // probe: main's registry holds no entry for a `remote:` id, so `pty:hasPty` + // routes it to the local provider and answers a fabricated "dead". The + // rejection frame is the evidence instead — it came from the process that + // owns the PTY, over a connection that is by construction still up. + | 'input-rejected-by-host' + | 'reattach-unverifiable' + // A restore was requested for a certified-dead pipeline (reveal path). + | 'restore-blocked' + // A spawn resolved without a PTY id, so the pane is mounted with no transport + // binding. pty:data for the old id then lands in the pre-handler buffer, which + // ACKs it — main's delivery health stays green while the pane shows nothing. + | 'spawn-left-pane-unbound' + +/** Same vocabulary the direct-SSH pane retry ledger settles with + * (DirectSshPaneRetryResult), so a pane reports both through one call. */ +export type TerminalPaneRecoveryOutcome = + | 'pending' + | 'success' + | 'failed' + | 'timed-out' + | 'superseded' + +/** The tab's recovery ledger. Lives on the row — not in a module- or + * store-level map keyed by tabId — so a tab's existence and its recovery + * budget are the same object: nothing can release the budget while keeping + * the row, and closing the tab drops both together (crash b5cfc6ca). */ +export type TerminalTabRecoveryLedger = { + /** Remount timestamps inside the rolling window. Backstop, not the control. */ + attemptedAt: number[] + /** Recovery epoch. A mounted pane captures it and stale requests are refused. */ + generation: number + /** What the mounted pane observed for the attempt this ledger describes. */ + outcome: TerminalPaneRecoveryOutcome + /** When that attempt was requested. Bounds how long 'pending' may block. */ + startedAt: number + /** The reason this attempt acted on. A settled failure refuses the SAME + * reason again until a new trigger arrives. */ + reason: TerminalPaneRecoveryReason + /** `tab.generation` right after the remount. Any later bump — authority + * change, SSH pane retry, activation respawn — is a new trigger, so the + * mismatch alone supersedes this ledger. No writer required. */ + tabGeneration: number +} + // ─── Terminal Tab (legacy — used by persistence and TerminalContentSlice) ─ export type TerminalTab = { id: string @@ -53,6 +105,11 @@ export type TerminalTab = { * `sortEpoch` increments. Split layouts use a numeric count because one tab * can remount several panes. Never persisted — it is a transient handoff. */ pendingActivationSpawn?: boolean | number + /** Transient recovery ledger for this tab. Never persisted — it describes a + * mounted pane's in-flight heal, and a stale one would refuse the first + * legitimate recovery after restart. Stripped exactly like + * `pendingActivationSpawn` (buildSanitizedTabsByWorktree). */ + recovery?: TerminalTabRecoveryLedger } export type TerminalPaneSplitDirection = 'vertical' | 'horizontal' diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index 289c5820397..963f0e13faf 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -348,7 +348,7 @@ export function ingestGitGrepLine( export function finalize(acc: SearchAccumulator): SearchResult { return normalizeSearchResult({ - files: Array.from(acc.fileMap.values()).filter((file) => file.matches.length > 0), + files: Array.from(acc.fileMap.values()), totalMatches: acc.totalMatches, truncated: acc.truncated }) diff --git a/src/shared/utf8-byte-limits.ts b/src/shared/utf8-byte-limits.ts index 767d5bfb877..134b5ea07d4 100644 --- a/src/shared/utf8-byte-limits.ts +++ b/src/shared/utf8-byte-limits.ts @@ -66,6 +66,10 @@ export function isUtf8ByteLengthWithinLimit(text: string, maxBytes: number): boo if (text.length > maxBytes) { return false } + // UTF-8 needs at most three bytes per UTF-16 unit, including unpaired surrogates. + if (text.length * 3 <= maxBytes) { + return true + } if (Number.isSafeInteger(maxBytes) && maxBytes <= MAX_UTF8_SCRATCH_BYTES) { if (utf8Scratch.length < maxBytes) { utf8Scratch = new Uint8Array(maxBytes) diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 48fe00a7f4d..0a24551381e 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -99,6 +99,12 @@ const terminalTabSchema = z.object({ customTitle: z.string().nullable(), color: z.string().nullable(), isPinned: z.boolean().optional(), + // Why: recovery asks the terminal row who owns the surface, so a row that + // loses viewMode on reload reads as "not chat-owned" and lets a hidden chat + // surface remount itself. Declared here so the row survives the parse, with + // the same `.catch('terminal')` degradation the unified tab uses below. + // Legacy rows that predate this stay undefined → 'terminal' in the renderer. + viewMode: z.enum(['terminal', 'chat']).catch('terminal').optional(), sortOrder: z.number(), createdAt: z.number(), generation: z.number().optional(), diff --git a/src/shared/workspace-session-terminal-buffers.ts b/src/shared/workspace-session-terminal-buffers.ts index ef8ac6a273b..706dbedc2d7 100644 --- a/src/shared/workspace-session-terminal-buffers.ts +++ b/src/shared/workspace-session-terminal-buffers.ts @@ -77,21 +77,24 @@ export function pruneLocalTerminalScrollbackBuffers( session: WorkspaceSessionState, repos: readonly RepoConnection[] ): WorkspaceSessionState { - const repoById = new Map(repos.map((repo) => [repo.id, repo] as const)) - const worktreeIdByTabId = new Map() + let repoById: Map | null = null + let worktreeIdByTabId: Map | null = null const tabsByWorktree = session.tabsByWorktree ?? {} const terminalLayoutsByTabIdForRead = session.terminalLayoutsByTabId ?? {} - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null for (const [tabId, layout] of Object.entries(terminalLayoutsByTabIdForRead)) { if (!layout.buffersByLeafId && !layout.scrollbackRefsByLeafId) { continue } + repoById ??= new Map(repos.map((repo) => [repo.id, repo] as const)) + if (!worktreeIdByTabId) { + worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + } const worktreeId = worktreeIdByTabId.get(tabId) if (shouldPreserveTerminalScrollbackBuffersForRepoMap(worktreeId, repoById)) { const capped = capTerminalScrollbackLeafBuffers(layout.buffersByLeafId) diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts index 5878b70a1b9..18a930fa492 100644 --- a/src/shared/workspace-session-terminal-schema.test.ts +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -72,4 +72,53 @@ describe('parseWorkspaceSession terminal fields', () => { expect(result.value.tabsByWorktree.wt).toEqual([]) } }) + + // Why this matters beyond persistence hygiene: terminal-pane recovery asks + // the terminal ROW who owns the surface. While the row lost viewMode on load, + // a chat-owned tab read as "not chat-owned" after every restart and recovery + // would remount its hidden surface — the race #19745's guard exists to stop. + describe('terminal row viewMode', () => { + function parseRow(row: Record): Record | undefined { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...row + } + ] + }, + terminalLayoutsByTabId: {} + }) + expect(result.ok).toBe(true) + return result.ok ? result.value.tabsByWorktree.wt[0] : undefined + } + + it('survives the load boundary so a restored row still reads chat-owned', () => { + expect(parseRow({ viewMode: 'chat' })?.viewMode).toBe('chat') + }) + + it('keeps an explicit terminal mode', () => { + expect(parseRow({ viewMode: 'terminal' })?.viewMode).toBe('terminal') + }) + + it('leaves a row persisted by an older build undefined rather than failing', () => { + expect(parseRow({})?.viewMode).toBeUndefined() + }) + + it('degrades an unknown mode from a newer build instead of dropping the tab', () => { + // .catch('terminal') — the safe default, never a whole-session parse failure. + expect(parseRow({ viewMode: 'holographic' })?.viewMode).toBe('terminal') + }) + }) }) diff --git a/src/types/psl.ts b/src/types/psl.ts deleted file mode 100644 index b34a5329583..00000000000 --- a/src/types/psl.ts +++ /dev/null @@ -1,17 +0,0 @@ -declare module 'psl' { - export type ParsedDomain = { - input: string - tld: string | null - sld: string | null - domain: string | null - subdomain: string | null - listed: boolean - } - - export type ParseError = { - input: string - error: { code: string; message: string } - } - - export function parse(input: string): ParsedDomain | ParseError -} diff --git a/tests/e2e/helpers/relay-execution-process.ts b/tests/e2e/helpers/relay-execution-process.ts new file mode 100644 index 00000000000..c9247bffca8 --- /dev/null +++ b/tests/e2e/helpers/relay-execution-process.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises' +import path from 'node:path' +import { createInterface } from 'node:readline' +import { spawnProcess } from '../../../src/shared/child-process/run-process' + +const executionProgram = ` +const fs = require('node:fs'); +const readline = require('node:readline'); +const marker = process.argv[1]; +let sequence = 0; +readline.createInterface({ input: process.stdin }).on('line', line => { + const { id, value } = JSON.parse(line); + if (value === 'mutation-1') fs.appendFileSync('mutations.log', marker + '\\n'); + process.stdout.write(JSON.stringify({ id, pid: process.pid, cwd: fs.realpathSync('.'), + marker, sequence: ++sequence, value }) + '\\n'); +}); +` + +export async function createRelayExecutionProcess() { + await mkdir(path.join(process.cwd(), '.tmp'), { recursive: true }) + const folder = await mkdtemp(path.join(process.cwd(), '.tmp', 'relay-execution-')) + const executionCwd = await realpath(folder) + const marker = randomUUID() + const pending = new Map< + number, + { + resolve: (value: string) => void + reject: (error: Error) => void + timer: ReturnType + } + >() + let sequence = 0 + let nextId = 0 + let failure: Error | null = null + const child = spawnProcess({ + program: process.execPath, + args: ['-e', executionProgram, marker], + cwd: folder, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + const fail = (error: Error) => { + failure = error + for (const item of pending.values()) { + clearTimeout(item.timer) + item.reject(error) + } + pending.clear() + } + child.on('error', fail) + child.stdin.on('error', fail) + child.stdout.on('error', fail) + child.stderr.on('error', fail) + child.stderr.resume() + const closed = new Promise((resolve) => + child.once('close', () => { + fail(new Error('execution process exited')) + resolve() + }) + ) + const lines = createInterface({ input: child.stdout }) + lines.on('line', (line) => { + try { + const output = JSON.parse(line) + const item = pending.get(output.id) + if ( + !item || + output.pid !== child.pid || + output.cwd !== executionCwd || + output.marker !== marker || + output.sequence !== sequence + 1 + ) { + throw new Error('execution ownership or output sequence changed') + } + sequence = output.sequence + clearTimeout(item.timer) + pending.delete(output.id) + item.resolve(output.value) + } catch (error) { + fail(error as Error) + } + }) + return { + pid: child.pid, + sequence: () => sequence, + execute: (value: string) => + new Promise((resolve, reject) => { + if (failure) { + reject(failure) + return + } + const id = ++nextId + const timer = setTimeout(() => fail(new Error('execution response timed out')), 5_000) + pending.set(id, { resolve, reject, timer }) + child.stdin.write(`${JSON.stringify({ id, value })}\n`) + }), + mutations: async () => { + try { + const entries = (await readFile(path.join(folder, 'mutations.log'), 'utf8')) + .trim() + .split('\n') + if (entries.some((entry) => entry !== marker)) { + throw new Error('unexpected execution artifact') + } + return entries.length + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 0 + } + throw error + } + }, + close: async () => { + child.stdin.end() + const timer = setTimeout(() => child.kill('SIGKILL'), 5_000) + try { + await closed + } finally { + clearTimeout(timer) + lines.close() + await rm(folder, { recursive: true, force: true }) + } + } + } +} diff --git a/tests/e2e/helpers/slept-workspace-probe.ts b/tests/e2e/helpers/slept-workspace-probe.ts new file mode 100644 index 00000000000..b2b5635b526 --- /dev/null +++ b/tests/e2e/helpers/slept-workspace-probe.ts @@ -0,0 +1,133 @@ +/** + * Shared probes for GH #10205: a deliberately slept workspace must stay cold. + * Drives the shipping sleep path (sidebar context menu) and reads both the + * renderer's live PTY model and host truth. + */ +import type { Locator, Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { ensureTerminalVisible } from './store' +import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal' + +export type WorkspaceSample = { + livePtyCount: number + tabCount: number + tabIds: string[] + mountedTabIds: string[] + tabPtyHints: (string | null)[] +} + +export function rowLocator(page: Page, worktreeId: string): Locator { + return page + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(worktreeId)}]` + ) + .first() +} + +export async function readWorkspaceSample( + page: Page, + worktreeId: string +): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('window.__store is not available') + } + const tabs = state.tabsByWorktree[id] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + const managers = window.__paneManagers + return { + livePtyCount: tabs.reduce( + (count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), + 0 + ), + tabCount: tabs.length, + tabIds: tabs.map((tab) => tab.id), + mountedTabIds: managers + ? Array.from(managers.keys()).filter((tabId) => tabIds.has(tabId)) + : [], + tabPtyHints: tabs.map((tab) => tab.ptyId ?? null) + } + }, worktreeId) +} + +/** Host-side truth: a revived workspace shows a freshly created live session here. */ +export async function readHostLiveTerminalCount(page: Page, worktreeId: string): Promise { + return (await page.evaluate(async (id) => { + const result = await window.api.runtime.call({ + method: 'terminal.list', + params: { worktree: `id:${id}`, requireFreshPtyLiveness: true } + }) + if (!result.ok) { + throw new Error(result.error.message) + } + return (result.result as { totalCount: number }).totalCount + }, worktreeId)) as number +} + +/** Connect-verdict lines (REATTACH / ATTACH / FRESH SPAWN / SKIP SPAWN) for one workspace. */ +export async function readConnectDiagnostics(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + const target = globalThis as unknown as Record + const diag = (target.__ptyConnectDiag as string[] | undefined) ?? [] + const tabIds = new Set((state?.tabsByWorktree[id] ?? []).map((tab) => tab.id)) + // Pane ids restart at 1 per worktree, so a verdict line is attributed to the + // tab named by the most recent connect line for that same pane id. + const tabByPaneId = new Map() + const owned: string[] = [] + for (const line of diag) { + const connect = /^pane=(\d+) tab=(\S+) /.exec(line) + if (connect) { + tabByPaneId.set(connect[1], connect[2]) + if (tabIds.has(connect[2])) { + owned.push(line) + } + continue + } + const verdict = /^pane=(\d+) ->/.exec(line) + if (verdict) { + const tabId = tabByPaneId.get(verdict[1]) + if (tabId && tabIds.has(tabId)) { + owned.push(line) + } + } + } + return owned + }, worktreeId) +} + +export async function giveWorkspaceALivePty(page: Page, worktreeId: string): Promise { + await page.evaluate((id) => { + window.__store?.getState().setActiveWorktree(id) + }, worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + return waitForActivePanePtyId(page, 30_000) +} + +/** The shipping sleep path: right-click the sidebar row, click "Sleep". */ +export async function sleepWorkspaceViaSidebar(page: Page, worktreeId: string): Promise { + const row = rowLocator(page, worktreeId) + await expect(row).toBeVisible() + await row.scrollIntoViewIfNeeded() + const scope = row.locator('[data-worktree-context-menu-scope="worktree"]').first() + const target = (await scope.count()) > 0 ? scope : row + await target.click({ button: 'right' }) + const sleepItem = page.getByRole('menuitem', { name: 'Sleep', exact: true }).first() + await expect(sleepItem).toBeVisible() + await sleepItem.click() +} + +export async function activateWorkspaceByClick(page: Page, worktreeId: string): Promise { + const row = rowLocator(page, worktreeId) + await expect(row).toBeVisible() + await row.scrollIntoViewIfNeeded() + await row.click() + await expect + .poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null), { + timeout: 10_000, + message: `sidebar click did not activate ${worktreeId}` + }) + .toBe(worktreeId) +} diff --git a/tests/e2e/helpers/source-control-ai-generation.ts b/tests/e2e/helpers/source-control-ai-generation.ts index c93a20e37a1..3a488bc58db 100644 --- a/tests/e2e/helpers/source-control-ai-generation.ts +++ b/tests/e2e/helpers/source-control-ai-generation.ts @@ -53,7 +53,6 @@ export async function openChecks(page: Page, worktreeId: string): Promise // instead of hanging on a locator that stopped matching mid-action. await checksButton.click({ timeout: 2_000 }).catch(() => undefined) } - await page.waitForTimeout(250) return page.evaluate(() => window.__store?.getState().rightSidebarTab) }, { timeout: 10_000 } diff --git a/tests/e2e/linear-url-workspace-entry.spec.ts b/tests/e2e/linear-url-workspace-entry.spec.ts index 76e17e11ffe..d1cb2677257 100644 --- a/tests/e2e/linear-url-workspace-entry.spec.ts +++ b/tests/e2e/linear-url-workspace-entry.spec.ts @@ -97,6 +97,10 @@ async function releaseHeldLinearLookup(page: Page): Promise { async function pasteLinearUrl(page: Page, input: ReturnType): Promise { await page.evaluate((text) => window.api.ui.writeClipboardText(text), LINEAR_URL) + // X selection ownership is async; pasting before it lands delivers stale text. + await expect + .poll(() => page.evaluate(() => window.api.ui.readClipboardText()), { timeout: 5_000 }) + .toBe(LINEAR_URL) await input.focus() await page.keyboard.press(pasteChord()) } diff --git a/tests/e2e/native-chat-first-flush-race.spec.ts b/tests/e2e/native-chat-first-flush-race.spec.ts index 1362a882902..2e85e2dc132 100644 --- a/tests/e2e/native-chat-first-flush-race.spec.ts +++ b/tests/e2e/native-chat-first-flush-race.spec.ts @@ -141,10 +141,24 @@ test.describe('Native chat first-flush transcript race (#8401)', () => { path: path.join(screenshotDir, '01-loading-no-error.png') }) - // Why: a short real delay proves the first readSession attempt already - // hit the not-yet-flushed file (returning notFound) and the renderer's - // backoff retry — not a lucky first read — is what picks it up below. - await orcaPage.waitForTimeout(1_500) + // Why observe, not sleep: 1_500ms is exactly UNFLUSHED_SETTLE_MS, so a fixed + // wait straddles the boundary where the host reports the transcript pending + // and the renderer cancels its own retry. Read through the same IPC instead, + // proving the miss directly. A notFound is never cached, so this cannot + // perturb the hydration the assertions below measure. + await expect + .poll( + () => + orcaPage.evaluate( + ({ id, file }) => + window.api.nativeChat + .readSession('claude', id, 50, file) + .then((result) => Boolean(result && 'error' in result && result.notFound)), + { id: sessionId, file: transcriptPath } + ), + { timeout: 10_000, message: 'transcript resolved before the first flush' } + ) + .toBe(true) await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0) const userText = 'Explain the native chat first-flush race fix for #8401' diff --git a/tests/e2e/orchestration-idle-mail-delivery.spec.ts b/tests/e2e/orchestration-idle-mail-delivery.spec.ts index cffa6415208..9d90aed1875 100644 --- a/tests/e2e/orchestration-idle-mail-delivery.spec.ts +++ b/tests/e2e/orchestration-idle-mail-delivery.spec.ts @@ -653,7 +653,12 @@ test.describe('orchestration delivery to a cold-parked agent', () => { const parkingDelayMs = 500 test.use({ - orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(parkingDelayMs) } + orcaAppExtraEnv: { + ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(parkingDelayMs), + // The working-title round trip (PTY -> daemon -> main) must beat the Enter + // timer; 500ms is a production heuristic, not a budget CI can honour. + ORCA_E2E_ORCHESTRATION_POINTER_ENTER_DELAY_MS: '5000' + } }) test('keeps one pointer and one idempotent prompt on the same parked PTY', async ({ diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index fae82b45d44..3967f7934eb 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -27,7 +27,11 @@ type StoreState = Record let mockStoreState: StoreState let storeSubscribers: ((state: StoreState) => void)[] = [] -const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) +/** The store action reports admission now, not a bare boolean. */ +const REMOUNTED = { remounted: true as const, generation: 1 } +const remountTerminalTabForRecovery = vi.fn<(tabId: string, request?: unknown) => typeof REMOUNTED>( + () => REMOUNTED +) vi.mock('@/store', () => ({ useAppStore: { @@ -278,7 +282,7 @@ describe('host-rejected paired-runtime input reaches a pane remount', () => { vi.resetModules() vi.clearAllMocks() storeSubscribers = [] - remountTerminalTabForRecovery.mockReturnValue(true) + remountTerminalTabForRecovery.mockReturnValue(REMOUNTED) mockStoreState = { activeWorktreeId: 'wt-1', activeWorkspaceExecutionHostId: `runtime:${ENVIRONMENT_ID}`, @@ -417,7 +421,12 @@ describe('host-rejected paired-runtime input reaches a pane remount', () => { // Hop 1: the host turned the refusal into the negotiated frame. await vi.waitFor(() => expect(hostOpcodes).toContain(TerminalStreamOpcode.WriteUnavailable)) // Hop 2 (the one that was missing): it survives pane recovery as a remount. - await vi.waitFor(() => expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1')) + await vi.waitFor(() => + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ reason: 'input-rejected-by-host', trigger: 'automatic' }) + ) + ) binding.dispose() _resetTerminalPaneRecoveryForTests() diff --git a/tests/e2e/pr-comments-sidebar-cards.spec.ts b/tests/e2e/pr-comments-sidebar-cards.spec.ts index d0cfa4498c2..127502dab91 100644 --- a/tests/e2e/pr-comments-sidebar-cards.spec.ts +++ b/tests/e2e/pr-comments-sidebar-cards.spec.ts @@ -138,15 +138,21 @@ test.describe('PR comments sidebar cards view', () => { await orcaPage.screenshot({ path: testInfo.outputPath('reaction-before.png') }) await threadReactionButton.click() await expect(orcaPage.getByRole('group', { name: 'Add reaction' })).toBeFocused() - await orcaPage.waitForTimeout(300) - await orcaPage.screenshot({ path: testInfo.outputPath('reaction-picker.png') }) + await expect(orcaPage.getByRole('button', { name: 'Add rocket reaction' })).toBeVisible() + await orcaPage.screenshot({ + path: testInfo.outputPath('reaction-picker.png'), + animations: 'disabled' + }) await orcaPage.getByRole('button', { name: 'Add rocket reaction' }).click() await expect(orcaPage.getByRole('group', { name: 'Add reaction' })).toBeHidden() const selectedRocket = reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) await expect(selectedRocket).toHaveAttribute('aria-pressed', 'true') await selectedRocket.focus() - await orcaPage.waitForTimeout(300) - await orcaPage.screenshot({ path: testInfo.outputPath('reaction-after.png') }) + await expect(selectedRocket).toBeFocused() + await orcaPage.screenshot({ + path: testInfo.outputPath('reaction-after.png'), + animations: 'disabled' + }) await selectedRocket.press('Enter') await expect(selectedRocket).toHaveCount(0) await expect(threadReactionButton).toBeFocused() diff --git a/tests/e2e/quick-open-file-paths.spec.ts b/tests/e2e/quick-open-file-paths.spec.ts index bb9703bc5cb..8625af42082 100644 --- a/tests/e2e/quick-open-file-paths.spec.ts +++ b/tests/e2e/quick-open-file-paths.spec.ts @@ -42,19 +42,20 @@ test('cmd+p quick open prioritizes the filename and reveals the full path on hov rowText?.indexOf('packages/orca/src/renderer/src/components/navigation/') ?? -1 ) - // Two hovers on purpose: results stream in and remount the row, and Radix only - // opens on a pointermove it actually receives. A single hover can land before - // the remount and leave the cursor sitting still over a row that never saw it. - await row.hover({ position: { x: 20, y: 12 } }) - await orcaPage.waitForTimeout(250) - await row.hover({ position: { x: 40, y: 12 } }) + const tooltip = orcaPage + .locator('[data-slot="tooltip-content"]') + .filter({ hasText: relativeFilePath }) + // Streaming results can remount the row under a stationary pointer. + await expect(async () => { + await row.hover({ position: { x: 20, y: 12 }, timeout: 1_000 }) + await row.hover({ position: { x: 40, y: 12 }, timeout: 1_000 }) + await expect(tooltip).toBeVisible({ timeout: 1_000 }) + }).toPass({ timeout: 10_000, intervals: [100, 250, 500] }) // Exact cursor placement is arithmetic, unit-tested via cursorTooltipOffsets. // Asserting it here measures the app mid-reflow and is flaky; what E2E is // uniquely good for is that the tooltip really opens with the whole path. - await expect( - orcaPage.locator('[data-slot="tooltip-content"]').filter({ hasText: relativeFilePath }) - ).toBeVisible() + await expect(tooltip).toBeVisible() const proofPath = process.env.ORCA_QUICK_OPEN_PROOF_PATH if (proofPath) { diff --git a/tests/e2e/relay-region-compatibility.unit.test.ts b/tests/e2e/relay-region-compatibility.unit.test.ts new file mode 100644 index 00000000000..9019498ada7 --- /dev/null +++ b/tests/e2e/relay-region-compatibility.unit.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AssignmentRequestSchema as BaselineRequest, + AssignmentResponseSchema as BaselineResponse +} from '../../cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages' +import { + DrainSchema as BaselineDrain, + HostHelloSchema as BaselineHello +} from '../../cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages' +import { AssignmentRequestSchema } from '../../cloud/packages/relay-contract/src/director-messages' +import { HostHelloSchema } from '../../cloud/packages/relay-contract/src/control-messages' +import { requestRelayAssignment } from '../../src/main/runtime/relay/relay-http-client' +import { RelayAssignRateGate } from '../../src/main/runtime/relay/relay-assign-rate-gate' + +const assignment = { + v: 1, + cellUrl: 'https://asia.example.test', + assignmentEpoch: 3, + lease: 'synthetic-assignment' +} +const window = { + generation: 1, + assignmentEpoch: 3, + incumbentRegion: 'asia-east2', + expiresAt: 100_000_000, + policyVersion: 1 +} +function request(fetch: typeof globalThis.fetch) { + return requestRelayAssignment({ + directorUrl: 'https://director.example.test', + relayHostId: 'abcdefghijklmnop', + relayToken: 'synthetic-authorization', + preferredRegion: 'asia-east2', + reconnect: true, + regionCorrection: { v: 1, action: 'issue-window' }, + fetch, + assignRateGate: new RelayAssignRateGate() + }) +} + +describe('relay correction mixed-version wire contracts', () => { + it('new desktop falls back against the actual pinned old director parser', async () => { + const bodies: unknown[] = [] + const fetch = vi.fn(async (_url, init) => { + const body: unknown = JSON.parse(String(init?.body)) + bodies.push(body) + return BaselineRequest.safeParse(body).success + ? Response.json(BaselineResponse.parse(assignment)) + : new Response(null, { status: 400 }) + }) + expect(await request(fetch)).toEqual(assignment) + expect(bodies).toHaveLength(2) + expect(AssignmentRequestSchema.safeParse(bodies[0]).success).toBe(true) + expect(BaselineRequest.safeParse(bodies[0]).success).toBe(false) + expect(bodies[1]).toEqual({ + v: 1, + relayHostId: 'abcdefghijklmnop', + preferredRegion: 'asia-east2', + reconnect: true + }) + }) + + it('the old desktop assignment shape remains accepted by the new director', () => { + const request = BaselineRequest.parse({ v: 1, relayHostId: 'abcdefghijklmnop' }) + expect(AssignmentRequestSchema.parse(request)).toEqual(request) + expect(BaselineResponse.parse(assignment)).toEqual(assignment) + }) + + it('the negotiated capability requires no change to the strict old host hello', () => { + const hello = { + v: 1, + relayHostId: 'abcdefghijklmnop', + assignmentEpoch: 3, + hostPublicKeyB64: Buffer.alloc(32).toString('base64'), + appVersion: 'test' + } + expect(BaselineHello.parse(HostHelloSchema.parse(hello))).toEqual(hello) + expect(BaselineHello.safeParse({ ...hello, idleRegionalRehome: true }).success).toBe(false) + }) + + it('the idle cutover uses a drain frame understood by the pinned old desktop', () => { + const drain = { recovery: 'resolve-director', graceMs: 0 } + expect(BaselineDrain.parse(drain)).toEqual(drain) + }) + + it.each([ + { v: 1, window: { ...window, policyVersion: 2 } }, + { v: 2, window }, + { v: 1, window: { ...window, expiresAt: -1 } }, + { v: 1, window: { ...window, unexpectedField: true } } + ])( + 'defers unsupported or malformed optional correction without losing placement: %j', + async (regionCorrection) => { + const result = await request(async () => Response.json({ ...assignment, regionCorrection })) + expect(result).toMatchObject(assignment) + expect(result.regionCorrection).toBeUndefined() + } + ) + + it('still accepts supported correction metadata', async () => { + const regionCorrection = { v: 1, window } + expect(await request(async () => Response.json({ ...assignment, regionCorrection }))).toEqual({ + ...assignment, + regionCorrection + }) + }) + + it.each([ + { cellUrl: 'http://untrusted.example.test' }, + { assignmentEpoch: -1 }, + { lease: '' }, + { unexpectedField: true } + ])('keeps the core assignment strict: %j', async (invalid) => { + await expect(request(async () => Response.json({ ...assignment, ...invalid }))).rejects.toThrow( + 'relay_assignment_failed_502' + ) + }) +}) diff --git a/tests/e2e/relay-region-correction.unit.test.ts b/tests/e2e/relay-region-correction.unit.test.ts new file mode 100644 index 00000000000..614e5c08c1d --- /dev/null +++ b/tests/e2e/relay-region-correction.unit.test.ts @@ -0,0 +1,519 @@ +import { createHash, randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import WebSocket from 'ws' +import type { IdleRegionalRehomeRequest } from '../../cloud/packages/relay-contract/src/idle-regional-rehome' +import { + openInMemoryRelayDatabase, + readRelayDatabasePoolPressure +} from '../../cloud/apps/relay/src/database' +import { createRelayServer } from '../../cloud/apps/relay/src/relay-server' +import type { RelayConfig } from '../../cloud/apps/relay/src/config' +import type * as AdminTokenVerifier from '../../cloud/apps/relay/src/admin-token-verifier' +import { RelayOriginPool } from '../../src/main/runtime/relay/relay-origin-pool' +import { RELAY_HOST_CAPABILITY_HEADERS } from '../../src/main/runtime/relay/relay-control-protocol' +import type { MobileSocketTransport } from '../../src/main/runtime/rpc/mobile-socket-wiring' +import { createRelayExecutionProcess } from './helpers/relay-execution-process' + +vi.mock('../../cloud/apps/relay/src/relay-token-verifier', () => ({ + createRelayTokenVerifier: () => async (hostId: string) => ({ + sub: 'transport-test-user', + prof: 'profile-1', + org: 'org-1', + relayHostId: hostId, + purpose: 'host-control', + exp: 4_102_444_800 + }), + readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null +})) + +vi.mock('../../cloud/apps/relay/src/admin-token-verifier', async (importOriginal) => ({ + ...(await importOriginal()), + createRegionalRehomeTokenVerifier: () => async (token: string) => token === 'test-director-token' +})) + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + const failures: unknown[] = [] + for (const cleanup of cleanups.splice(0).toReversed()) { + try { + await cleanup() + } catch (error) { + failures.push(error) + } + } + vi.restoreAllMocks() + if (failures.length > 0) { + throw new AggregateError(failures, 'relay topology cleanup failed') + } +}) + +async function topology() { + const execution = await createRelayExecutionProcess() + cleanups.push(() => execution.close()) + let clock = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => clock) + const database = await openInMemoryRelayDatabase() + cleanups.push(() => database.close()) + const keypair = nacl.box.keyPair() + const hostId = createHash('sha256').update(keypair.publicKey).digest('base64url').slice(0, 16) + const identity = { userId: 'transport-test-user', relayHostId: hostId } + const cells = [ + { + id: 'transport-us', + url: 'https://transport-us.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + }, + { + id: 'transport-asia', + url: 'https://transport-asia.example.test', + region: 'asia-east2' as const, + capacityRequests: 100 + } + ] + const incarnations = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' + ] + const endpoints = new Map() + const sockets = new Set() + const servers = cells.map((cell, index) => + createRelayServer( + { + port: 0, + publicUrl: cell.url, + cellUrl: cell.url, + role: 'cell', + cellId: cell.id, + region: cell.region, + cells, + dataDir: '', + authIssuer: 'https://auth.example.test', + authAudience: 'orca-relay', + adminJwksUrl: 'https://auth.example.test/jwks', + jwksUrl: 'https://auth.example.test/jwks', + assignmentSigningKey: new Uint8Array(32), + adminAudience: 'https://director.example.test/v1/admin/drain', + deployServiceAccount: 'deploy@example.test', + rehomeAudience: 'https://director.example.test/v1/admin/host-drain', + rehomeDirectorServiceAccount: 'director@example.test', + databasePoolMax: 1, + publicAssignmentsEnabled: true, + publicAssignmentConcurrency: 2, + publicAssignmentQueueMax: 128, + publicAssignmentWaitMs: 4_000, + publicResolveConcurrency: 1, + publicResolveWaitMs: 5_000, + publicAssignmentRetryAfterSeconds: 5, + regionCorrectionCohortPercent: 100 + } as RelayConfig, + database, + { now: () => clock, random: () => 0.5, cellIncarnation: incarnations[index] } + ) + ) + cleanups.push(async () => { + for (const socket of sockets) { + socket.terminate() + } + for (const relay of servers) { + relay.sessions.drain(0) + await new Promise((resolve) => relay.server.close(() => resolve())) + } + }) + const source = servers[0]! + const target = servers[1]! + await source.assignments.inspectRegionalRehomeControl() + clock += 86_400_000 + await source.assignments.applyRegionalRehomeControl({ + expectedGeneration: 0, + enabled: true, + notBefore: clock, + ratePerMinute: 10, + preferenceMaxAgeMs: 86_400_000, + hostCooldownMs: 604_800_000, + drainGraceMs: 60_000 + }) + await source.assignments.reconcileCells(cells) + const startedAt = clock - 1_000 + const safety = () => ({ + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + const heartbeat = async () => { + for (const [index, cell] of cells.entries()) { + const relay = servers[index]! + relay.observability.flush({ + ...relay.runtimeCounts(), + ...readRelayDatabasePoolPressure(database) + }) + await source.assignments.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt, + ready: true, + observedRequests: 0 + }) + await source.assignments.recordCellRegionalRehomeStatus({ + cellId: cell.id, + cellIncarnation: incarnations[index]!, + regionalRehomeProtocol: 3, + safety: { + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + }) + } + } + await heartbeat() + for (const [index, relay] of servers.entries()) { + relay.server.listen(0, '127.0.0.1') + await once(relay.server, 'listening') + const address = relay.server.address() + if (!address || typeof address === 'string') { + throw new Error('missing local address') + } + endpoints.set(new URL(cells[index]!.url).host, `ws://127.0.0.1:${address.port}`) + } + const connect = (url: string, headers?: Record) => { + const parsed = new URL(url) + const socket = new WebSocket(`${endpoints.get(parsed.host)}${parsed.pathname}`, { headers }) + sockets.add(socket) + return socket + } + let failCorroboration = 0 + let pauseCorroboration = false + let corroborationFailures = 0 + let rejectTargetControls = false + let targetControlFailures = 0 + const executionErrors: unknown[] = [] + let delayedReply: (() => void) | null = null + const received: string[] = [] + const pool = new RelayOriginPool({ + directorUrl: 'https://director.example.test', + relayHostId: hostId, + identity: { userId: identity.userId, profileId: 'profile-1', organizationId: 'org-1' }, + keypair: { ...keypair, publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') }, + appVersion: 'transport-test', + isCurrent: () => true, + onStatus: () => {}, + now: () => clock, + mobileSocketWiring: { + attachTransport: (transport: MobileSocketTransport) => { + transport.onMessage((raw, reply) => { + const value = raw.toString() + received.push(value) + void execution + .execute(value) + .then((output) => { + if (output === 'mutation-1') { + delayedReply = () => reply('mutation-1-ack') + } else { + reply(`host:${output}`) + } + }) + .catch((error) => executionErrors.push(error)) + }) + return () => {} + } + } as never, + createControlSocket: (url, token) => { + if (rejectTargetControls && new URL(url).host === new URL(cells[1]!.url).host) { + targetControlFailures++ + throw new Error('simulated_target_unavailable') + } + const socket = connect(url, { + authorization: `Bearer ${token}`, + ...RELAY_HOST_CAPABILITY_HEADERS + }) + if (process.env.ORCA_RELAY_TRANSPORT_DIAGNOSTICS === '1') { + const cell = new URL(url).host + console.info('transport-control-created', { + cell, + stack: new Error('transport control created').stack + }) + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) + if (['region-restored', 'host-hello-ack', 'drain'].includes(message.type)) { + console.info('transport-control-message', { + cell, + type: message.type, + assignmentEpoch: message.assignmentEpoch, + generation: message.generation + }) + } + }) + socket.on('close', (code) => console.info('transport-control-close', { cell, code })) + } + return socket + }, + createDataSocket: (url) => connect(url), + fetch: (async () => { + if (failCorroboration > 0 || pauseCorroboration) { + failCorroboration = Math.max(0, failCorroboration - 1) + corroborationFailures++ + return Response.json({ error: 'temporary_director_failure' }, { status: 503 }) + } + const assignment = await source.assignments.resolve(identity) + if (!assignment) { + return Response.json({ error: 'assignment_not_found' }, { status: 409 }) + } + return Response.json({ + v: 1, + cellUrl: assignment.cellUrl, + assignmentEpoch: assignment.assignmentEpoch, + lease: 'synthetic-assignment-lease' + }) + }) as typeof fetch + }) + cleanups.push(async () => { + pool.closeNow() + }) + const assignment = await source.assignments.assign(identity, 'us-central1') + await pool.openInitial( + { + v: 1, + cellUrl: assignment.cellUrl, + assignmentEpoch: assignment.assignmentEpoch, + lease: 'synthetic-assignment-lease' + }, + hostId + ) + const attachPhone = async (cellIndex: number, device: string) => { + const invite = await source.store.createInvite(identity, device) + const socket = connect(`${cells[cellIndex]!.url}/v1/connect/${hostId}`) + await once(socket, 'open') + const hello = once(socket, 'message') + socket.send( + JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential: invite.inviteToken }) + ) + const [raw] = await hello + expect(JSON.parse(raw.toString())).toMatchObject({ type: 'relay-hello', ok: true }) + return socket + } + let candidate: (IdleRegionalRehomeRequest & { sourceCellUrl: string }) | undefined + const prepareMove = async () => { + const issued = await source.assignments.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await source.assignments.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 180, 'asia-east2': 40 } + }, + assignment.assignmentEpoch + ) + candidate = (await source.assignments.selectIdleRegionalRehomeCandidates(safety()))[0] + expect(candidate).toBeDefined() + return candidate! + } + const move = async () => { + if (!candidate) { + await prepareMove() + } + const { sourceCellUrl, ...request } = candidate! + const address = endpoints.get(new URL(sourceCellUrl).host)!.replace('ws:', 'http:') + const response = await fetch(`${address}/v1/admin/host-idle-rehome`, { + method: 'POST', + headers: { authorization: 'Bearer test-director-token', 'content-type': 'application/json' }, + body: JSON.stringify({ ...request, cohortPercent: 100, directorSafety: safety() }) + }) + const body = (await response.json()) as { v: number; outcome: string } + expect(response.status, JSON.stringify(body)).toBe(200) + return { outcome: body.outcome } + } + return { + source, + target, + pool, + identity, + database, + cells, + attachPhone, + connectDevice: () => connect(`${cells[0]!.url}/v1/connect/${hostId}`), + move, + prepareMove, + heartbeat, + now: () => clock, + advance: (ms: number) => { + clock += ms + }, + received, + failNextCorroboration: () => { + failCorroboration = 1 + }, + pauseCorroboration: (paused: boolean) => { + pauseCorroboration = paused + }, + corroborationFailures: () => corroborationFailures, + targetControlFailures: () => targetControlFailures, + failTarget: () => { + rejectTargetControls = true + const session = target.sessions.get(identity) + if (session?.socket) { + session.socket.terminate() + } + }, + execution, + executionErrors, + mutations: () => execution.mutations(), + reply: () => { + if (!delayedReply) { + throw new Error('no delayed mutation') + } + delayedReply() + } + } +} + +async function echo(socket: WebSocket, value: string) { + const marker = `${value}:${randomUUID()}` + const response = once(socket, 'message') + socket.send(marker) + const [raw] = await response + expect(raw.toString()).toBe(`host:${marker}`) +} + +describe('idle region correction across real relay and desktop WebSockets', () => { + it('releases the empty source and recovers normally when the target never registers', async () => { + const context = await topology() + await context.prepareMove() + context.failTarget() + expect(await context.move()).toEqual({ outcome: 'committed' }) + await expect.poll(() => context.source.sessions.get(context.identity)).toBeNull() + await expect + .poll(async () => + context.database.query( + `SELECT activity_id FROM relay_assignment_activity_leases + WHERE user_id = ? AND relay_host_id = ? AND cell_id = ?`, + [context.identity.userId, context.identity.relayHostId, context.cells[0]!.id] + ) + ) + .toEqual([]) + await expect.poll(context.targetControlFailures).toBeGreaterThan(0) + context.advance(15 * 60_000 + 1) + await context.heartbeat() + expect(await context.source.assignments.abortExpiredEvacuations()).toBe(1) + expect(await context.source.assignments.resolve(context.identity)).toMatchObject({ + cellId: context.cells[0]!.id, + assignmentEpoch: 3 + }) + await expect + .poll(() => context.pool.activeAssignment?.cellUrl, { timeout: 15_000 }) + .toBe(context.cells[0]!.url) + await expect + .poll(() => context.source.sessions.get(context.identity)?.state, { timeout: 15_000 }) + .toBe('active') + const returning = await context.attachPhone(0, 'phone-after-target-failure') + await echo(returning, 'after-target-failure') + expect(await context.mutations()).toBe(0) + expect(context.executionErrors).toEqual([]) + }, 30_000) + + it('rejects an arrival during cutover and restores admissions after a definite failed commit', async () => { + const context = await topology() + await context.prepareMove() + const original = context.source.sessions.get(context.identity)! + let entered!: () => void + let release!: () => void + const committing = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(context.source.assignments, 'commitIdleRegionalRehome').mockImplementationOnce( + async () => { + entered() + await gate + throw new Error('simulated_database_unavailable_before_commit') + } + ) + const move = context.move() + await committing + try { + const invite = await context.source.store.createInvite(context.identity, 'racing-phone') + const arriving = context.connectDevice() + const rejected = once(arriving, 'close') + await once(arriving, 'open') + arriving.send( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: invite.inviteToken + }) + ) + expect((await rejected)[0]).toBe(4409) + expect(context.source.sessions.get(context.identity)).toBe(original) + } finally { + release() + await move + } + expect(await move).toEqual({ outcome: 'deferred' }) + expect(context.source.sessions.get(context.identity)).toBe(original) + const returning = await context.attachPhone(0, 'retrying-phone') + await echo(returning, 'after-definite-abort') + expect(await context.mutations()).toBe(0) + expect(context.executionErrors).toEqual([]) + }, 30_000) + + it('defers for either connected device, then moves after both disconnect without replaying work', async () => { + const context = await topology() + const phone = await context.attachPhone(0, 'phone') + const tablet = await context.attachPhone(0, 'tablet') + const sourceSession = context.source.sessions.get(context.identity)! + await echo(phone, 'before-cutover') + phone.send('mutation-1') + await expect.poll(context.mutations).toBe(1) + expect(await context.move()).toEqual({ outcome: 'busy' }) + expect(context.source.sessions.get(context.identity)).toBe(sourceSession) + expect((await context.source.assignments.resolve(context.identity))?.cellId).toBe( + context.cells[0]!.id + ) + const acknowledged = once(phone, 'message') + context.reply() + expect((await acknowledged)[0].toString()).toBe('mutation-1-ack') + const phoneClosed = once(phone, 'close') + phone.close() + await phoneClosed + await expect.poll(() => sourceSession.activeSplices.size).toBe(1) + expect(await context.move()).toEqual({ outcome: 'busy' }) + await echo(tablet, 'quiet-tablet-still-connected') + const tabletClosed = once(tablet, 'close') + tablet.close() + await tabletClosed + await expect.poll(() => sourceSession.activeSplices.size).toBe(0) + expect(await context.move()).toEqual({ outcome: 'committed' }) + await expect + .poll(() => context.pool.activeAssignment?.cellUrl, { timeout: 15_000 }) + .toBe(context.cells[1]!.url) + await expect.poll(() => context.source.sessions.get(context.identity)).toBeNull() + const returning = await context.attachPhone(1, 'returning-phone') + await echo(returning, 'after-idle-cutover') + expect(await context.mutations()).toBe(1) + expect(context.executionErrors).toEqual([]) + expect(context.execution.sequence()).toBe(4) + }, 30_000) +}) diff --git a/tests/e2e/resource-manager-folder-labels.spec.ts b/tests/e2e/resource-manager-folder-labels.spec.ts new file mode 100644 index 00000000000..fb4f37a0268 --- /dev/null +++ b/tests/e2e/resource-manager-folder-labels.spec.ts @@ -0,0 +1,147 @@ +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { createRestartSession } from './helpers/orca-restart' + +test.use({ seedTestRepo: false }) + +for (const theme of ['dark', 'light'] as const) { + test(`Resource Manager names folder workspaces and their groups (${theme})`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const root = mkdtempSync(join(tmpdir(), 'orca-resource-folders-')) + registerPostElectronShutdownCleanup(async () => rmSync(root, { recursive: true, force: true })) + const folders = ['Release notes', 'Customer research'].map((name) => { + const folderPath = join(root, name) + mkdirSync(folderPath) + return { name, folderPath } + }) + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 1200, height: 900 }) + await orcaPage.evaluate( + async ({ theme, folders }) => { + const state = window.__store!.getState() + await state.updateSettingsOrThrow({ theme }) + for (const [index, folder] of folders.entries()) { + const group = await window.api.projectGroups.create({ + name: index === 0 ? 'Documentation' : 'Product', + parentPath: folder.folderPath, + createdFrom: 'folder-scan' + }) + await state.fetchProjectGroups() + if (!group) { + throw new Error('Could not create project group') + } + const workspace = await state.createFolderWorkspace({ + projectGroupId: group.id, + ...folder + }) + if (!workspace) { + throw new Error('Could not create folder workspace') + } + await window.api.pty.spawn({ + cols: 80, + rows: 24, + cwd: workspace.folderPath, + worktreeId: `folder:${workspace.id}`, + initiallyHidden: true + }) + } + await window.__store!.getState().fetchMemorySnapshot() + }, + { theme, folders } + ) + + await orcaPage.getByRole('button', { name: /^Resource Manager,/ }).click() + const popover = orcaPage.getByRole('dialog') + await expect(popover.getByText('Resource Manager', { exact: true })).toBeVisible() + await expect(popover.getByRole('button', { name: /^Resume workspace/ })).toHaveCount(2) + const screenshot = testInfo.outputPath(`resource-manager-folders-${theme}.png`) + await orcaPage.screenshot({ path: screenshot, animations: 'disabled' }) + await testInfo.attach(`resource-manager-folders-${theme}`, { + path: screenshot, + contentType: 'image/png' + }) + + await expect(popover.getByText('Documentation', { exact: true })).toBeVisible() + await expect(popover.getByText('Product', { exact: true })).toBeVisible() + for (const { name } of folders) { + await expect( + popover.getByRole('button', { name: `Resume workspace ${name}`, exact: true }) + ).toBeVisible() + } + await expect(popover).not.toContainText('folder:') + }) +} + +test('names a folder terminal recovered from the daemon after restart without an open tab', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + const root = mkdtempSync(join(tmpdir(), 'orca-recovered-folder-')) + const session = createRestartSession(testInfo) + let launched: Awaited> | undefined + try { + launched = await session.launch() + await waitForSessionReady(launched.page) + await launched.page.evaluate(async (folderPath) => { + const state = window.__store!.getState() + await state.updateSettingsOrThrow({ theme: 'dark' }) + const group = await window.api.projectGroups.create({ + name: 'Documentation', + parentPath: folderPath, + createdFrom: 'folder-scan' + }) + await state.fetchProjectGroups() + if (!group) { + throw new Error('Could not create project group') + } + const folder = await state.createFolderWorkspace({ + projectGroupId: group.id, + name: 'Recovered notes', + folderPath + }) + if (!folder) { + throw new Error('Could not create folder workspace') + } + await window.api.pty.spawn({ + cols: 80, + rows: 24, + cwd: folderPath, + worktreeId: `folder:${folder.id}`, + initiallyHidden: true + }) + }, root) + await session.close(launched.app) + launched = await session.launch() + const page = launched.page + await waitForSessionReady(page) + await page.setViewportSize({ width: 1200, height: 900 }) + await page.evaluate(() => window.__store!.getState().fetchMemorySnapshot()) + await page.getByRole('button', { name: /^Resource Manager,/ }).click() + const popover = page.getByRole('dialog') + await expect(popover.getByText('Resource Manager', { exact: true })).toBeVisible() + try { + await expect( + popover.getByRole('button', { name: 'Resume workspace Recovered notes', exact: true }) + ).toBeVisible() + await expect(popover.getByText(/^pid \d+$/)).toBeVisible() + await expect(popover).not.toContainText('Unattributed') + await expect(popover).not.toContainText('folder:') + } finally { + const screenshot = testInfo.outputPath('resource-manager-recovered-folder.png') + await page.screenshot({ path: screenshot, animations: 'disabled' }) + await testInfo.attach('resource-manager-recovered-folder', { + path: screenshot, + contentType: 'image/png' + }) + } + } finally { + if (launched) { + await session.close(launched.app) + } + await session.dispose() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/tests/e2e/runtime-host-status-recovery.spec.ts b/tests/e2e/runtime-host-status-recovery.spec.ts new file mode 100644 index 00000000000..09707606da1 --- /dev/null +++ b/tests/e2e/runtime-host-status-recovery.spec.ts @@ -0,0 +1,221 @@ +import { createConnection, createServer, type Socket, type AddressInfo } from 'node:net' +import type { Page } from '@stablyai/playwright-test' +import { decodePairingOffer, encodePairingOffer } from '../../src/shared/pairing' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + launchPairedWebClient, + type RuntimeDesktopPairingOffer +} from './helpers/paired-electron-client' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' + +async function interruptibleHost(offer: RuntimeDesktopPairingOffer) { + const pairing = decodePairingOffer(offer.pairingUrl) + const endpoint = new URL(pairing.endpoint) + const sockets = new Set() + let online = true + const server = createServer((client) => { + if (!online) { + client.destroy() + return + } + const host = createConnection({ host: endpoint.hostname, port: Number(endpoint.port) }) + for (const socket of [client, host]) { + sockets.add(socket) + socket.on('error', () => { + client.destroy() + host.destroy() + }) + socket.on('close', () => { + sockets.delete(socket) + client.destroy() + host.destroy() + }) + } + client.pipe(host).pipe(client) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + const pairingUrl = encodePairingOffer({ ...pairing, endpoint: `ws://127.0.0.1:${address.port}` }) + let webClientUrl: string | undefined + if (offer.webClientUrl) { + const url = new URL(offer.webClientUrl) + url.search = '' + url.hash = new URLSearchParams({ pairing: pairingUrl }).toString() + webClientUrl = url.href + } + return { + offer: { pairingUrl, webClientUrl }, + setOnline(value: boolean) { + online = value + if (!online) { + sockets.forEach((socket) => socket.destroy()) + } + }, + async close() { + sockets.forEach((socket) => socket.destroy()) + await new Promise((resolve) => server.close(() => resolve())) + } + } +} + +async function statusEvidence(page: Page, environmentId?: string) { + return page.evaluate((id) => { + const entries = window.__store?.getState().runtimeStatusByEnvironmentId + const entry = id ? entries?.get(id) : entries?.values().next().value + return entry?.snapshot + ? { + verification: entry.snapshot.verification, + transport: entry.snapshot.transport, + runtimeId: entry.status?.runtimeId, + sequence: entry.snapshot.sequence + } + : null + }, environmentId) +} + +async function expectWorkspaceHostAppearance( + page: Page, + disconnected: boolean, + hostLabel?: string +) { + const cards = page.locator('[data-worktree-card-surface="true"]') + const card = ( + hostLabel ? cards.filter({ has: page.getByText(hostLabel, { exact: true }) }) : cards + ).first() + await expect(card).toBeVisible() + await expect(card).toHaveCSS('opacity', disconnected ? '0.6' : '1') + const icon = card.locator(disconnected ? 'svg.lucide-server-off' : 'svg.lucide-server').first() + await expect(icon).toBeVisible() + await expect( + card.locator(disconnected ? 'svg.lucide-server' : 'svg.lucide-server-off') + ).toHaveCount(0) + await expect(icon).toHaveClass(disconnected ? /text-destructive/ : /text-muted-foreground/) + await icon.hover() + await expect( + page.getByRole('tooltip', { name: disconnected ? /disconnected/i : /Project on/ }) + ).toBeVisible() + await page.mouse.move(900, 600) +} + +for (const topology of ['desktop', 'headless'] as const) { + test(`connection-owned status recovers with a ${topology} host and independent viewers`, async ({ + electronApp, + orcaPage: page, + testRepoPath + }, testInfo) => { + test.setTimeout(180_000) + let headless: Awaited> | null = null + let proxy: Awaited> | undefined + let client: Awaited> | undefined + let browser: Awaited> | undefined + try { + headless = + topology === 'headless' + ? await launchHeadlessPairedRuntimeHost({ pinnedServePort: true }) + : null + const offer = headless?.offer ?? (await createRuntimeDesktopPairingOffer(page)) + await (headless + ? headless.client.call('repo.add', { path: testRepoPath }) + : page.evaluate(async (path) => { + await window.api.repos.add({ path }) + await window.__store?.getState().fetchRepos() + }, testRepoPath)) + proxy = await interruptibleHost(offer) + client = await launchPairedElectronClient(offer, testInfo, 'Direct host') + proxy.setOnline(false) + const offlineId = await client.page.evaluate(async (pairingCode) => { + const { environment } = await window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Recovering host', + pairingCode + }) + const store = window.__store!.getState() + store.setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) + await store.refreshRuntimeEnvironmentStatus(environment.id, 1_000) + return environment.id + }, proxy.offer.pairingUrl) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ verification: 'unavailable' }) + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ + verification: 'verified' + }) + proxy.setOnline(true) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + const initial = await statusEvidence(client!.page, offlineId) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expect(client.page.getByText('Recovering host', { exact: true }).first()).toBeVisible() + await client.page.screenshot({ path: testInfo.outputPath(`${topology}-recovered.png`) }) + browser = await launchPairedWebClient(electronApp, proxy.offer) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expectWorkspaceHostAppearance(browser.page, false) + proxy.setOnline(false) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ transport: 'disconnected' }) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ transport: 'disconnected' }) + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ + verification: 'verified', + transport: 'ready' + }) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(client.page, false, 'Direct host') + await expectWorkspaceHostAppearance(browser.page, false) + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-reconnecting.png`) + }) + await browser.page.screenshot({ + path: testInfo.outputPath(`${topology}-browser-reconnecting.png`) + }) + proxy.setOnline(true) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + expect((await statusEvidence(client!.page, offlineId))!.sequence).toBeGreaterThan( + initial!.sequence + ) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(browser.page, false) + await browser.page.screenshot({ + path: testInfo.outputPath(`${topology}-browser-recovered.png`) + }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, offlineId) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ verification: 'blocked', transport: 'disconnected' }) + await expectWorkspaceHostAppearance(client.page, true, 'Recovering host') + await expectWorkspaceHostAppearance(client.page, false, 'Direct host') + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-disconnected.png`) + }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.connect({ selector }) + }, offlineId) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(browser.page, false) + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-restored.png`) + }) + } finally { + await browser?.dispose() + await client?.dispose() + await proxy?.close() + await headless?.dispose() + } + }) +} diff --git a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts index 09cfa6d6d7c..9a5528938be 100644 --- a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts +++ b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts @@ -16,6 +16,7 @@ import { resetWebSessionTabsSnapshotFreshnessForTests, type WebSessionTabsSyncState } from '../../src/renderer/src/runtime/web-session-tabs-sync' +import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture' vi.mock('../../src/renderer/src/store', () => ({ useAppStore: { @@ -689,7 +690,9 @@ describe('real PTY decorative session-tabs fanout', () => { }) it('renews retained hook status without resetting its state start', () => { - const runtime = new OrcaRuntimeService() + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps) + const uninstallStatusRepublish = statusWiring.attach(runtime) const ptyId = seedWorktree(runtime, 0) const internals = runtime as unknown as RuntimeInternals const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] @@ -769,5 +772,7 @@ describe('real PTY decorative session-tabs fanout', () => { true ) unsubscribe() + uninstallStatusRepublish() + statusWiring.statusStore.stop() }) }) diff --git a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts index 2c60fe19082..d15a6513cee 100644 --- a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts +++ b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot @@ -27,7 +28,9 @@ type Harness = { } function createHarness(): Harness { - const runtime = new OrcaRuntimeService() + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps) + const uninstallStatusRepublish = statusWiring.attach(runtime) runtime.registerPty(PTY_ID, WORKTREE_ID) const tab: TerminalTab = { type: 'terminal', @@ -58,7 +61,17 @@ function createHarness(): Harness { const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { publications.push(structuredClone(snapshot)) }) - return { internals, publications, runtime, tab, unsubscribe } + return { + internals, + publications, + runtime, + tab, + unsubscribe: () => { + unsubscribe() + uninstallStatusRepublish() + statusWiring.statusStore.stop() + } + } } function setRichStatus( diff --git a/tests/e2e/slept-workspace-remount-wake.spec.ts b/tests/e2e/slept-workspace-remount-wake.spec.ts new file mode 100644 index 00000000000..54109a6b532 --- /dev/null +++ b/tests/e2e/slept-workspace-remount-wake.spec.ts @@ -0,0 +1,87 @@ +/** + * GH #10205: a manual sleep keeps the tab's session id as a wake hint, so a later + * remount of its still-mounted pane reattaches that dead id and the daemon spawns + * a fresh shell. Production parking timings are deliberate: a shrunk park delay + * unmounts the slept panes and hides the behavior. + */ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { getAllWorktreeIds, waitForSessionReady } from './helpers/store' +import { + activateWorkspaceByClick, + giveWorkspaceALivePty, + readConnectDiagnostics, + readHostLiveTerminalCount, + readWorkspaceSample, + sleepWorkspaceViaSidebar +} from './helpers/slept-workspace-probe' + +const OBSERVATION_MS = 8_000 +const SAMPLE_INTERVAL_MS = 200 + +async function assertStaysCold(page: Page, worktreeId: string): Promise { + let peakLivePty = 0 + let peakTabs = 0 + const deadline = Date.now() + OBSERVATION_MS + while (Date.now() < deadline) { + const sample = await readWorkspaceSample(page, worktreeId) + peakLivePty = Math.max(peakLivePty, sample.livePtyCount) + peakTabs = Math.max(peakTabs, sample.tabCount) + await page.waitForTimeout(SAMPLE_INTERVAL_MS) + } + const hostLive = await readHostLiveTerminalCount(page, worktreeId) + const diag = await readConnectDiagnostics(page, worktreeId) + console.error(`[#10205] ${JSON.stringify({ peakLivePty, peakTabs, hostLive, diag })}`) + expect(peakLivePty, 'slept workspace grew a live PTY').toBe(0) + expect(peakTabs, 'slept workspace grew a tab').toBe(1) + expect(hostLive, 'host created a session for the slept workspace').toBe(0) + // Why: proves the gate held rather than the pane having quietly unmounted. + expect(diag.at(-1), 'remounted pane did not wait for the wake').toContain('WAIT FOR WAKE') +} + +test('remounting a slept hidden pane does not respawn its PTY', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const [slept, other] = await getAllWorktreeIds(orcaPage) + expect(other, 'seeded repo must expose two worktrees').toBeTruthy() + await giveWorkspaceALivePty(orcaPage, slept) + await giveWorkspaceALivePty(orcaPage, other) + await activateWorkspaceByClick(orcaPage, slept) + expect((await readWorkspaceSample(orcaPage, slept)).livePtyCount).toBeGreaterThan(0) + + await sleepWorkspaceViaSidebar(orcaPage, slept) + await expect + .poll(async () => (await readWorkspaceSample(orcaPage, slept)).livePtyCount, { + timeout: 20_000, + message: 'sleep did not release the workspace PTYs' + }) + .toBe(0) + await activateWorkspaceByClick(orcaPage, other) + + const sample = await readWorkspaceSample(orcaPage, slept) + const sleptTabId = sample.tabIds[0] + expect(sleptTabId, 'slept workspace must retain a tab').toBeTruthy() + // Presence preconditions: the pane is still mounted and still carries its wake hint, + // otherwise a remount has nothing to reattach and the oracle passes vacuously. + expect(sample.mountedTabIds, 'slept pane was parked before the remount').toContain(sleptTabId) + expect(sample.tabPtyHints[0], 'sleep must keep the session id as a wake hint').toBeTruthy() + + const remounted = await orcaPage.evaluate( + (tabId) => window.__store?.getState().remountTerminalTabForRecovery(tabId).remounted ?? false, + sleptTabId + ) + expect(remounted, 'remountTerminalTabForRecovery did not find the slept tab').toBe(true) + await assertStaysCold(orcaPage, slept) + + // Non-vacuity: a deliberate click must still wake it, and exactly once — the + // waiting pane and its remounted successor must not both reattach. + await activateWorkspaceByClick(orcaPage, slept) + await expect + .poll(async () => (await readWorkspaceSample(orcaPage, slept)).livePtyCount, { + timeout: 40_000, + message: 'the slept workspace never wakes even on deliberate activation' + }) + .toBeGreaterThan(0) + await orcaPage.waitForTimeout(3_000) + expect((await readWorkspaceSample(orcaPage, slept)).livePtyCount).toBe(1) + expect(await readHostLiveTerminalCount(orcaPage, slept)).toBe(1) +}) diff --git a/tests/e2e/tab-drag-blur-cancel.spec.ts b/tests/e2e/tab-drag-blur-cancel.spec.ts new file mode 100644 index 00000000000..adf16c711e1 --- /dev/null +++ b/tests/e2e/tab-drag-blur-cancel.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from './helpers/orca-app' + +for (const theme of ['dark', 'light'] as const) { + test(`tab drag stays cancelled after blur and a later drag still splits (${theme})`, async ({ + orcaPage + }, testInfo) => { + await orcaPage.setViewportSize({ width: 1200, height: 900 }) + await orcaPage.evaluate(async (theme) => { + const state = window.__store!.getState() + await state.updateSettingsOrThrow({ theme }) + const worktreeId = state.activeWorktreeId! + const tabs = state.tabsByWorktree[worktreeId] ?? [] + for (let index = tabs.length; index < 2; index++) { + state.createTab(worktreeId) + } + }, theme) + + const tabs = orcaPage.locator('[data-testid="sortable-tab"]:visible') + const panels = orcaPage.locator('[data-tab-group-body-id]:visible') + const preview = orcaPage.getByText('New split', { exact: true }) + await expect(tabs).toHaveCount(2) + await expect(panels).toHaveCount(1) + const panel = (await panels.boundingBox())! + const target = { x: panel.x + panel.width - 30, y: panel.y + panel.height / 2 } + const startDrag = async (): Promise => { + const tab = (await tabs.first().boundingBox())! + await orcaPage.mouse.move(tab.x + tab.width / 2, tab.y + tab.height / 2) + await orcaPage.mouse.down() + await orcaPage.mouse.move(target.x, target.y, { steps: 12 }) + await expect(preview).toBeVisible() + } + + await startDrag() + // Exercise the window event without changing native focus on the developer's desktop. + await orcaPage.evaluate(async () => { + window.dispatchEvent(new Event('blur')) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + }) + await expect(preview).toHaveCount(0) + await orcaPage.mouse.move(target.x - 10, target.y + 10, { steps: 3 }) + + const screenshot = testInfo.outputPath(`tab-drag-after-blur-${theme}.png`) + await orcaPage.screenshot({ path: screenshot, animations: 'disabled' }) + await testInfo.attach(`tab-drag-after-blur-${theme}`, { + path: screenshot, + contentType: 'image/png' + }) + await expect(preview).toHaveCount(0) + await orcaPage.mouse.up() + await expect(panels).toHaveCount(1) + + await startDrag() + await orcaPage.mouse.up() + await expect(preview).toHaveCount(0) + await expect(panels).toHaveCount(2) + }) +} diff --git a/tests/e2e/tasks-page.spec.ts b/tests/e2e/tasks-page.spec.ts index b8f57a1996f..962f86de0c5 100644 --- a/tests/e2e/tasks-page.spec.ts +++ b/tests/e2e/tasks-page.spec.ts @@ -13,6 +13,9 @@ import { GITHUB_TASK_SEARCH_IDLE_MS } from '../../src/renderer/src/components/us // on a loaded runner, so one slow keystroke committed a prefix and failed the assertion. const TASK_SEARCH_TYPING_DELAY_MS = Math.round(GITHUB_TASK_SEARCH_IDLE_MS / 6) const TASK_SEARCH_SETTLE_MS = GITHUB_TASK_SEARCH_IDLE_MS + 50 +// Why derived: the probe must outlast the idle window plus a React commit and two +// store round trips; a flat 2s left ~1.2s of slack on a single-worker runner. +const TASK_SEARCH_PROBE_TIMEOUT_MS = GITHUB_TASK_SEARCH_IDLE_MS * 6 type RenderedTaskSource = { source: string @@ -411,7 +414,9 @@ test.describe('Tasks page', () => { await input.fill('') await expect - .poll(async () => readTaskSearchRequestProbe(orcaPage), { timeout: 2_000 }) + .poll(async () => readTaskSearchRequestProbe(orcaPage), { + timeout: TASK_SEARCH_PROBE_TIMEOUT_MS + }) .toEqual({ countQueries: ['is:issue is:open'], fetchQueries: ['is:issue is:open'] }) await resetTaskSearchRequestProbe(orcaPage) @@ -422,7 +427,9 @@ test.describe('Tasks page', () => { // The contract is that no prefix of the typed query is ever queried, not that the // probe is empty at one instant: exactly one request per surface, for the final value. await expect - .poll(async () => readTaskSearchRequestProbe(orcaPage), { timeout: 2_000 }) + .poll(async () => readTaskSearchRequestProbe(orcaPage), { + timeout: TASK_SEARCH_PROBE_TIMEOUT_MS + }) .toEqual({ countQueries: ['is:issue rate'], fetchQueries: ['is:issue rate'] }) await resetTaskSearchRequestProbe(orcaPage) @@ -430,7 +437,9 @@ test.describe('Tasks page', () => { await input.press('Enter') await expect - .poll(async () => readTaskSearchRequestProbe(orcaPage), { timeout: 2_000 }) + .poll(async () => readTaskSearchRequestProbe(orcaPage), { + timeout: TASK_SEARCH_PROBE_TIMEOUT_MS + }) .toEqual({ countQueries: ['is:issue ratex'], fetchQueries: ['is:issue ratex'] }) await orcaPage.waitForTimeout(TASK_SEARCH_SETTLE_MS) expect(await readTaskSearchRequestProbe(orcaPage)).toEqual({ diff --git a/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts index c734301d33f..fd4915d0ce3 100644 --- a/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts +++ b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts @@ -160,14 +160,16 @@ process.stdout.write(${JSON.stringify(`${marker}\n`)}) observe() }, blocked.tabId) - const remounted = await orcaPage.evaluate((tabId) => { + // No request argument: an external lifecycle remount, which skips the + // recovery ledger entirely and so reports generation 0. + const remountResult = await orcaPage.evaluate((tabId) => { const state = window.__store?.getState() if (!state) { throw new Error('Renderer store unavailable') } return state.remountTerminalTabForRecovery(tabId) }, blocked.tabId) - expect(remounted).toBe(true) + expect(remountResult).toMatchObject({ remounted: true }) // Keep the original pre-spawn attempt gated until React has committed the // successor pane. Releasing earlier lets a loaded CI renderer finish the diff --git a/tests/e2e/update-status-error-details.spec.ts b/tests/e2e/update-status-error-details.spec.ts new file mode 100644 index 00000000000..a007a10de13 --- /dev/null +++ b/tests/e2e/update-status-error-details.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const CHECK_ERROR = 'E2E update check failed: connection refused' + +test.use({ seedTestRepo: false }) + +for (const theme of ['dark', 'light'] as const) { + test(`automatic update failure opens details from the status bar (${theme})`, async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 1200, height: 800 }) + await orcaPage.evaluate(async (theme) => { + const state = window.__store!.getState() + await state.updateSettingsOrThrow({ theme }) + state.setUpdateStatus({ state: 'checking', userInitiated: false }) + }, theme) + await expect(orcaPage.locator('html')).toHaveClass(theme === 'dark' ? /\bdark\b/ : /\blight\b/) + await orcaPage.evaluate((message) => { + window.__store!.getState().setUpdateStatus({ + state: 'error', + message, + userInitiated: false + }) + }, CHECK_ERROR) + + const statusButton = orcaPage.getByRole('button', { + name: 'Update failed. Click to expand.', + exact: true + }) + const card = orcaPage.getByRole('complementary', { name: 'Update error', exact: true }) + await expect(statusButton).toBeVisible() + await expect(card).toBeHidden() + + await statusButton.click() + // Capture before the assertion so the broken build provides the same visual evidence. + const statusClickScreenshot = testInfo.outputPath( + `update-error-after-status-click-${theme}.png` + ) + await orcaPage.screenshot({ path: statusClickScreenshot, animations: 'disabled' }) + await testInfo.attach(`update-error-after-status-click-${theme}`, { + path: statusClickScreenshot, + contentType: 'image/png' + }) + await expect(card).toBeVisible() + await expect(statusButton).toHaveAttribute('aria-expanded', 'true') + await expect(card.getByRole('heading', { name: 'Update Check Failed' })).toBeVisible() + await expect(card.getByRole('button', { name: 'Re-check', exact: true })).toBeVisible() + + await card.getByRole('button', { name: 'Show details', exact: true }).click() + await expect(card.getByText(CHECK_ERROR, { exact: true })).toBeVisible() + const detailsScreenshot = testInfo.outputPath(`update-error-expanded-details-${theme}.png`) + await orcaPage.screenshot({ path: detailsScreenshot, animations: 'disabled' }) + await testInfo.attach(`update-error-expanded-details-${theme}`, { + path: detailsScreenshot, + contentType: 'image/png' + }) + + await card.getByRole('button', { name: 'Minimize to status bar', exact: true }).click() + await expect(card).toBeHidden() + await expect(statusButton).toHaveAttribute('aria-expanded', 'false') + await statusButton.click() + await expect(card).toBeVisible() + await expect(statusButton).toHaveAttribute('aria-expanded', 'true') + }) +} diff --git a/tests/e2e/worktree-switch-first-paint.spec.ts b/tests/e2e/worktree-switch-first-paint.spec.ts new file mode 100644 index 00000000000..307e179faf6 --- /dev/null +++ b/tests/e2e/worktree-switch-first-paint.spec.ts @@ -0,0 +1,486 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { loadWorktreesUntilPathsPresent } from './helpers/worktree-registration' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +/** + * Worktree-switch first-paint budget. + * + * Why this exists: worktree-switch-responsiveness.spec.ts proves the click task + * stays short, and the reveal-convergence spec proves the buffer eventually + * matches. Neither covers the symptom users report — the revealed terminal is + * BLANK for a beat after the switch. This measures the phase that owns that + * beat: switch click -> revealed pane has painted its restored content. + * + * The scenario is the one that dominates at many-worktree scale: a switch to a + * worktree whose tabs are in the persisted session but have never been mounted + * in this renderer. Hot-retain only keeps 4 worktrees warm, so with hundreds of + * worktrees essentially every switch is this one. Reloading the renderer between + * rounds reproduces it exactly, at production parking timings. + */ + +// Why 3: the field profile that motivated this budget has 449 worktrees whose +// median tab count is 2-3, so a 3-tab worktree is the switch users actually pay for. +const TABS_PER_WORKTREE = Number(process.env.ORCA_SWITCH_TABS ?? '3') +const SCROLLBACK_LINES = 1_500 +// Budget: a switch has to look instant. Anything over this reads as a stall. +const FIRST_PAINT_BUDGET_MS = Number(process.env.ORCA_SWITCH_BUDGET_MS ?? '250') +// Why repeat: a single cold reveal on a loaded dev machine swings by tens of ms, +// which is the same order as the effect under test. +const SWITCH_SAMPLE_COUNT = Number(process.env.ORCA_SWITCH_ROUNDS ?? '5') + +type SwitchSample = { + activationMs: number | null + paneMountedMs: number | null + contentRestoredMs: number | null + maxFrameGapMs: number + longTaskTotalMs: number + worstLongTaskMs: number + mountedAtActivation: number + settledPaneManagers: number + settledPanes: number + settledWebglContexts: number +} + +type SwitchPaintProbe = { + t0: number + activationMs: number | null + paneMountedMs: number | null + contentRestoredMs: number | null + frames: number[] + longTasks: number[] + mountedAtActivation: number + stop: () => void +} + +declare global { + var __switchPaintProbe: SwitchPaintProbe | undefined +} + +async function ensureTabs(page: Page, worktreeId: string, marker: string): Promise { + await switchToWorktree(page, worktreeId) + await ensureTerminalVisible(page) + const tabIds: string[] = [] + for (let index = 0; index < TABS_PER_WORKTREE; index += 1) { + const tabId = await page.evaluate( + ({ id, wanted }) => { + const state = window.__store!.getState() + const existing = state.tabsByWorktree[id] ?? [] + const reuse = existing[wanted] + const tab = reuse ?? state.createTab(id, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { id: worktreeId, wanted: index } + ) + await waitForActiveTerminalManager(page, 30_000) + const ptyId = await waitForActivePanePtyId(page, 30_000) + const label = `${marker}_T${index}` + await execInTerminal( + page, + ptyId, + `for i in $(seq 1 ${SCROLLBACK_LINES}); do echo "${label}_$i ${'y'.repeat(48)}"; done; echo ${label}_READY` + ) + await waitForTerminalOutput(page, `${label}_READY`, 60_000) + tabIds.push(tabId) + } + return tabIds +} + +async function waitForUnmountedTabs(page: Page, tabIds: readonly string[]): Promise { + return expect + .poll( + () => + page.evaluate((ids) => ids.every((id) => window.__paneManagers?.has(id) !== true), tabIds), + { timeout: 20_000, message: 'switch target still had mounted panes' } + ) + .toBe(true) + .then( + () => true, + () => false + ) +} + +/** Tabs with a mounted pane, once the post-reveal warm-up has settled. */ +async function waitForMountedTabs(page: Page, tabIds: readonly string[]): Promise { + const read = () => + page.evaluate( + (ids) => ids.filter((id) => window.__paneManagers?.has(id) === true).sort(), + [...tabIds] + ) + await expect + .poll(async () => (await read()).length, { + timeout: 20_000, + message: 'activation-deferred tabs never mounted after the reveal' + }) + .toBe(tabIds.length) + .catch(() => undefined) + return read() +} + +async function measureSwitch( + page: Page, + targetWorktreeId: string, + targetTabIds: readonly string[] +): Promise { + await page.evaluate( + ({ worktreeId, tabIds }) => { + const probe = { + t0: performance.now(), + activationMs: null as number | null, + paneMountedMs: null as number | null, + contentRestoredMs: null as number | null, + frames: [] as number[], + longTasks: [] as number[], + mountedAtActivation: 0, + stop: () => {} + } + globalThis.__switchPaintProbe = probe + let observer: PerformanceObserver | null = null + try { + observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + probe.longTasks.push(entry.duration) + } + }) + observer.observe({ entryTypes: ['longtask'] }) + } catch { + /* longtask unsupported */ + } + let running = true + const visibleTabId = () => { + const state = window.__store!.getState() + return state.activeWorktreeId === worktreeId && state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[worktreeId] ?? null) + } + const tick = () => { + if (!running) { + return + } + const now = performance.now() - probe.t0 + probe.frames.push(now) + const state = window.__store!.getState() + if (probe.activationMs === null && state.activeWorktreeId === worktreeId) { + probe.activationMs = now + // Why here and not at paint: this is the switch's own frame, before any + // idle admission can run, so it measures what the SWITCH mounted. + probe.mountedAtActivation = tabIds.filter( + (id) => window.__paneManagers?.has(id) === true + ).length + } + const tabId = visibleTabId() + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (probe.paneMountedMs === null && pane?.container?.isConnected) { + probe.paneMountedMs = now + } + if (probe.contentRestoredMs === null && pane) { + // Restored = the revealed viewport carries real text rather than an + // empty grid. Read on a frame callback, so this is the frame the + // content became renderable — one frame ahead of the pixels, and not + // a pixel assertion. Both arms are measured identically. + const buffer = pane.terminal.buffer.active + let filledRows = 0 + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + if (line && line.translateToString(true).trim().length > 0) { + filledRows += 1 + } + } + if (filledRows >= Math.min(5, pane.terminal.rows)) { + probe.contentRestoredMs = now + } + } + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + probe.stop = () => { + running = false + try { + observer?.disconnect() + } catch { + /* ignore */ + } + } + window.__store!.getState().setActiveWorktree(worktreeId) + }, + { worktreeId: targetWorktreeId, tabIds: [...targetTabIds] } + ) + + // Why poll rather than sample a fixed window: the measurement is "how long did + // the restore take", so the harness must outlast the slowest runner rather than + // give up at a deadline and report the reveal as never restoring. + await expect + .poll(() => page.evaluate(() => globalThis.__switchPaintProbe?.contentRestoredMs ?? null), { + timeout: 30_000, + message: 'revealed terminal never restored its content' + }) + .not.toBeNull() + // Let the idle admission drain so the settled-resource readings are steady. + await page.waitForTimeout(2_000) + + return page.evaluate(() => { + const probe = globalThis.__switchPaintProbe! + probe.stop() + let maxGap = 0 + let previous = 0 + for (const frame of probe.frames) { + maxGap = Math.max(maxGap, frame - previous) + previous = frame + } + let settledPanes = 0 + let settledWebglContexts = 0 + const managers = window.__paneManagers + for (const manager of managers?.values() ?? []) { + settledPanes += (manager.getPanes?.() ?? []).length + // Why diagnostics and not `pane.webglAddon`: getPanes() hands back a public + // projection that has no webglAddon field, so reading it is always falsy. + const diagnostics = + ( + manager as { getRenderingDiagnostics?: () => { hasWebgl?: boolean }[] } + ).getRenderingDiagnostics?.() ?? [] + settledWebglContexts += diagnostics.filter((entry) => entry.hasWebgl === true).length + } + return { + settledPaneManagers: managers?.size ?? 0, + settledPanes, + settledWebglContexts, + activationMs: probe.activationMs, + paneMountedMs: probe.paneMountedMs, + contentRestoredMs: probe.contentRestoredMs, + maxFrameGapMs: +maxGap.toFixed(1), + longTaskTotalMs: +probe.longTasks.reduce((total, value) => total + value, 0).toFixed(1), + worstLongTaskMs: +probe.longTasks + .reduce((worst, value) => Math.max(worst, value), 0) + .toFixed(1), + mountedAtActivation: probe.mountedAtActivation + } + }) +} + +function report(label: string, sample: SwitchSample): string { + return [ + `${label}:`, + ` activation ${sample.activationMs?.toFixed(1) ?? 'n/a'}ms`, + ` pane mounted ${sample.paneMountedMs?.toFixed(1) ?? 'n/a'}ms`, + ` content restored ${sample.contentRestoredMs?.toFixed(1) ?? 'never'}ms`, + ` max frame gap ${sample.maxFrameGapMs}ms`, + ` long tasks total=${sample.longTaskTotalMs}ms worst=${sample.worstLongTaskMs}ms`, + ` panes at switch ${sample.mountedAtActivation}/${TABS_PER_WORKTREE}`, + ` settled resources managers=${sample.settledPaneManagers} panes=${sample.settledPanes} webgl=${sample.settledWebglContexts}` + ].join('\n') +} + +async function publish(testInfo: TestInfo, name: string, body: string): Promise { + console.log(body) + await testInfo.attach(name, { body, contentType: 'text/plain' }) +} + +// Why 8 extra: hot-retain keeps the 4 most recently hidden worktrees mounted and +// exempts the last-active one, so a target only cold-parks once enough other +// worktrees have been visited after it. That is the steady state at field scale. +const FILLER_WORKTREE_COUNT = Number(process.env.ORCA_SWITCH_FILLER_WORKTREES ?? '8') + +async function addFillerWorktrees( + page: Page, + testRepoPath: string +): Promise<{ ids: string[]; cleanup: () => void }> { + const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-switch-paint-'))) + const paths = Array.from({ length: FILLER_WORKTREE_COUNT }, (_, index) => + path.join(parent, `filler-${index}`) + ) + const removeAll = (): void => { + for (const worktreePath of paths) { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: testRepoPath, + stdio: 'ignore' + }) + } catch { + /* best effort */ + } + } + rmSync(parent, { recursive: true, force: true }) + } + // Why clean up before rethrowing: testRepoPath is worker-scoped and reused by + // later specs, so a half-built fixture would leak worktrees into them. + try { + for (const worktreePath of paths) { + execFileSync('git', ['worktree', 'add', '--detach', worktreePath, 'HEAD'], { + cwd: testRepoPath, + stdio: 'ignore' + }) + } + } catch (error) { + removeAll() + throw error + } + try { + return await registerFillerWorktrees(page, testRepoPath, paths, removeAll) + } catch (error) { + removeAll() + throw error + } +} + +async function registerFillerWorktrees( + page: Page, + testRepoPath: string, + paths: readonly string[], + cleanup: () => void +): Promise<{ ids: string[]; cleanup: () => void }> { + const repoId = await page.evaluate( + (repoPath) => + window.__store!.getState().repos.find((repo) => repo.path === repoPath)?.id ?? null, + testRepoPath + ) + if (!repoId) { + throw new Error(`seeded repo not registered: ${testRepoPath}`) + } + await loadWorktreesUntilPathsPresent(page, repoId, [...paths]) + const ids = await page.evaluate( + ({ id, wanted }) => + (window.__store!.getState().worktreesByRepo[id] ?? []) + .filter((worktree) => wanted.includes(worktree.path)) + .map((worktree) => worktree.id), + { id: repoId, wanted: paths } + ) + return { ids, cleanup } +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +// Linux needs a mapped window for animation frames after reload; run on an isolated display. +test.describe('Worktree switch first paint @headful', () => { + test.skip( + process.env.ORCA_BACKGROUND_LAUNCH === '1', + 'First-paint measurement requires a mapped window' + ) + test('repaints an unmounted worktree within the switch budget', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.setTimeout(900_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + const worktreeIds = await getAllWorktreeIds(orcaPage) + expect(worktreeIds.length).toBeGreaterThanOrEqual(2) + const [primaryId, targetId] = worktreeIds + const filler = await addFillerWorktrees(orcaPage, testRepoPath) + + const samples: SwitchSample[] = [] + const lines: string[] = [] + try { + const targetTabIds = await ensureTabs(orcaPage, targetId, 'WTB') + + // Give the filler worktrees persisted tabs without mounting them, so the + // store carries a field-scale tab population (the profile that motivated + // this budget has 846 tabs across 449 worktrees). + await orcaPage.evaluate( + ({ ids, perWorktree }) => { + const state = window.__store!.getState() + for (const id of ids) { + const existing = state.tabsByWorktree[id] ?? [] + for (let index = existing.length; index < perWorktree; index += 1) { + state.createTab(id) + } + } + }, + { ids: filler.ids, perWorktree: 2 } + ) + + for (let round = 0; round < SWITCH_SAMPLE_COUNT; round += 1) { + // Leave the primary active and let the session persist before reloading: + // startup restores the persisted active worktree, so this is what makes + // the target come back with tabs in the session and no pane ever mounted + // — the state every switch lands in once the worktree count exceeds the + // hot-retain working set. + await switchToWorktree(orcaPage, primaryId) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(2_500) + await orcaPage.reload() + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(2_500) + const unmounted = await waitForUnmountedTabs(orcaPage, targetTabIds) + expect(unmounted, 'target worktree was already mounted before the switch').toBe(true) + + const sample = await measureSwitch(orcaPage, targetId, targetTabIds) + samples.push(sample) + lines.push(report(`round ${round + 1} (target unmounted=${unmounted})`, sample)) + + // The half of the contract that keeps the speed-up free: the hidden tabs + // the switch skipped still end up mounted, so the next tab switch is as + // warm as it was before the reveal stopped mounting them up front. + const warmedTabIds = await waitForMountedTabs(orcaPage, targetTabIds) + expect(warmedTabIds, 'deferred tabs never joined the warm working set').toEqual( + [...targetTabIds].sort() + ) + } + } finally { + filler.cleanup() + } + + const restored = samples + .map((sample) => sample.contentRestoredMs) + .filter((value): value is number => value !== null) + expect(restored.length, 'revealed terminal never restored its content').toBe(samples.length) + const summary = [ + `first activation -> ${TABS_PER_WORKTREE}-tab worktree, ${samples.length} rounds`, + ` content restored: median=${median(restored).toFixed(1)}ms samples=${restored + .map((value) => value.toFixed(0)) + .join(', ')}ms`, + ` activation: median=${median( + samples.map((sample) => sample.activationMs ?? 0) + ).toFixed(1)}ms`, + ` panes at switch: ${samples.map((sample) => sample.mountedAtActivation).join(', ')}`, + ` settled panes: ${samples.map((sample) => sample.settledPanes).join(', ')}`, + ` settled webgl: ${samples.map((sample) => sample.settledWebglContexts).join(', ')}`, + '', + ...lines + ].join('\n') + await publish(testInfo, 'first-activation-switch.txt', summary) + + for (const sample of samples) { + expect( + sample.mountedAtActivation, + 'the switch mounted more than the pane the user is looking at' + ).toBe(1) + } + // Why CI is exempt from the budget and not from the invariants: shared + // runners cannot hold a latency threshold, but "the switch mounted one pane" + // and "the warm set came back" are exact and are the real regression guards. + if (process.env.CI) { + console.log( + `[switch-budget] CI run, latency budget not enforced (median ${median(restored).toFixed(1)}ms)` + ) + return + } + expect(median(restored)).toBeLessThanOrEqual(FIRST_PAINT_BUDGET_MS) + }) +}) diff --git a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs index 63b7222a93d..ffac29bddea 100644 --- a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs @@ -27,19 +27,19 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { createCompletedOnboardingProfile, safeRemoveLocalDirectory -} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const PARK_DELAY_MS = 1_500 const SETTLE_AFTER_PARK_MS = 4_000 // Short root so the daemon Unix socket fits under the macOS 104-char limit; diff --git a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs index a01cbf2cc3a..70bf1f66944 100644 --- a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs @@ -33,19 +33,19 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { createCompletedOnboardingProfile, safeRemoveLocalDirectory -} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const scenarioTimeoutMs = 300_000 // Short enough that a tab parks within a few seconds of being hidden, long diff --git a/tests/tools/benchmarks/terminal-perf-bench.mjs b/tests/tools/benchmarks/terminal-perf-bench.mjs index a73ffae6bc9..551f33d0433 100644 --- a/tests/tools/benchmarks/terminal-perf-bench.mjs +++ b/tests/tools/benchmarks/terminal-perf-bench.mjs @@ -19,16 +19,16 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' +import { safeRemoveLocalDirectory } from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const scenarioTimeoutMs = 300_000 const defaultIterations = 8 const defaultSwitches = 24 diff --git a/tests/tools/pi-owner-runtime-smoke.mjs b/tests/tools/pi-owner-runtime-smoke.mjs new file mode 100644 index 00000000000..204627340ac --- /dev/null +++ b/tests/tools/pi-owner-runtime-smoke.mjs @@ -0,0 +1,128 @@ +// Run: node tests/tools/pi-owner-runtime-smoke.mjs /path/to/pi-coding-agent +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' + +const piRoot = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Pass an installed pi-coding-agent package directory') +const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-owner-')) +const received = [] +const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) { + body += chunk + } + received.push(JSON.parse(body)) + response.end('{}') +}) +try { + const bundle = join(scratch, 'orca.cjs') + await build({ + stdin: { + contents: [ + "export { getPiAgentStatusExtensionSource } from './src/main/pi/agent-status-extension-source';", + "export { runProcess } from './src/shared/child-process/run-process';" + ].join('\n'), + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + packages: 'external' + }) + const { getPiAgentStatusExtensionSource, runProcess } = createRequire(import.meta.url)(bundle) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const dead = await runProcess({ + program: process.execPath, + args: ['-e', 'console.log(process.pid)'] + }) + assert.equal(dead.code, 0) + const deadPid = Number(dead.stdout.trim()) + assert.throws(() => process.kill(deadPid, 0), { code: 'ESRCH' }) + const worker = join(scratch, 'worker.mjs') + const moduleUrl = (file) => JSON.stringify(pathToFileURL(join(piRoot, file)).href) + await writeFile( + worker, + ` + import assert from 'node:assert/strict' + import { loadExtensions } from ${moduleUrl('dist/core/extensions/loader.js')} + import { ExtensionRunner } from ${moduleUrl('dist/core/extensions/runner.js')} + import { SessionManager } from ${moduleUrl('dist/core/session-manager.js')} + const loaded = await loadExtensions([process.argv[2]], process.cwd()) + assert.deepEqual(loaded.errors, []) + const runner = new ExtensionRunner(loaded.extensions, loaded.runtime, process.cwd(), SessionManager.inMemory(process.cwd()), undefined) + const errors = [] + runner.onError(error => errors.push(error)) + await runner.emit({ type: 'agent_start' }) + await new Promise(resolve => setTimeout(resolve, 250)) + assert.deepEqual(errors, []) + console.log(JSON.stringify({pid: process.pid, owner: process.env[process.argv[3]], handlers: loaded.extensions[0].handlers.size})) + ` + ) + const results = [] + for (const kind of ['pi', 'omp', 'prime-agent']) { + const ownerKey = + kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED' + for (const scenario of ['baseline-dead', 'fixed-dead', 'fixed-live']) { + let source = getPiAgentStatusExtensionSource(kind) + if (scenario === 'baseline-dead') { + const guard = 'if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return' + assert.ok( + source.includes(guard), + 'Baseline mutation must replace the actual ownership guard' + ) + source = source.replace(guard, 'if (ownerPid && ownerPid !== selfPid) return') + } + const extension = join(scratch, `${kind}-${scenario}.ts`) + await writeFile(extension, source) + const before = received.length + const owner = scenario === 'fixed-live' ? process.pid : deadPid + const child = await runProcess({ + program: process.execPath, + args: [worker, extension, ownerKey], + cwd: scratch, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_PANE_KEY: 'owner-proof', + ORCA_AGENT_HOOK_PORT: String(server.address().port), + ORCA_AGENT_HOOK_TOKEN: 'isolated-proof-token', + ORCA_AGENT_HOOK_ENV: 'proof', + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_PI_STATUS_OWNED: '', + ORCA_PRIME_AGENT_STATUS_OWNED: '', + PRIME_AGENT_INTERNAL_DAEMON_WORKER: kind === 'prime-agent' ? '1' : '', + [ownerKey]: String(owner) + }, + timeoutMs: 15000 + }) + assert.equal(child.code, 0, child.stderr) + const observation = JSON.parse(child.stdout.trim().split('\n').at(-1)) + const shouldReport = scenario === 'fixed-dead' + assert.equal( + received.length - before, + shouldReport ? 1 : 0, + `${kind}/${scenario}: HTTP delivery` + ) + assert.equal(observation.owner, String(shouldReport ? observation.pid : owner)) + assert.equal(observation.handlers > 0, shouldReport) + if (shouldReport) { + assert.equal(received.at(-1).payload.hook_event_name, 'agent_start') + } + results.push({ kind, scenario, posts: received.length - before, ...observation }) + } + } + console.log(JSON.stringify({ platform: process.platform, results }, null, 2)) +} finally { + server.closeAllConnections() + server.close() + await rm(scratch, { recursive: true, force: true }) +} diff --git a/tests/tools/pi-provider-runtime-smoke.mjs b/tests/tools/pi-provider-runtime-smoke.mjs new file mode 100644 index 00000000000..bda4f6a0f89 --- /dev/null +++ b/tests/tools/pi-provider-runtime-smoke.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +const piCli = process.argv[2] && resolve(process.argv[2]) +assert.ok(piCli, 'Pass the installed Pi CLI entrypoint') +const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-provider-')) +const requests = [] +const server = createServer(async (req, res) => { + let body = '' + for await (const part of req) { + body += part + } + requests.push(JSON.parse(body)) + res.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const chunk of [ + { + id: 'proof', + object: 'chat.completion.chunk', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'fixture-generated-commit' }, + finish_reason: null + } + ] + }, + { + id: 'proof', + object: 'chat.completion.chunk', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } + } + ]) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`) + } + res.end('data: [DONE]\n\n') +}) +try { + const bundle = join(scratch, 'orca.cjs') + await build({ + stdin: { + contents: + "export {planCommitMessageGeneration} from './src/shared/commit-message-plan'; export {runProcess} from './src/shared/child-process/run-process';", + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + packages: 'external' + }) + const { planCommitMessageGeneration, runProcess } = createRequire(import.meta.url)(bundle) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const dir = join(scratch, 'agent') + await mkdir(join(dir, 'extensions'), { recursive: true }) + await writeFile( + join(dir, 'extensions', 'provider.ts'), + `export default function(pi){pi.registerProvider('orca-proof',{name:'Proof',baseUrl:'http://127.0.0.1:${server.address().port}/v1',apiKey:'fixture-only',api:'openai-completions',models:[{id:'local',name:'Proof',reasoning:false,input:['text'],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:8192,maxTokens:256}]})}` + ) + await writeFile( + join(dir, 'settings.json'), + JSON.stringify({ defaultProvider: 'orca-proof', defaultModel: 'local' }) + ) + const planned = planCommitMessageGeneration( + { agentId: 'pi', model: 'orca-proof/local' }, + 'Generate one short commit message.' + ) + assert.equal(planned.ok, true) + const fixedArgs = planned.plan.args + assert.ok(!fixedArgs.includes('--no-extensions')) + const variants = [ + ['baseline', [...fixedArgs, '--no-extensions']], + ['extensions-enabled', fixedArgs] + ] + const results = [] + for (const [variant, args] of variants) { + const n = requests.length + const result = await runProcess({ + program: process.execPath, + args: [piCli, ...args], + cwd: scratch, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + HOME: scratch, + USERPROFILE: scratch, + ORCA_BACKGROUND_LAUNCH: '1', + PI_CODING_AGENT_DIR: dir + }, + input: planned.plan.stdinPayload, + timeoutMs: 20000 + }) + results.push({ + variant, + args, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + requests: requests.length - n + }) + } + assert.equal(results[0].requests, 0) + assert.notEqual(results[0].code, 0) + assert.equal(results[1].code, 0, results[1].stderr) + assert.match(results[1].stdout, /fixture-generated-commit/) + assert.equal(results[1].requests, 1) + console.log( + JSON.stringify( + { + scope: + 'Actual Pi CLI and production command planner; isolated extension provider with local OpenAI-compatible fixture.', + platform: process.platform, + results + }, + null, + 2 + ) + ) +} finally { + server.closeAllConnections() + server.close() + await rm(scratch, { recursive: true, force: true }) +} diff --git a/tests/tools/relay-bench/find-cell.mjs b/tests/tools/relay-bench/find-cell.mjs new file mode 100644 index 00000000000..cc48be054c9 --- /dev/null +++ b/tests/tools/relay-bench/find-cell.mjs @@ -0,0 +1,32 @@ +import { createRequire } from 'node:module' +const WebSocket = createRequire(import.meta.url)('ws') +const hostId = process.argv[2] +const bogus = 'A'.repeat(43) +const probe = (cell) => + new Promise((resolve) => { + const ws = new WebSocket(`wss://${cell}.relay.onorca.dev/v1/connect/${hostId}`, { + perMessageDeflate: false + }) + const t0 = performance.now() + const done = (r) => { + try { + ws.terminate() + } catch {} + resolve({ cell, ms: Math.round(performance.now() - t0), ...r }) + } + ws.on('open', () => + ws.send(JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential: bogus })) + ) + ws.on('message', (m) => done({ hello: JSON.parse(m.toString()).code })) + ws.on('error', (e) => done({ error: e.code ?? e.message })) + ws.on('close', (c) => done({ close: c })) + setTimeout(() => done({ error: 'timeout' }), 8000) + }) +const cells = Array.from({ length: 30 }, (_, i) => `c${i + 1}`) +const results = await Promise.all(cells.map(probe)) +for (const r of results) { + if (r.hello !== 4409 || process.argv[3]) { + console.log(JSON.stringify(r)) + } +} +console.log('probed', results.length, 'wrong-cell:', results.filter((r) => r.hello === 4409).length)